Compare commits
1 Commits
5b26a70a15
...
SEND041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44ef56ff3 |
@@ -4,23 +4,11 @@ matrix:
|
||||
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
|
||||
BFF_MONGO_SECRET_PATH: sendico/db
|
||||
BFF_API_SECRET_PATH: sendico/api/endpoint
|
||||
BFF_VAULT_SECRET_PATH: sendico/edge/bff/vault
|
||||
BFF_ENV: prod
|
||||
|
||||
when:
|
||||
- event: push
|
||||
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:
|
||||
- name: version
|
||||
@@ -47,14 +35,6 @@ steps:
|
||||
- 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 bff
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -75,7 +55,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/bff/build-image.sh
|
||||
|
||||
|
||||
@@ -8,14 +8,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/billing/documents/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/billing_documents.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -42,14 +34,6 @@ steps:
|
||||
- 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 billing_documents
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -70,7 +54,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/billing_documents/build-image.sh
|
||||
|
||||
|
||||
@@ -8,14 +8,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/billing/fees/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/billing_fees.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -42,14 +34,6 @@ steps:
|
||||
- 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 billing_fees
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -70,7 +54,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- 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:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
exclude: ['**']
|
||||
ignore_message: '[infra]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -7,14 +7,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/discovery/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/discovery.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -41,14 +33,6 @@ steps:
|
||||
- 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 discovery
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -69,7 +53,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/discovery/build-image.sh
|
||||
|
||||
|
||||
@@ -7,16 +7,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/edge/bff/**
|
||||
- api/pkg/**
|
||||
- api/proto/**
|
||||
- frontend/**
|
||||
- interface/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/frontend.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -11,15 +11,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/fx/ingestor/**
|
||||
- api/fx/storage/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/fx_ingestor.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -47,14 +38,6 @@ steps:
|
||||
- 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 fx_ingestor
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -75,7 +58,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/fx/build-image.sh
|
||||
|
||||
|
||||
@@ -11,16 +11,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/fx/oracle/**
|
||||
- api/fx/storage/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/fx_oracle.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -48,14 +38,6 @@ steps:
|
||||
- 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 fx_oracle
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -76,7 +58,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/fx/build-image.sh
|
||||
|
||||
|
||||
@@ -11,15 +11,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/gateway/chain/**
|
||||
- api/gateway/common/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/gateway_chain.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -46,14 +37,6 @@ steps:
|
||||
- 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 gateway_chain
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -74,7 +57,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/chain_gateway/build-image.sh
|
||||
|
||||
|
||||
@@ -10,15 +10,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/gateway/mntx/**
|
||||
- api/gateway/common/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/gateway_mntx.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -45,14 +36,6 @@ steps:
|
||||
- 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 gateway_mntx
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -73,7 +56,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/mntx/build-image.sh
|
||||
|
||||
|
||||
@@ -8,15 +8,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/gateway/tgsettle/**
|
||||
- api/gateway/common/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/gateway_tgsettle.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -43,14 +34,6 @@ steps:
|
||||
- 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 gateway_tgsettle
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -71,7 +54,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/tgsettle/build-image.sh
|
||||
|
||||
|
||||
@@ -11,15 +11,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/gateway/tron/**
|
||||
- api/gateway/common/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/gateway_tron.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -46,14 +37,6 @@ steps:
|
||||
- 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 gateway_tron
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -74,7 +57,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/tron_gateway/build-image.sh
|
||||
|
||||
|
||||
@@ -8,14 +8,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/ledger/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/ledger.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -42,14 +34,6 @@ steps:
|
||||
- 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 ledger
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -70,7 +54,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/ledger/build-image.sh
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
exclude: ['**']
|
||||
ignore_message: '[infra]'
|
||||
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -11,14 +11,6 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/notification/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/notification.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -45,14 +37,6 @@ steps:
|
||||
- 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 notification
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -73,7 +57,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- 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:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/payments/orchestrator/**
|
||||
- api/payments/storage/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
- ci/prod/**
|
||||
- .woodpecker/payments_orchestrator.yml
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
@@ -43,14 +34,6 @@ steps:
|
||||
- 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_orchestrator
|
||||
|
||||
- name: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
@@ -71,7 +54,7 @@ steps:
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ backend-tests, secrets ]
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- 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
|
||||
# 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
|
||||
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
|
||||
GREEN := \033[0;32m
|
||||
@@ -52,30 +31,18 @@ help:
|
||||
@echo "$(YELLOW)Selective Operations:$(NC)"
|
||||
@echo " make infra-up Start infrastructure only (mongo, nats, vault)"
|
||||
@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 ""
|
||||
@echo "$(YELLOW)Build Groups:$(NC)"
|
||||
@echo " make build-core Build core services (discovery, ledger, fees, documents)"
|
||||
@echo " make build-fx Build FX services (oracle, ingestor)"
|
||||
@echo " make build-payments Build payment orchestrator"
|
||||
@echo " make build-gateways Build gateway services (chain, tron, aurora, tgsettle)"
|
||||
@echo " make build-api Build API services (notification, callbacks, bff)"
|
||||
@echo " make build-gateways Build gateway services (chain, tron, mntx, tgsettle)"
|
||||
@echo " make build-api Build API services (notification, bff)"
|
||||
@echo " make build-frontend Build Flutter web frontend"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Development:$(NC)"
|
||||
@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 ""
|
||||
@echo "Examples:"
|
||||
@@ -109,7 +76,7 @@ init:
|
||||
@echo "$(GREEN)Verifying .env.dev...$(NC)"
|
||||
@cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1)
|
||||
@echo "$(GREEN)Running proto generation...$(NC)"
|
||||
@./ci/scripts/proto/generate.sh
|
||||
@./generate_protos.sh
|
||||
@echo "$(GREEN)Building Docker images...$(NC)"
|
||||
@$(COMPOSE) build
|
||||
@echo "$(GREEN)✅ Initialization complete!$(NC)"
|
||||
@@ -121,7 +88,7 @@ init:
|
||||
# Build all images
|
||||
build:
|
||||
@echo "$(GREEN)Building all service images...$(NC)"
|
||||
@./ci/scripts/proto/generate.sh
|
||||
@./generate_protos.sh
|
||||
@$(COMPOSE) build
|
||||
|
||||
# Start all services
|
||||
@@ -169,25 +136,12 @@ endif
|
||||
@echo "$(GREEN)✅ $(SERVICE) rebuilt$(NC)"
|
||||
@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-api:
|
||||
proto:
|
||||
@echo "$(GREEN)Generating protobuf code...$(NC)"
|
||||
@./ci/scripts/proto/generate.sh
|
||||
@./generate_protos.sh
|
||||
@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:
|
||||
@echo "$(YELLOW)WARNING: This will remove all containers and volumes!$(NC)"
|
||||
@@ -242,32 +196,14 @@ services-up:
|
||||
dev-billing-documents \
|
||||
dev-ledger \
|
||||
dev-payments-orchestrator \
|
||||
dev-payments-quotation \
|
||||
dev-payments-methods \
|
||||
dev-chain-gateway \
|
||||
dev-tron-gateway \
|
||||
dev-aurora-gateway \
|
||||
dev-mntx-gateway \
|
||||
dev-tgsettle-gateway \
|
||||
dev-notification \
|
||||
dev-callbacks \
|
||||
dev-bff \
|
||||
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:
|
||||
@$(COMPOSE) ps
|
||||
@@ -287,14 +223,11 @@ list-services:
|
||||
@echo " - dev-billing-documents :50061, :9409 (Billing Documents)"
|
||||
@echo " - dev-ledger :50052, :9401 (Double-Entry Ledger)"
|
||||
@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-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-notification :8081 (Notifications)"
|
||||
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
|
||||
@echo " - dev-bff :8080 (Backend for Frontend)"
|
||||
@echo " - dev-frontend :3000 (Flutter Web UI)"
|
||||
|
||||
@@ -318,59 +251,16 @@ build-fx:
|
||||
|
||||
build-payments:
|
||||
@echo "$(GREEN)Building payment services...$(NC)"
|
||||
@$(COMPOSE) build dev-payments-orchestrator dev-payments-quotation dev-payments-methods
|
||||
@$(COMPOSE) build dev-payments-orchestrator
|
||||
|
||||
build-gateways:
|
||||
@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:
|
||||
@echo "$(GREEN)Building API services...$(NC)"
|
||||
@$(COMPOSE) build dev-notification dev-callbacks dev-bff
|
||||
@$(COMPOSE) build dev-notification dev-bff
|
||||
|
||||
build-frontend:
|
||||
@echo "$(GREEN)Building frontend...$(NC)"
|
||||
@$(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)"
|
||||
|
||||
176
README.md
176
README.md
@@ -1,176 +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 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:
|
||||
legal_name: "Sendico Ltd"
|
||||
legal_address: "12 Market Street, London, UK"
|
||||
logo_path: "assets/logo.png"
|
||||
logo_path: "/assets/logo.png"
|
||||
templates:
|
||||
acceptance_path: "templates/acceptance.tpl"
|
||||
protection:
|
||||
|
||||
@@ -26,9 +26,9 @@ documents:
|
||||
issuer:
|
||||
legal_name: "Sendico Ltd"
|
||||
legal_address: "12 Market Street, London, UK"
|
||||
logo_path: "/app/assets/logo.png"
|
||||
logo_path: "/assets/logo.png"
|
||||
templates:
|
||||
acceptance_path: "/app/templates/acceptance.tpl"
|
||||
acceptance_path: "templates/acceptance.tpl"
|
||||
protection:
|
||||
owner_password: "sendico-documents"
|
||||
storage:
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
module github.com/tech/sendico/billing/documents
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.6
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 // 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/checksum v1.9.11 // 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/s3shared v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // 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.17 // 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.17 // 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.17 // 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.8 // 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.17 // 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.9 // 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.6 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v4 v4.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/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-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // 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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
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.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
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.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
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/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
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.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
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.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
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.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
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.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
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.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||
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.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
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.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
|
||||
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.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
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.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||
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.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
|
||||
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.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
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.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
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.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
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.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
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.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
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.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||
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.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||
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.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||
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.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
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.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
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.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
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.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||
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.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
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.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||
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.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
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.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||
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.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||
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.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||
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.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||
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.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||
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.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
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.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
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/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/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/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -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=
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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-20220722155257-8c9f86f7a55f/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.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
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-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -24,6 +24,5 @@ func Create() version.Printer {
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return vf.Create(&info)
|
||||
}
|
||||
|
||||
@@ -21,13 +21,11 @@ func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error)
|
||||
if root == "" {
|
||||
return nil, merrors.InvalidArgument("docstore: local root_path is empty")
|
||||
}
|
||||
|
||||
store := &LocalStore{
|
||||
logger: logger.Named("docstore").Named("local"),
|
||||
rootPath: root,
|
||||
}
|
||||
store.logger.Info("Document storage initialised", zap.String("root_path", root))
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||
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))
|
||||
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,16 +49,12 @@ func (s *LocalStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
if accessKey == "" && cfg.AccessKeyEnv != "" {
|
||||
accessKey = strings.TrimSpace(os.Getenv(cfg.AccessKeyEnv))
|
||||
}
|
||||
|
||||
secretKey := strings.TrimSpace(cfg.SecretAccessKey)
|
||||
if secretKey == "" && 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
|
||||
}
|
||||
}
|
||||
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...)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
|
||||
opts.UsePathStyle = cfg.ForcePathStyle
|
||||
|
||||
if endpoint != "" {
|
||||
opts.BaseEndpoint = aws.String(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
store := &S3Store{
|
||||
@@ -86,7 +87,6 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
bucket: bucket,
|
||||
}
|
||||
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
@@ -102,10 +101,8 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,19 +110,15 @@ func (s *S3Store) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch document", zap.Error(err), zap.String("key", key))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer obj.Body.Close()
|
||||
|
||||
return io.ReadAll(obj.Body)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ type S3Config struct {
|
||||
Bucket string `yaml:"bucket"`
|
||||
AccessKeyEnv string `yaml:"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"`
|
||||
UseSSL bool `yaml:"use_ssl"`
|
||||
ForcePathStyle bool `yaml:"force_path_style"`
|
||||
@@ -55,13 +55,11 @@ func New(logger mlogger.Logger, cfg Config) (Store, error) {
|
||||
if cfg.Local == nil {
|
||||
return nil, merrors.InvalidArgument("docstore: local config missing")
|
||||
}
|
||||
|
||||
return NewLocalStore(logger, *cfg.Local)
|
||||
case string(DriverS3), string(DriverMinio):
|
||||
if cfg.S3 == nil {
|
||||
return nil, merrors.InvalidArgument("docstore: s3 config missing")
|
||||
}
|
||||
|
||||
return NewS3Store(logger, *cfg.S3)
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("docstore: unsupported driver")
|
||||
|
||||
@@ -29,8 +29,7 @@ type Imp struct {
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
|
||||
Documents documents.Config `yaml:"documents"`
|
||||
Documents documents.Config `yaml:"documents"`
|
||||
}
|
||||
|
||||
// Create initialises the billing documents server implementation.
|
||||
@@ -47,7 +46,6 @@ func (i *Imp) Shutdown() {
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +68,6 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.config = cfg
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to initialise document storage", zap.Error(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 := ""
|
||||
if cfg.GRPC != nil {
|
||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||
}
|
||||
|
||||
svc := documents.NewService(logger, repo, producer,
|
||||
documents.WithDiscoveryInvokeURI(invokeURI),
|
||||
documents.WithConfig(cfg.Documents),
|
||||
documents.WithDocumentStore(docStore),
|
||||
)
|
||||
i.service = svc
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
@@ -104,7 +98,6 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
@@ -114,14 +107,12 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &config{Config: &grpcapp.Config{}}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,5 @@ func (c Config) AcceptanceTemplatePath() string {
|
||||
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
||||
return "templates/acceptance.tpl"
|
||||
}
|
||||
|
||||
return c.Templates.AcceptancePath
|
||||
}
|
||||
|
||||
@@ -85,18 +85,14 @@ func statusFromError(err error) string {
|
||||
if err == nil {
|
||||
return "success"
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return "error"
|
||||
}
|
||||
|
||||
code := st.Code()
|
||||
|
||||
if code == codes.OK {
|
||||
return "success"
|
||||
}
|
||||
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
|
||||
@@ -105,6 +101,5 @@ func docTypeLabel(docType documentsv1.DocumentType) string {
|
||||
if label == "" {
|
||||
return "DOCUMENT_TYPE_UNSPECIFIED"
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -40,7 +41,6 @@ func WithDiscoveryInvokeURI(uri string) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.invokeURI = strings.TrimSpace(uri)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,6 @@ func WithProducer(producer msg.Producer) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.producer = producer
|
||||
}
|
||||
}
|
||||
@@ -62,7 +61,6 @@ func WithConfig(cfg Config) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.config = cfg
|
||||
}
|
||||
}
|
||||
@@ -73,7 +71,6 @@ func WithDocumentStore(store docstore.Store) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.docStore = store
|
||||
}
|
||||
}
|
||||
@@ -84,15 +81,12 @@ func WithTemplateRenderer(renderer TemplateRenderer) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.template = renderer
|
||||
}
|
||||
}
|
||||
|
||||
// Service provides billing document metadata and retrieval endpoints.
|
||||
type Service struct {
|
||||
documentsv1.UnimplementedDocumentServiceServer
|
||||
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
docStore docstore.Store
|
||||
@@ -101,12 +95,12 @@ type Service struct {
|
||||
invokeURI string
|
||||
config Config
|
||||
template TemplateRenderer
|
||||
documentsv1.UnimplementedDocumentServiceServer
|
||||
}
|
||||
|
||||
// NewService constructs a documents service with optional configuration.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||
initMetrics()
|
||||
|
||||
svc := &Service{
|
||||
logger: logger.Named("documents"),
|
||||
storage: repo,
|
||||
@@ -115,17 +109,14 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
|
||||
if svc.template == 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 {
|
||||
svc.template = tmpl
|
||||
}
|
||||
}
|
||||
|
||||
svc.startDiscoveryAnnouncer()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -139,50 +130,107 @@ func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_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) {
|
||||
start := time.Now()
|
||||
paymentRefs := 0
|
||||
var paymentRefs []string
|
||||
if req != nil {
|
||||
paymentRefs = len(req.GetPaymentRefs())
|
||||
paymentRefs = req.GetPaymentRefs()
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.Int("payment_refs", paymentRefs))
|
||||
|
||||
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||
observeBatchSize(paymentRefs)
|
||||
observeBatchSize(len(paymentRefs))
|
||||
|
||||
itemsCount := 0
|
||||
if resp != nil {
|
||||
itemsCount = len(resp.GetItems())
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Int("items", itemsCount),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("BatchResolveDocuments finished", fields...)
|
||||
}()
|
||||
|
||||
_ = ctx
|
||||
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||
if len(paymentRefs) == 0 {
|
||||
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) {
|
||||
@@ -193,7 +241,6 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
docType = req.GetType()
|
||||
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
||||
}
|
||||
|
||||
logger := s.logger.With(
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("document_type", docTypeLabel(docType)),
|
||||
@@ -202,7 +249,6 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
||||
|
||||
if resp != nil {
|
||||
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||
}
|
||||
@@ -211,131 +257,93 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
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("GetDocument failed", append(fields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("GetDocument finished", fields...)
|
||||
}()
|
||||
|
||||
_ = ctx
|
||||
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||
|
||||
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 paymentRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "payment_ref is required")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger := s.logger.With(
|
||||
zap.String("organization_ref", organizationRef),
|
||||
zap.String("gateway_service", gatewayService),
|
||||
zap.String("operation_ref", operationRef),
|
||||
)
|
||||
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
|
||||
|
||||
if resp != nil {
|
||||
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
|
||||
err = status.Error(codes.InvalidArgument, "document type is required")
|
||||
return nil, err
|
||||
}
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
return nil, err
|
||||
}
|
||||
if s.docStore == nil {
|
||||
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
|
||||
return nil, err
|
||||
}
|
||||
if s.template == nil {
|
||||
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if organizationRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "organization_ref is required")
|
||||
record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef)
|
||||
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 == "" {
|
||||
err = status.Error(codes.InvalidArgument, "gateway_service is required")
|
||||
|
||||
return nil, err
|
||||
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
|
||||
content, loadErr := s.docStore.Load(ctx, path)
|
||||
if loadErr != nil {
|
||||
return nil, status.Error(codes.Internal, loadErr.Error())
|
||||
}
|
||||
return &documentsv1.GetDocumentResponse{
|
||||
Content: content,
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
MimeType: "application/pdf",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if operationRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "operation_ref is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := operationSnapshotFromRequest(req)
|
||||
content, _, genErr := s.generateOperationPDF(snapshot)
|
||||
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
||||
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{
|
||||
Content: content,
|
||||
Filename: operationDocumentFilename(operationRef),
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
MimeType: "application/pdf",
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
@@ -353,27 +361,15 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
||||
if err != nil {
|
||||
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{
|
||||
Issuer: s.config.Issuer,
|
||||
OwnerPassword: s.config.Protection.OwnerPassword,
|
||||
}
|
||||
|
||||
placeholder := strings.Repeat("0", 64)
|
||||
|
||||
firstPass, err := generated.Render(blocks, placeholder)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
footerHash := sha256.Sum256(firstPass)
|
||||
footerHex := hex.EncodeToString(footerHash[:])
|
||||
|
||||
@@ -381,177 +377,22 @@ func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, strin
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(types) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]documentsv1.DocumentType, 0, len(types))
|
||||
for _, t := range types {
|
||||
result = append(result, t.Proto())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
|
||||
suffix := "document.pdf"
|
||||
|
||||
switch docType {
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||
suffix = "act.pdf"
|
||||
@@ -559,16 +400,12 @@ func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) st
|
||||
suffix = "invoice.pdf"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||
suffix = "receipt.pdf"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||
// default suffix used
|
||||
}
|
||||
|
||||
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
|
||||
}
|
||||
|
||||
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
|
||||
name := "document"
|
||||
|
||||
switch docType {
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||
name = "act"
|
||||
@@ -576,9 +413,6 @@ func documentFilename(docType documentsv1.DocumentType, paymentRef string) strin
|
||||
name = "invoice"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||
name = "receipt"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||
// default name used
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
||||
}
|
||||
|
||||
@@ -12,15 +12,13 @@ import (
|
||||
"github.com/tech/sendico/billing/documents/storage/model"
|
||||
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type stubRepo struct {
|
||||
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 }
|
||||
|
||||
var _ storage.Repository = (*stubRepo)(nil)
|
||||
@@ -30,24 +28,22 @@ type stubDocumentsStore struct {
|
||||
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
|
||||
|
||||
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.updateCalls++
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -63,21 +59,19 @@ func newMemDocStore() *memDocStore {
|
||||
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++
|
||||
copyData := make([]byte, len(data))
|
||||
copy(copyData, data)
|
||||
m.data[key] = copyData
|
||||
|
||||
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++
|
||||
data := m.data[key]
|
||||
copyData := make([]byte, len(data))
|
||||
copy(copyData, data)
|
||||
|
||||
return copyData, nil
|
||||
}
|
||||
|
||||
@@ -90,13 +84,14 @@ type stubTemplate struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
|
||||
func (s *stubTemplate) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||
s.calls++
|
||||
|
||||
return s.blocks, nil
|
||||
}
|
||||
|
||||
func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
||||
func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
snapshot := model.ActSnapshot{
|
||||
PaymentID: "PAY-123",
|
||||
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
|
||||
@@ -105,6 +100,14 @@ func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
record := &model.DocumentRecord{
|
||||
PaymentRef: "PAY-123",
|
||||
Snapshot: snapshot,
|
||||
}
|
||||
|
||||
documentsStore := &stubDocumentsStore{record: record}
|
||||
repo := &stubRepo{store: documentsStore}
|
||||
store := newMemDocStore()
|
||||
tmpl := &stubTemplate{
|
||||
blocks: []renderer.Block{
|
||||
{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),
|
||||
WithDocumentStore(store),
|
||||
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 {
|
||||
t.Fatalf("generateActPDF first call: %v", err)
|
||||
t.Fatalf("GetDocument first call: %v", err)
|
||||
}
|
||||
|
||||
if len(pdf1) == 0 {
|
||||
if len(resp1.Content) == 0 {
|
||||
t.Fatalf("expected content on first call")
|
||||
}
|
||||
|
||||
if hash1 == "" {
|
||||
t.Fatalf("expected non-empty hash on first call")
|
||||
stored := record.Hashes[model.DocumentTypeAct]
|
||||
if stored == "" {
|
||||
t.Fatalf("expected stored hash")
|
||||
}
|
||||
|
||||
footerHash := extractFooterHash(pdf1)
|
||||
|
||||
footerHash := extractFooterHash(resp1.Content)
|
||||
if footerHash == "" {
|
||||
t.Fatalf("expected footer hash in PDF")
|
||||
}
|
||||
|
||||
if hash1 != footerHash {
|
||||
t.Fatalf("stored hash mismatch: got %s", hash1)
|
||||
if stored != footerHash {
|
||||
t.Fatalf("stored hash mismatch: got %s", stored)
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("generateActPDF second call: %v", err)
|
||||
t.Fatalf("GetDocument second call: %v", err)
|
||||
}
|
||||
if hash2 == "" {
|
||||
t.Fatalf("expected non-empty hash on second call")
|
||||
if !bytes.Equal(resp1.Content, resp2.Content) {
|
||||
t.Fatalf("expected identical PDF bytes on second call")
|
||||
}
|
||||
footerHash2 := extractFooterHash(pdf2)
|
||||
if footerHash2 == "" {
|
||||
t.Fatalf("expected footer hash in second PDF")
|
||||
|
||||
if tmpl.calls != 1 {
|
||||
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
|
||||
}
|
||||
if footerHash2 != hash2 {
|
||||
t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
|
||||
if store.saveCount != 1 {
|
||||
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 {
|
||||
prefix := []byte("Document integrity hash: ")
|
||||
idx := bytes.Index(pdf, prefix)
|
||||
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
start := idx + len(prefix)
|
||||
|
||||
end := start
|
||||
|
||||
for end < len(pdf) && isHexDigit(pdf[end]) {
|
||||
end++
|
||||
}
|
||||
|
||||
if end-start != 64 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(pdf[start:end])
|
||||
}
|
||||
|
||||
func isHexDigit(b byte) bool {
|
||||
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 {
|
||||
return nil, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
return renderer.ParseBlocks(buf.String())
|
||||
}
|
||||
|
||||
@@ -50,7 +49,6 @@ func formatMoney(amount decimal.Decimal, currency string) string {
|
||||
if currency == "" {
|
||||
return amount.String()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s", amount.String(), currency)
|
||||
}
|
||||
|
||||
@@ -58,6 +56,5 @@ func formatDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package documents
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
|
||||
func TestTemplateRenderer_Render(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "..", "templates", "acceptance.tpl")
|
||||
|
||||
tmpl, err := newTemplateRenderer(path)
|
||||
if err != nil {
|
||||
t.Fatalf("newTemplateRenderer: %v", err)
|
||||
@@ -31,18 +29,22 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
t.Fatalf("expected blocks, got none")
|
||||
}
|
||||
|
||||
title := findBlock(blocks, renderer.TagTitle)
|
||||
|
||||
if title == nil {
|
||||
t.Fatalf("expected title block")
|
||||
}
|
||||
|
||||
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
|
||||
foundTitle := false
|
||||
for _, line := range title.Lines {
|
||||
if line == "ACT OF ACCEPTANCE OF SERVICES" {
|
||||
foundTitle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundTitle {
|
||||
t.Fatalf("expected title content not found")
|
||||
}
|
||||
|
||||
@@ -50,17 +52,13 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if kv == nil {
|
||||
t.Fatalf("expected kv block")
|
||||
}
|
||||
|
||||
foundExecutor := false
|
||||
|
||||
for _, row := range kv.Rows {
|
||||
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
|
||||
foundExecutor = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundExecutor {
|
||||
t.Fatalf("expected executor name in kv block")
|
||||
}
|
||||
@@ -69,17 +67,13 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if table == nil {
|
||||
t.Fatalf("expected table block")
|
||||
}
|
||||
|
||||
foundAmount := false
|
||||
|
||||
for _, row := range table.Rows {
|
||||
if len(row) >= 2 && row[1] == "123.45 USD" {
|
||||
foundAmount = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundAmount {
|
||||
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 nil
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64)
|
||||
if logoWidth > 0 {
|
||||
textX = startX + logoWidth + 6
|
||||
}
|
||||
|
||||
pdf.SetXY(textX, startY)
|
||||
pdf.SetFont("Helvetica", "B", 12)
|
||||
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()
|
||||
|
||||
if logoWidth > 0 {
|
||||
logoBottom := startY + logoWidth
|
||||
if logoBottom > currentY {
|
||||
|
||||
@@ -2,6 +2,7 @@ package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
@@ -38,22 +39,18 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
||||
pdf.SetFooterFunc(func() {
|
||||
pdf.SetY(-15)
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
|
||||
footer := "Document integrity hash: " + footerHash
|
||||
footer := fmt.Sprintf("Document integrity hash: %s", footerHash)
|
||||
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
||||
})
|
||||
|
||||
pdf.AddPage()
|
||||
|
||||
if _, err := drawHeader(pdf, r.Issuer, pageMarginLeft, pageMarginTop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pdf.Ln(6)
|
||||
|
||||
for _, block := range blocks {
|
||||
renderBlock(pdf, block)
|
||||
|
||||
if pdf.Error() != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -73,64 +69,47 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||
pdf.Ln(6)
|
||||
case TagTitle:
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(4)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 7, line, "", 1, "C", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
case TagSubtitle:
|
||||
pdf.SetFont("Helvetica", "", 11)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(3)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 6, line, "", 1, "C", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
case TagMeta:
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(2)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 4.5, line, "", 1, "R", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
case TagSection:
|
||||
pdf.Ln(2)
|
||||
pdf.SetFont("Helvetica", "B", 11)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(3)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 6, line, "", 1, "L", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(1)
|
||||
case TagText:
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||
pdf.Ln(1)
|
||||
@@ -140,14 +119,12 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||
renderTable(pdf, block)
|
||||
case TagSign:
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 6, text, "", "L", false)
|
||||
pdf.Ln(2)
|
||||
default:
|
||||
// Unknown tag: treat as plain text for resilience.
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||
pdf.Ln(1)
|
||||
@@ -156,7 +133,6 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||
|
||||
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
usable := usableWidth(pdf)
|
||||
keyWidth := math.Round(usable * 0.35)
|
||||
valueWidth := usable - keyWidth
|
||||
@@ -166,14 +142,11 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||
if len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := row[0]
|
||||
|
||||
value := ""
|
||||
if len(row) > 1 {
|
||||
value = row[1]
|
||||
}
|
||||
|
||||
x := pdf.GetX()
|
||||
y := pdf.GetY()
|
||||
|
||||
@@ -189,7 +162,6 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||
|
||||
pdf.SetY(maxFloat(leftY, rightY))
|
||||
}
|
||||
|
||||
pdf.Ln(1)
|
||||
}
|
||||
|
||||
@@ -197,7 +169,6 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
if len(block.Rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
usable := usableWidth(pdf)
|
||||
col1 := math.Round(usable * 0.7)
|
||||
col2 := usable - col1
|
||||
@@ -205,11 +176,9 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
|
||||
header := block.Rows[0]
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
|
||||
if len(header) > 0 {
|
||||
pdf.CellFormat(col1, lineHeight, header[0], "1", 0, "L", false, 0, "")
|
||||
}
|
||||
|
||||
if len(header) > 1 {
|
||||
pdf.CellFormat(col2, lineHeight, header[1], "1", 1, "R", false, 0, "")
|
||||
} else {
|
||||
@@ -217,19 +186,15 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
}
|
||||
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
for _, row := range block.Rows[1:] {
|
||||
colA := ""
|
||||
colB := ""
|
||||
|
||||
if len(row) > 0 {
|
||||
colA = row[0]
|
||||
}
|
||||
|
||||
if len(row) > 1 {
|
||||
colB = row[1]
|
||||
}
|
||||
|
||||
x := pdf.GetX()
|
||||
y := pdf.GetY()
|
||||
pdf.MultiCell(col1, lineHeight, colA, "1", "L", false)
|
||||
@@ -239,14 +204,12 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
rightY := pdf.GetY()
|
||||
pdf.SetY(maxFloat(leftY, rightY))
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
}
|
||||
|
||||
func usableWidth(pdf *gofpdf.Fpdf) float64 {
|
||||
pageW, _ := pdf.GetPageSize()
|
||||
left, _, right, _ := pdf.GetMargins()
|
||||
|
||||
return pageW - left - right
|
||||
}
|
||||
|
||||
@@ -254,6 +217,5 @@ func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -26,13 +26,11 @@ func TestRenderer_RenderContainsText(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
if len(pdfBytes) == 0 {
|
||||
t.Fatalf("expected PDF bytes")
|
||||
}
|
||||
|
||||
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
|
||||
|
||||
for _, token := range checks {
|
||||
if !containsPDFText(pdfBytes, 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)) {
|
||||
return true
|
||||
}
|
||||
|
||||
hexText := hex.EncodeToString([]byte(text))
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(hexText))) {
|
||||
return true
|
||||
}
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(hexText))) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16Bytes := encodeUTF16BE(text, false)
|
||||
|
||||
if bytes.Contains(pdfBytes, utf16Bytes) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16Hex := hex.EncodeToString(utf16Bytes)
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16Hex))) {
|
||||
return true
|
||||
}
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16Hex))) {
|
||||
return true
|
||||
}
|
||||
@@ -75,33 +66,25 @@ func containsPDFText(pdfBytes []byte, text string) bool {
|
||||
if bytes.Contains(pdfBytes, utf16BytesBOM) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16HexBOM := hex.EncodeToString(utf16BytesBOM)
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16HexBOM))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16HexBOM)))
|
||||
}
|
||||
|
||||
func encodeUTF16BE(text string, withBOM bool) []byte {
|
||||
encoded := utf16.Encode([]rune(text))
|
||||
length := len(encoded) * 2
|
||||
|
||||
if withBOM {
|
||||
length += 2
|
||||
}
|
||||
|
||||
out := make([]byte, 0, length)
|
||||
|
||||
if withBOM {
|
||||
out = append(out, 0xFE, 0xFF)
|
||||
}
|
||||
|
||||
for _, v := range encoded {
|
||||
out = append(out, byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ type Block struct {
|
||||
func ParseBlocks(input string) ([]Block, error) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
blocks := make([]Block, 0)
|
||||
|
||||
var current *Block
|
||||
|
||||
flush := func() {
|
||||
@@ -45,24 +44,17 @@ func ParseBlocks(input string) ([]Block, error) {
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
flush()
|
||||
|
||||
tag := Tag(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
||||
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if tag == TagSpacer {
|
||||
blocks = append(blocks, Block{Tag: TagSpacer})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
current = &Block{Tag: tag}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -70,19 +62,16 @@ func ParseBlocks(input string) ([]Block, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch current.Tag { //nolint:exhaustive // only KV and Table need row parsing
|
||||
switch current.Tag {
|
||||
case TagKV, TagTable:
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(line, "|")
|
||||
|
||||
row := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
row = append(row, strings.TrimSpace(part))
|
||||
}
|
||||
|
||||
current.Rows = append(current.Rows, row)
|
||||
default:
|
||||
current.Lines = append(current.Lines, line)
|
||||
@@ -94,6 +83,5 @@ func ParseBlocks(input string) ([]Block, error) {
|
||||
}
|
||||
|
||||
flush()
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ func DocumentTypeFromProto(t documentsv1.DocumentType) DocumentType {
|
||||
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
|
||||
return DocumentType(name)
|
||||
}
|
||||
|
||||
return DocumentTypeUnspecified
|
||||
}
|
||||
|
||||
@@ -37,24 +36,22 @@ func (t DocumentType) Proto() documentsv1.DocumentType {
|
||||
if value, ok := documentsv1.DocumentType_value[string(t)]; ok {
|
||||
return documentsv1.DocumentType(value)
|
||||
}
|
||||
|
||||
return documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||
}
|
||||
|
||||
// ActSnapshot captures the immutable data needed to generate an acceptance act.
|
||||
type ActSnapshot struct {
|
||||
PaymentID string `bson:"paymentId" json:"paymentId"`
|
||||
Date time.Time `bson:"date" json:"date"`
|
||||
PaymentID string `bson:"paymentId" json:"paymentId"`
|
||||
Date time.Time `bson:"date" json:"date"`
|
||||
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
|
||||
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
||||
Currency string `bson:"currency" json:"currency"`
|
||||
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
||||
Currency string `bson:"currency" json:"currency"`
|
||||
}
|
||||
|
||||
func (s *ActSnapshot) Normalize() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.PaymentID = strings.TrimSpace(s.PaymentID)
|
||||
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
|
||||
s.Currency = strings.TrimSpace(s.Currency)
|
||||
@@ -63,25 +60,21 @@ func (s *ActSnapshot) Normalize() {
|
||||
// DocumentRecord stores document metadata and cached artefacts for a payment.
|
||||
type DocumentRecord struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
|
||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
|
||||
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
|
||||
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
|
||||
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
|
||||
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
||||
}
|
||||
|
||||
func (r *DocumentRecord) Normalize() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.PaymentRef = strings.TrimSpace(r.PaymentRef)
|
||||
r.Snapshot.Normalize()
|
||||
|
||||
if r.StoragePaths == nil {
|
||||
r.StoragePaths = map[DocumentType]string{}
|
||||
}
|
||||
|
||||
if r.Hashes == nil {
|
||||
r.Hashes = map[DocumentType]string{}
|
||||
}
|
||||
|
||||
@@ -42,22 +42,18 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
defer cancel()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
documentsStore, err := store.NewDocuments(result.logger, database)
|
||||
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
|
||||
}
|
||||
|
||||
result.documents = documentsStore
|
||||
|
||||
result.logger.Info("Billing documents MongoDB storage initialised")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,14 +38,13 @@ func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error)
|
||||
|
||||
for _, def := range indexes {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
childLogger := logger.Named("documents")
|
||||
childLogger.Debug("Documents store initialised")
|
||||
childLogger.Debug("documents store initialised")
|
||||
|
||||
return &Documents{
|
||||
logger: childLogger,
|
||||
@@ -57,9 +56,7 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("documentsStore: nil record")
|
||||
}
|
||||
|
||||
record.Normalize()
|
||||
|
||||
if record.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) {
|
||||
return storage.ErrDuplicateDocument
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -82,21 +76,17 @@ func (d *Documents) Update(ctx context.Context, record *model.DocumentRecord) er
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("documentsStore: nil record")
|
||||
}
|
||||
|
||||
if record.ID.IsZero() {
|
||||
return merrors.InvalidArgument("documentsStore: missing record id")
|
||||
}
|
||||
|
||||
record.Normalize()
|
||||
record.Update()
|
||||
if err := d.repo.Update(ctx, record); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return storage.ErrDocumentNotFound
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -111,10 +101,8 @@ func (d *Documents) GetByPaymentRef(ctx context.Context, paymentRef string) (*mo
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrDocumentNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
@@ -125,34 +113,26 @@ func (d *Documents) ListByPaymentRefs(ctx context.Context, paymentRefs []string)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
refs = append(refs, clean)
|
||||
}
|
||||
|
||||
if len(refs) == 0 {
|
||||
return []*model.DocumentRecord{}, nil
|
||||
}
|
||||
|
||||
query := repository.Query().Comparison(repository.Field("paymentRef"), builder.In, refs)
|
||||
records := make([]*model.DocumentRecord, 0)
|
||||
|
||||
decoder := func(cur *mongo.Cursor) error {
|
||||
var rec model.DocumentRecord
|
||||
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
|
||||
}
|
||||
|
||||
records = append(records, &rec)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.6
|
||||
|
||||
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/pkg v0.1.0
|
||||
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
|
||||
)
|
||||
|
||||
@@ -25,31 +25,31 @@ require (
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.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/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-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // 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/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
|
||||
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
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/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
@@ -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/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -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=
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
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/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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-20220722155257-8c9f86f7a55f/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.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
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-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -24,6 +24,5 @@ func Create() version.Printer {
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return vf.Create(&info)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ type Imp struct {
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
|
||||
Oracle OracleConfig `yaml:"oracle"`
|
||||
Oracle OracleConfig `yaml:"oracle"`
|
||||
}
|
||||
|
||||
type OracleConfig struct {
|
||||
@@ -46,7 +45,6 @@ func (c OracleConfig) dialTimeout() time.Duration {
|
||||
if c.DialTimeoutSecs <= 0 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
|
||||
return time.Duration(c.DialTimeoutSecs) * time.Second
|
||||
}
|
||||
|
||||
@@ -54,7 +52,6 @@ func (c OracleConfig) callTimeout() time.Duration {
|
||||
if c.CallTimeoutSecs <= 0 {
|
||||
return 3 * time.Second
|
||||
}
|
||||
|
||||
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||
}
|
||||
|
||||
@@ -72,11 +69,9 @@ func (i *Imp) Shutdown() {
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
|
||||
if i.oracleClient != nil {
|
||||
_ = i.oracleClient.Close()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,7 +98,6 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.config = cfg
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
@@ -111,23 +105,22 @@ func (i *Imp) Start() error {
|
||||
}
|
||||
|
||||
var oracleClient oracleclient.Client
|
||||
|
||||
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
|
||||
defer cancel()
|
||||
|
||||
oracleConn, err := oracleclient.New(dialCtx, oracleclient.Config{
|
||||
oc, err := oracleclient.New(dialCtx, oracleclient.Config{
|
||||
Address: addr,
|
||||
DialTimeout: cfg.Oracle.dialTimeout(),
|
||||
CallTimeout: cfg.Oracle.callTimeout(),
|
||||
Insecure: cfg.Oracle.InsecureTransport,
|
||||
})
|
||||
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 {
|
||||
oracleClient = oracleConn
|
||||
i.oracleClient = oracleConn
|
||||
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
||||
oracleClient = oc
|
||||
i.oracleClient = oc
|
||||
i.logger.Info("connected to oracle service", zap.String("address", addr))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,16 +129,13 @@ func (i *Imp) Start() error {
|
||||
if oracleClient != nil {
|
||||
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||
}
|
||||
|
||||
if cfg.GRPC != nil {
|
||||
if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" {
|
||||
opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI))
|
||||
}
|
||||
}
|
||||
|
||||
svc := fees.NewService(logger, repo, producer, opts...)
|
||||
i.service = svc
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
@@ -153,7 +143,6 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
@@ -163,14 +152,12 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &config{Config: &grpcapp.Config{}}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package calculator
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -34,7 +33,6 @@ func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return "eCalculator{
|
||||
logger: logger.Named("calculator"),
|
||||
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) {
|
||||
baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if plan == nil {
|
||||
return nil, merrors.InvalidArgument("plan is required")
|
||||
}
|
||||
if intent == nil {
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
|
||||
trigger := convertTrigger(intent.GetTrigger())
|
||||
if trigger == model.TriggerUnspecified {
|
||||
return nil, merrors.InvalidArgument("unsupported trigger")
|
||||
}
|
||||
|
||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid base amount")
|
||||
}
|
||||
if baseAmount.Sign() < 0 {
|
||||
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
||||
}
|
||||
|
||||
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
||||
|
||||
rules := make([]model.FeeRule, len(plan.Rules))
|
||||
copy(rules, plan.Rules)
|
||||
@@ -58,37 +73,81 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
||||
if rules[i].Priority == rules[j].Priority {
|
||||
return rules[i].RuleID < rules[j].RuleID
|
||||
}
|
||||
|
||||
return rules[i].Priority < rules[j].Priority
|
||||
})
|
||||
|
||||
planID := planIDFrom(plan)
|
||||
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||
|
||||
planID := ""
|
||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
||||
planID = planRef.Hex()
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
||||
|
||||
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||
if calcErr != nil {
|
||||
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if amount.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency)
|
||||
entrySide := resolvedEntrySide(rule)
|
||||
currency := intent.GetBaseAmount().GetCurrency()
|
||||
if override := strings.TrimSpace(rule.Currency); override != "" {
|
||||
currency = override
|
||||
}
|
||||
|
||||
lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID))
|
||||
applied = append(applied, buildAppliedRule(rule, planID))
|
||||
entrySide := mapEntrySide(rule.EntrySide)
|
||||
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
|
||||
@@ -111,24 +170,40 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
|
||||
|
||||
result := new(big.Rat)
|
||||
|
||||
result, err = applyPercentage(result, baseAmount, rule.Percentage)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
||||
percentageRat, perr := dmath.RatFromString(percentage)
|
||||
if perr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
||||
}
|
||||
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
||||
}
|
||||
|
||||
result, err = applyFixed(result, rule.FixedAmount)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
||||
fixedRat, ferr := dmath.RatFromString(fixed)
|
||||
if ferr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
||||
}
|
||||
result = dmath.AddRat(result, fixedRat)
|
||||
}
|
||||
|
||||
result, err = applyMin(result, rule.MinimumAmount)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
||||
minRat, merr := dmath.RatFromString(minStr)
|
||||
if merr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
||||
}
|
||||
if dmath.CmpRat(result, minRat) < 0 {
|
||||
result = new(big.Rat).Set(minRat)
|
||||
}
|
||||
}
|
||||
|
||||
result, err = applyMax(result, rule.MaximumAmount)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
||||
maxRat, merr := dmath.RatFromString(maxStr)
|
||||
if merr != nil {
|
||||
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
||||
}
|
||||
if dmath.CmpRat(result, maxRat) > 0 {
|
||||
result = new(big.Rat).Set(maxRat)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Sign() < 0 {
|
||||
@@ -143,66 +218,6 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
|
||||
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 (
|
||||
attrFxBaseCurrency = "fx_base_currency"
|
||||
attrFxQuoteCurrency = "fx_quote_currency"
|
||||
@@ -217,9 +232,7 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
|
||||
}
|
||||
|
||||
attrs := intent.GetAttributes()
|
||||
|
||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||
|
||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||
if base == "" || quote == "" {
|
||||
return nil
|
||||
@@ -234,26 +247,20 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
|
||||
Provider: provider,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Mid
|
||||
}
|
||||
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Ask
|
||||
}
|
||||
|
||||
if rateValue == "" {
|
||||
rateValue = snapshot.Bid
|
||||
}
|
||||
@@ -285,19 +292,15 @@ func inferScale(amount string) uint32 {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||
value = value[1:]
|
||||
}
|
||||
|
||||
if _, after, found := strings.Cut(value, "."); found {
|
||||
return uint32(len(after)) //nolint:gosec // decimal scale; cannot overflow
|
||||
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
||||
return uint32(len(value[dot+1:]))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -305,15 +308,12 @@ func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[s
|
||||
if rule.Trigger != trigger {
|
||||
return false
|
||||
}
|
||||
|
||||
if rule.EffectiveFrom.After(bookedAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ruleMatchesAttributes(rule, attributes)
|
||||
}
|
||||
|
||||
@@ -325,7 +325,6 @@ func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
@@ -334,115 +333,17 @@ func parseScale(field, value string) (uint32, error) {
|
||||
if clean == "" {
|
||||
return 0, merrors.InvalidArgument(field + " is empty")
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||
if err != nil {
|
||||
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||
}
|
||||
|
||||
return uint32(parsed), nil
|
||||
}
|
||||
|
||||
func 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 {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(meta[key])
|
||||
}
|
||||
|
||||
@@ -450,10 +351,10 @@ func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := make(map[string]string, len(src))
|
||||
maps.Copy(cloned, src)
|
||||
|
||||
for k, v := range src {
|
||||
cloned[k] = v
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
@@ -461,22 +362,18 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo
|
||||
if len(rule.AppliesTo) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for key, value := range rule.AppliesTo {
|
||||
if attributes == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
attrValue, ok := attributes[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if !matchesAttributeValue(value, attrValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -486,17 +383,16 @@ func matchesAttributeValue(expected, actual string) bool {
|
||||
return actual == ""
|
||||
}
|
||||
|
||||
for value := range strings.SplitSeq(trimmed, ",") {
|
||||
values := strings.Split(trimmed, ",")
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if value == "*" || value == actual {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -550,8 +446,6 @@ func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||
|
||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||
switch trigger {
|
||||
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
|
||||
return model.TriggerUnspecified
|
||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||
return model.TriggerCapture
|
||||
case feesv1.Trigger_TRIGGER_REFUND:
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
@@ -23,19 +22,17 @@ type planFinder interface {
|
||||
type feeResolver struct {
|
||||
plans storage.PlansStore
|
||||
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
|
||||
if pf, ok := plans.(planFinder); ok {
|
||||
finder = pf
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return &feeResolver{
|
||||
plans: plans,
|
||||
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 {
|
||||
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||
}
|
||||
|
||||
// Try org-specific first if provided.
|
||||
if isOrgRef(orgRef) {
|
||||
plan, rule, err := r.tryOrgRule(ctx, *orgRef, trigger, asOf, attrs)
|
||||
if err != nil {
|
||||
if orgRef != nil && !orgRef.IsZero() {
|
||||
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
|
||||
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
||||
return plan, rule, nil
|
||||
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), 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
|
||||
}
|
||||
|
||||
if rule != nil {
|
||||
return plan, rule, nil
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := r.getGlobalPlan(ctx, asOf)
|
||||
plan, err := r.getGlobalPlan(ctx, at)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
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")
|
||||
}
|
||||
|
||||
r.logger.Warn("Failed resolving global fee plan", zap.Error(err))
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rule, err := selectRule(plan, trigger, asOf, attrs)
|
||||
rule, err := selectRule(plan, trigger, at, attrs)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
|
||||
} else {
|
||||
globalFields := zapFieldsForPlan(plan)
|
||||
globalFields = append([]zap.Field{
|
||||
globalFields := []zap.Field{
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", asOf),
|
||||
zap.Time("booked_at", at),
|
||||
zap.Any("attributes", attrs),
|
||||
}, globalFields...)
|
||||
}
|
||||
globalFields = append(globalFields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("No matching rule in global plan", globalFields...)
|
||||
}
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
r.logSelectedRule(orgRef, trigger, asOf, attrs, rule, plan)
|
||||
|
||||
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{
|
||||
selectedFields := []zap.Field{
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at),
|
||||
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),
|
||||
}
|
||||
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 isOrgRef(orgRef) {
|
||||
fields = append(fields, mzap.ObjRef("org_ref", *orgRef))
|
||||
if orgRef != nil && !orgRef.IsZero() {
|
||||
selectedFields = append(selectedFields, mzap.ObjRef("org_ref", *orgRef))
|
||||
}
|
||||
selectedFields = append(selectedFields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("Selected fee rule", selectedFields...)
|
||||
|
||||
fields = append(fields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("Selected fee rule", fields...)
|
||||
}
|
||||
|
||||
func isOrgRef(ref *bson.ObjectID) bool {
|
||||
return ref != nil && !ref.IsZero()
|
||||
return plan, rule, nil
|
||||
}
|
||||
|
||||
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if r.finder != nil {
|
||||
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
|
||||
}
|
||||
|
||||
return r.plans.GetActivePlan(ctx, orgRef, at)
|
||||
}
|
||||
|
||||
func (r *feeResolver) getGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
|
||||
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
if r.finder != nil {
|
||||
return r.finder.FindActiveGlobalPlan(ctx, asOf)
|
||||
return r.finder.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
|
||||
var (
|
||||
selected *model.FeeRule
|
||||
highestPriority int
|
||||
)
|
||||
|
||||
var selected *model.FeeRule
|
||||
var highestPriority int
|
||||
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
|
||||
}
|
||||
|
||||
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||
continue
|
||||
}
|
||||
|
||||
if selected == nil || rule.Priority > highestPriority {
|
||||
matched := rule
|
||||
selected = &matched
|
||||
copy := rule
|
||||
selected = ©
|
||||
highestPriority = rule.Priority
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if rule.Priority == highestPriority {
|
||||
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 {
|
||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(appliesTo) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for key, value := range appliesTo {
|
||||
if attrs == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
attrValue, ok := attrs[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if !matchesAppliesValue(value, attrValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -262,17 +195,16 @@ func matchesAppliesValue(expected, actual string) bool {
|
||||
return actual == ""
|
||||
}
|
||||
|
||||
for value := range strings.SplitSeq(trimmed, ",") {
|
||||
values := strings.Split(trimmed, ",")
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if value == "*" || value == actual {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -280,7 +212,6 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
|
||||
if plan == nil {
|
||||
return []zap.Field{zap.Bool("plan_present", false)}
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.Bool("plan_present", true),
|
||||
zap.Bool("plan_active", plan.Active),
|
||||
@@ -292,16 +223,13 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
|
||||
} else {
|
||||
fields = append(fields, zap.Bool("plan_effective_to_set", false))
|
||||
}
|
||||
|
||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||
fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef))
|
||||
} else {
|
||||
fields = append(fields, zap.Bool("plan_org_ref_set", false))
|
||||
}
|
||||
|
||||
if plan.GetID() != nil && !plan.GetID().IsZero() {
|
||||
fields = append(fields, mzap.StorableRef(plan))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
globalPlan := &model.FeePlan{
|
||||
@@ -28,23 +28,20 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
orgA := bson.NewObjectID()
|
||||
|
||||
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||
}
|
||||
|
||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
||||
}
|
||||
|
||||
if rule.RuleID != "global_capture" {
|
||||
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
@@ -70,25 +67,22 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected org plan rule, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "org_capture" {
|
||||
t.Fatalf("expected org rule, got %s", rule.RuleID)
|
||||
}
|
||||
|
||||
otherOrg := bson.NewObjectID()
|
||||
|
||||
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected global fallback for other org, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "global_capture" {
|
||||
t.Fatalf("expected global rule, got %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
@@ -109,7 +103,6 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected rule resolution, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "high" {
|
||||
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
|
||||
}
|
||||
@@ -128,7 +121,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
@@ -159,14 +152,13 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "current" {
|
||||
t.Fatalf("expected current global rule, got %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
@@ -184,7 +176,6 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected card rule, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "card" {
|
||||
t.Fatalf("expected card rule, got %s", rule.RuleID)
|
||||
}
|
||||
@@ -193,14 +184,13 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected default rule, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "default" {
|
||||
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
@@ -219,7 +209,6 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected list match rule, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "network_multi" {
|
||||
t.Fatalf("expected network list rule, got %s", rule.RuleID)
|
||||
}
|
||||
@@ -228,7 +217,6 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected wildcard rule, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "asset_any" {
|
||||
t.Fatalf("expected asset wildcard rule, got %s", rule.RuleID)
|
||||
}
|
||||
@@ -237,14 +225,13 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected default rule, got error: %v", err)
|
||||
}
|
||||
|
||||
if rule.RuleID != "default" {
|
||||
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
plan := &model.FeePlan{
|
||||
@@ -263,28 +250,28 @@ func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
plan1 := &model.FeePlan{
|
||||
p1 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
plan1.OrganizationRef = &org
|
||||
plan2 := &model.FeePlan{
|
||||
p1.OrganizationRef = &org
|
||||
p2 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
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())
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 plan, err := m.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
|
||||
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
|
||||
return plan, nil
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return m.FindActiveGlobalPlan(ctx, asOf)
|
||||
return m.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
if plan.EffectiveFrom.After(asOf) {
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
continue
|
||||
}
|
||||
|
||||
matches = append(matches, plan)
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if len(matches) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, asOf time.Time) (*model.FeePlan, error) {
|
||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
if plan.EffectiveFrom.After(asOf) {
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
continue
|
||||
}
|
||||
|
||||
matches = append(matches, plan)
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if len(matches) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
||||
fields := logFieldsFromRequestMeta(meta)
|
||||
fields = append(fields, logFieldsFromIntent(intent)...)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -20,14 +19,11 @@ func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 4)
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
|
||||
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -35,30 +31,24 @@ func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 5)
|
||||
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
fields = append(fields, zap.String("trigger", trigger.String()))
|
||||
}
|
||||
|
||||
if base := intent.GetBaseAmount(); base != nil {
|
||||
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
||||
fields = append(fields, zap.String("base_amount", amount))
|
||||
}
|
||||
|
||||
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
||||
fields = append(fields, zap.String("base_currency", currency))
|
||||
}
|
||||
}
|
||||
|
||||
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
||||
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
||||
}
|
||||
|
||||
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
||||
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -66,20 +56,16 @@ func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 3)
|
||||
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
||||
fields = append(fields, zap.String("request_ref", reqRef))
|
||||
}
|
||||
|
||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||
fields = append(fields, zap.String("idempotency_key", idem))
|
||||
}
|
||||
|
||||
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
||||
fields = append(fields, zap.String("trace_ref", traceRef))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -87,20 +73,16 @@ func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
|
||||
if payload.ExpiresAtUnixMs > 0 {
|
||||
fields = append(fields,
|
||||
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
||||
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
||||
}
|
||||
|
||||
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
||||
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxU
|
||||
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
triggerLabel = "TRIGGER_UNSPECIFIED"
|
||||
}
|
||||
|
||||
fxLabel := strconv.FormatBool(fxUsed)
|
||||
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
|
||||
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
|
||||
@@ -60,16 +59,13 @@ func statusFromError(err error) string {
|
||||
if err == nil {
|
||||
return "success"
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return "error"
|
||||
}
|
||||
|
||||
code := st.Code()
|
||||
if code == codes.OK {
|
||||
return "success"
|
||||
}
|
||||
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,7 +22,6 @@ import (
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -33,8 +33,6 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
feesv1.UnimplementedFeeEngineServer
|
||||
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
producer msg.Producer
|
||||
@@ -44,6 +42,7 @@ type Service struct {
|
||||
resolver FeeResolver
|
||||
announcer *discovery.Announcer
|
||||
invokeURI string
|
||||
feesv1.UnimplementedFeeEngineServer
|
||||
}
|
||||
|
||||
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,
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -63,11 +61,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
if svc.clock == nil {
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
|
||||
if svc.calculator == nil {
|
||||
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
||||
}
|
||||
|
||||
if svc.resolver == nil {
|
||||
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||
}
|
||||
@@ -87,12 +83,25 @@ func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_FEES",
|
||||
Operations: []string{"fee.calc"},
|
||||
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) {
|
||||
var (
|
||||
meta *feesv1.RequestMeta
|
||||
@@ -102,29 +111,23 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
}
|
||||
|
||||
var fxUsed bool
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
@@ -137,10 +140,8 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("QuoteFees finished", logFields...)
|
||||
}()
|
||||
|
||||
@@ -154,14 +155,12 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
if parseErr != nil {
|
||||
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
|
||||
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 {
|
||||
err = computeErr
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -169,9 +168,8 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fxResult,
|
||||
FxUsed: fx,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -184,17 +182,48 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if intent != nil {
|
||||
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")
|
||||
|
||||
@@ -208,14 +237,12 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if parseErr != nil {
|
||||
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
|
||||
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 {
|
||||
err = computeErr
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -223,8 +250,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if ttl <= 0 {
|
||||
ttl = 60000
|
||||
}
|
||||
|
||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
|
||||
payload := feeQuoteTokenPayload{
|
||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||
@@ -235,9 +261,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
|
||||
var token string
|
||||
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")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -247,9 +272,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
ExpiresAt: timestamppb.New(expiresAt),
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fxResult,
|
||||
FxUsed: fx,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -258,23 +282,49 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
if req != nil {
|
||||
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
||||
|
||||
start := s.clock.Now()
|
||||
|
||||
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
|
||||
|
||||
defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, 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...)
|
||||
}()
|
||||
|
||||
logger.Debug("ValidateFeeToken request received")
|
||||
|
||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||
resultReason = "missing_token"
|
||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -283,11 +333,8 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||
if decodeErr != nil {
|
||||
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"}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -299,29 +346,22 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
|
||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||
resultReason = "expired"
|
||||
|
||||
logger.Info("Fee quote token expired")
|
||||
|
||||
logger.Info("fee quote token expired")
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
|
||||
if parseErr != nil {
|
||||
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"}
|
||||
|
||||
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 {
|
||||
err = computeErr
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -331,9 +371,8 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
Intent: payload.Intent,
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fxResult,
|
||||
FxUsed: fx,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -341,31 +380,24 @@ func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
|
||||
if req == nil {
|
||||
return status.Error(codes.InvalidArgument, "request is required")
|
||||
}
|
||||
|
||||
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
|
||||
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
|
||||
}
|
||||
|
||||
if req.GetIntent() == nil {
|
||||
return status.Error(codes.InvalidArgument, "intent is required")
|
||||
}
|
||||
|
||||
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
return status.Error(codes.InvalidArgument, "intent.trigger is required")
|
||||
}
|
||||
|
||||
if req.GetIntent().GetBaseAmount() == nil {
|
||||
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
|
||||
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
|
||||
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -373,7 +405,6 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e
|
||||
if req == nil {
|
||||
return status.Error(codes.InvalidArgument, "request is required")
|
||||
}
|
||||
|
||||
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) {
|
||||
bookedAt := resolvedBookedAt(intent, now)
|
||||
|
||||
logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
|
||||
if !orgRef.IsZero() {
|
||||
logFields = append(logFields, mzap.ObjRef("organization_ref", orgRef))
|
||||
bookedAt := now
|
||||
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
||||
bookedAt = intent.GetBookedAt().AsTime()
|
||||
}
|
||||
|
||||
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, logFieldsFromTrace(trace)...)
|
||||
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())
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
|
||||
|
||||
return nil, nil, nil, mapResolveError(err)
|
||||
switch {
|
||||
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
|
||||
plan.Rules = []model.FeeRule{*rule}
|
||||
|
||||
defer func() {
|
||||
plan.Rules = originalRules
|
||||
}()
|
||||
@@ -417,116 +461,13 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
|
||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
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 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 {
|
||||
OrganizationRef string `json:"organization_ref"`
|
||||
Intent *feesv1.Intent `json:"intent"`
|
||||
@@ -539,36 +480,17 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
|
||||
if err != nil {
|
||||
return "", merrors.Internal("fees: failed to serialize token payload")
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
|
||||
var payload feeQuoteTokenPayload
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return payload, merrors.InvalidArgument("fees: invalid token encoding")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return payload, merrors.InvalidArgument("fees: invalid token payload")
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -93,11 +93,9 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
if got := line.GetMoney().GetAmount(); got != "3.20" {
|
||||
t.Fatalf("expected fee amount 3.20, got %s", got)
|
||||
}
|
||||
|
||||
if line.GetMoney().GetCurrency() != "USD" {
|
||||
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
|
||||
}
|
||||
|
||||
if line.GetLedgerAccountRef() != "acct:fees" {
|
||||
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" {
|
||||
t.Fatalf("applied rule metadata mismatch: %+v", applied)
|
||||
}
|
||||
|
||||
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
|
||||
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
|
||||
}
|
||||
|
||||
if applied.GetParameters()["scale"] != "2" {
|
||||
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -193,23 +189,20 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.GetLines()) != 1 {
|
||||
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
|
||||
}
|
||||
|
||||
line := resp.GetLines()[0]
|
||||
if line.GetLedgerAccountRef() != "acct:base" {
|
||||
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
|
||||
}
|
||||
|
||||
if line.GetMoney().GetAmount() != "5.00" {
|
||||
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -256,18 +249,16 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.GetLines()) != 1 {
|
||||
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
|
||||
}
|
||||
|
||||
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
|
||||
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -325,26 +316,22 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
|
||||
if !calc.called {
|
||||
t.Fatalf("expected calculator to be invoked")
|
||||
}
|
||||
|
||||
if calc.gotPlan != plan {
|
||||
t.Fatalf("expected calculator to receive plan pointer")
|
||||
}
|
||||
|
||||
if len(resp.GetLines()) != len(result.Lines) {
|
||||
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
|
||||
}
|
||||
|
||||
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
|
||||
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := bson.NewObjectID()
|
||||
@@ -369,7 +356,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
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{
|
||||
Pair: req.Pair,
|
||||
Mid: "1.2300",
|
||||
@@ -412,12 +399,10 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
if resp.GetFxUsed() == nil {
|
||||
t.Fatalf("expected FxUsed to be populated")
|
||||
}
|
||||
|
||||
fx := resp.GetFxUsed()
|
||||
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
|
||||
t.Fatalf("unexpected FxUsed payload: %+v", fx)
|
||||
}
|
||||
|
||||
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
|
||||
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
|
||||
}
|
||||
|
||||
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 plan, err := s.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
|
||||
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
|
||||
return plan, nil
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return s.FindActiveGlobalPlan(ctx, asOf)
|
||||
return s.FindActiveGlobalPlan(context.Background(), at)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if !s.plan.Active {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if s.plan.EffectiveFrom.After(asOf) {
|
||||
if s.plan.EffectiveFrom.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(asOf) {
|
||||
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if !s.globalPlan.Active {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if s.globalPlan.EffectiveFrom.After(asOf) {
|
||||
if s.globalPlan.EffectiveFrom.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(asOf) {
|
||||
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
return s.globalPlan, nil
|
||||
}
|
||||
|
||||
@@ -534,10 +509,8 @@ func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *fees
|
||||
s.called = true
|
||||
s.gotPlan = plan
|
||||
s.bookedAt = bookedAt
|
||||
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
return s.result, nil
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
|
||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||
switch trigger {
|
||||
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
|
||||
return model.TriggerUnspecified
|
||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||
return model.TriggerCapture
|
||||
case feesv1.Trigger_TRIGGER_REFUND:
|
||||
|
||||
@@ -28,13 +28,12 @@ const (
|
||||
type FeePlan struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
|
||||
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
@@ -44,21 +43,21 @@ func (*FeePlan) Collection() string {
|
||||
|
||||
// FeeRule represents a single pricing rule within a plan.
|
||||
type FeeRule struct {
|
||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||
Priority int `bson:"priority" json:"priority"`
|
||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||
Priority int `bson:"priority" json:"priority"`
|
||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
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()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
plansStore, err := store.NewPlans(result.logger, database)
|
||||
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
|
||||
}
|
||||
|
||||
result.plans = plansStore
|
||||
|
||||
result.logger.Info("Billing fees MongoDB storage initialised")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ type plansStore struct {
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
const maxActivePlanResults = 2
|
||||
|
||||
// NewPlans constructs a Mongo-backed PlansStore.
|
||||
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -56,8 +53,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
||||
Unique: true,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -71,7 +67,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
||||
},
|
||||
}
|
||||
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{
|
||||
@@ -84,7 +80,6 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||
if err := validatePlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,12 +88,9 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
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 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() {
|
||||
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||
}
|
||||
|
||||
if err := validatePlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.repo.Update(ctx, plan); err != nil {
|
||||
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 nil
|
||||
}
|
||||
|
||||
@@ -128,83 +116,72 @@ func (p *plansStore) Get(ctx context.Context, planRef bson.ObjectID) (*model.Fee
|
||||
if planRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
||||
}
|
||||
|
||||
result := &model.FeePlan{}
|
||||
if err := p.repo.Get(ctx, planRef, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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.
|
||||
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 {
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return p.FindActiveGlobalPlan(ctx, asOf)
|
||||
return p.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
|
||||
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() {
|
||||
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||
}
|
||||
|
||||
query := repository.Query().Filter(repository.OrgField(), orgRef)
|
||||
|
||||
return p.findActivePlan(ctx, query, asOf)
|
||||
return p.findActivePlan(ctx, query, at)
|
||||
}
|
||||
|
||||
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(
|
||||
repository.Exists(repository.OrgField(), false),
|
||||
repository.Query().Filter(repository.OrgField(), nil),
|
||||
)
|
||||
|
||||
return p.findActivePlan(ctx, globalQuery, asOf)
|
||||
return p.findActivePlan(ctx, globalQuery, at)
|
||||
}
|
||||
|
||||
var _ storage.PlansStore = (*plansStore)(nil)
|
||||
|
||||
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, asOf time.Time) (*model.FeePlan, error) {
|
||||
limit := int64(maxActivePlanResults)
|
||||
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
|
||||
limit := int64(2)
|
||||
query := orgQuery.
|
||||
Filter(repository.Field("active"), true).
|
||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, asOf).
|
||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||
Sort(repository.Field("effectiveFrom"), false).
|
||||
Limit(&limit)
|
||||
|
||||
query = query.And(
|
||||
repository.Query().Or(
|
||||
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
|
||||
|
||||
decoder := func(cursor *mongo.Cursor) error {
|
||||
target := &model.FeePlan{}
|
||||
if err := cursor.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plans = append(plans, target)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -212,18 +189,15 @@ func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query,
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(plans) == 0 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
if len(plans) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
|
||||
return plans[0], nil
|
||||
}
|
||||
|
||||
@@ -231,61 +205,44 @@ func validatePlan(plan *model.FeePlan) error {
|
||||
if plan == nil {
|
||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||
}
|
||||
|
||||
if len(plan.Rules) == 0 {
|
||||
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
|
||||
}
|
||||
|
||||
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
|
||||
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
|
||||
}
|
||||
|
||||
// Ensure unique priority per (trigger, appliesTo) combination.
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, rule := range plan.Rules {
|
||||
if err := validateRule(rule); err != nil {
|
||||
return err
|
||||
if strings.TrimSpace(rule.Percentage) != "" {
|
||||
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule percentage")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.FixedAmount) != "" {
|
||||
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.MinimumAmount) != "" {
|
||||
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.MaximumAmount) != "" {
|
||||
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
|
||||
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
|
||||
}
|
||||
}
|
||||
|
||||
appliesKey := normalizeAppliesTo(rule.AppliesTo)
|
||||
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
|
||||
|
||||
if _, ok := seen[priorityKey]; ok {
|
||||
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
|
||||
}
|
||||
|
||||
seen[priorityKey] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func 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
|
||||
}
|
||||
|
||||
@@ -293,19 +250,15 @@ func normalizeAppliesTo(applies map[string]string) string {
|
||||
if len(applies) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(applies))
|
||||
for k := range applies {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+normalizeAppliesToValue(applies[k]))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
@@ -319,37 +272,28 @@ func normalizeAppliesToValue(value string) string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
hasWildcard := false
|
||||
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if value == "*" {
|
||||
hasWildcard = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
|
||||
if hasWildcard {
|
||||
return "*"
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
sort.Strings(normalized)
|
||||
|
||||
return strings.Join(normalized, ",")
|
||||
}
|
||||
|
||||
@@ -358,7 +302,7 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
|
||||
return nil
|
||||
}
|
||||
|
||||
var orgQuery builder.Query
|
||||
orgQuery := repository.Query()
|
||||
if plan.OrganizationRef.IsZero() {
|
||||
orgQuery = repository.Query().Or(
|
||||
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)
|
||||
newFrom := plan.EffectiveFrom
|
||||
|
||||
newTo := maxTime
|
||||
if plan.EffectiveTo != nil {
|
||||
newTo = *plan.EffectiveTo
|
||||
@@ -392,10 +335,8 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
|
||||
query = query.Limit(&limit)
|
||||
|
||||
var overlapFound bool
|
||||
|
||||
decoder := func(_ *mongo.Cursor) error {
|
||||
decoder := func(cursor *mongo.Cursor) error {
|
||||
overlapFound = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -403,13 +344,10 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if overlapFound {
|
||||
return storage.ErrConflictingFeePlans
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.6
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../pkg
|
||||
|
||||
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/tech/sendico/pkg v0.1.0
|
||||
go.uber.org/zap v1.27.1
|
||||
@@ -20,17 +20,17 @@ require (
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.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-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
@@ -38,12 +38,12 @@ require (
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
|
||||
google.golang.org/grpc v1.78.0 // 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
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/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
@@ -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/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -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=
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
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/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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-20220722155257-8c9f86f7a55f/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.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
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-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package appversion exposes build-time version information for the discovery service.
|
||||
package appversion
|
||||
|
||||
import (
|
||||
@@ -15,9 +14,8 @@ var (
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
// Create returns a version printer populated with the compile-time build metadata.
|
||||
func Create() version.Printer { //nolint:ireturn // factory returns interface by design
|
||||
info := version.Info{
|
||||
func Create() version.Printer {
|
||||
vi := version.Info{
|
||||
Program: "Sendico Discovery Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
@@ -25,6 +23,5 @@ func Create() version.Printer { //nolint:ireturn // factory returns interface by
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return vf.Create(&info)
|
||||
return vf.Create(&vi)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package serverimp contains the concrete discovery server implementation.
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
@@ -11,10 +10,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMetricsAddress = ":9405"
|
||||
defaultShutdownTimeoutSeconds = 15
|
||||
)
|
||||
const defaultMetricsAddress = ":9405"
|
||||
|
||||
type config struct {
|
||||
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
|
||||
@@ -28,28 +24,24 @@ type metricsConfig 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) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
|
||||
return nil, err //nolint:wrapcheck
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &config{}
|
||||
|
||||
err = yaml.Unmarshal(data, cfg)
|
||||
if err != nil {
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
|
||||
return nil, err //nolint:wrapcheck
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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) == "" {
|
||||
|
||||
@@ -14,51 +14,43 @@ import (
|
||||
|
||||
func (i *Imp) startDiscovery(cfg *config) error {
|
||||
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
|
||||
//nolint:wrapcheck
|
||||
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
|
||||
}
|
||||
|
||||
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
|
||||
if err != nil {
|
||||
return err //nolint:wrapcheck
|
||||
return err
|
||||
}
|
||||
|
||||
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
|
||||
|
||||
registry := discovery.NewRegistry()
|
||||
|
||||
var registryOpts []discovery.RegistryOption
|
||||
|
||||
if cfg.Registry != nil && cfg.Registry.KVTTLSeconds != nil {
|
||||
ttlSeconds := *cfg.Registry.KVTTLSeconds
|
||||
if ttlSeconds < 0 {
|
||||
i.logger.Warn("Discovery registry TTL is negative, disabling TTL", zap.Int("ttl_seconds", ttlSeconds))
|
||||
ttlSeconds = 0
|
||||
}
|
||||
|
||||
registryOpts = append(registryOpts, discovery.WithRegistryKVTTL(time.Duration(ttlSeconds)*time.Second))
|
||||
}
|
||||
|
||||
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, mservice.Discovery, registryOpts...)
|
||||
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery), registryOpts...)
|
||||
if err != nil {
|
||||
return err //nolint:wrapcheck
|
||||
return err
|
||||
}
|
||||
|
||||
svc.Start()
|
||||
i.registrySvc = svc
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: mservice.Discovery,
|
||||
Service: "DISCOVERY",
|
||||
InstanceID: discovery.InstanceID(),
|
||||
Operations: []string{discovery.OperationDiscoveryLookup},
|
||||
Operations: []string{"discovery.lookup"},
|
||||
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.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,12 +58,10 @@ func (i *Imp) stopDiscovery() {
|
||||
if i == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if i.announcer != nil {
|
||||
i.announcer.Stop()
|
||||
i.announcer = nil
|
||||
}
|
||||
|
||||
if i.registrySvc != nil {
|
||||
i.registrySvc.Stop()
|
||||
i.registrySvc = nil
|
||||
|
||||
@@ -15,30 +15,22 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const readHeaderTimeout = 5 * time.Second
|
||||
|
||||
func (i *Imp) startMetrics(cfg *metricsConfig) {
|
||||
if i == nil {
|
||||
return
|
||||
}
|
||||
|
||||
address := ""
|
||||
if cfg != nil {
|
||||
address = strings.TrimSpace(cfg.Address)
|
||||
}
|
||||
|
||||
if address == "" {
|
||||
i.logger.Info("Metrics endpoint disabled")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lc := net.ListenConfig{}
|
||||
|
||||
listener, err := lc.Listen(context.Background(), "tcp", address)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,9 +38,7 @@ func (i *Imp) startMetrics(cfg *metricsConfig) {
|
||||
router.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
var healthRouter routers.Health
|
||||
|
||||
hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, "")
|
||||
if err != nil {
|
||||
if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil {
|
||||
i.logger.Warn("Failed to initialise health router", zap.Error(err))
|
||||
} else {
|
||||
hr.SetStatus(health.SSStarting)
|
||||
@@ -59,16 +49,13 @@ func (i *Imp) startMetrics(cfg *metricsConfig) {
|
||||
i.metricsSrv = &http.Server{
|
||||
Addr: address,
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||
|
||||
serveErr := i.metricsSrv.Serve(listener)
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(serveErr))
|
||||
|
||||
if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
|
||||
if healthRouter != nil {
|
||||
healthRouter.SetStatus(health.SSTerminating)
|
||||
}
|
||||
@@ -82,18 +69,14 @@ func (i *Imp) shutdownMetrics(ctx context.Context) {
|
||||
i.metricsHealth.Finish()
|
||||
i.metricsHealth = nil
|
||||
}
|
||||
|
||||
if i.metricsSrv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := i.metricsSrv.Shutdown(ctx)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
||||
} else {
|
||||
i.logger.Info("Metrics server stopped")
|
||||
}
|
||||
|
||||
i.metricsSrv = nil
|
||||
}
|
||||
|
||||
@@ -101,6 +84,5 @@ func (i *Imp) setMetricsStatus(status health.ServiceStatus) {
|
||||
if i == nil || i.metricsHealth == nil {
|
||||
return
|
||||
}
|
||||
|
||||
i.metricsHealth.SetStatus(status)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ import (
|
||||
"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) {
|
||||
return &Imp{
|
||||
logger: logger.Named("server"),
|
||||
@@ -21,7 +18,6 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start loads configuration, starts metrics and the discovery registry, then blocks until stopped.
|
||||
func (i *Imp) Start() error {
|
||||
i.initStopChannels()
|
||||
defer i.closeDone()
|
||||
@@ -32,37 +28,29 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.config = cfg
|
||||
|
||||
messagingDriver := "none"
|
||||
if cfg.Messaging != nil {
|
||||
messagingDriver = string(cfg.Messaging.Driver)
|
||||
}
|
||||
|
||||
metricsAddress := ""
|
||||
if cfg.Metrics != nil {
|
||||
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
|
||||
}
|
||||
|
||||
if metricsAddress == "" {
|
||||
metricsAddress = "disabled"
|
||||
}
|
||||
|
||||
i.logger.Info("Discovery config loaded",
|
||||
zap.String("messaging_driver", messagingDriver),
|
||||
zap.String("metrics_address", metricsAddress))
|
||||
i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress))
|
||||
|
||||
i.startMetrics(cfg.Metrics)
|
||||
|
||||
err = i.startDiscovery(cfg)
|
||||
if err != nil {
|
||||
if err := i.startDiscovery(cfg); err != nil {
|
||||
i.stopDiscovery()
|
||||
i.setMetricsStatus(health.SSTerminating)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
|
||||
i.shutdownMetrics(ctx)
|
||||
cancel()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -71,11 +59,9 @@ func (i *Imp) Start() error {
|
||||
|
||||
<-i.stopCh
|
||||
i.logger.Info("Discovery service stop signal received")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the discovery service and its metrics server.
|
||||
func (i *Imp) Shutdown() {
|
||||
timeout := i.shutdownTimeout()
|
||||
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
|
||||
@@ -86,7 +72,6 @@ func (i *Imp) Shutdown() {
|
||||
if i.doneCh != nil {
|
||||
<-i.doneCh
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
i.shutdownMetrics(ctx)
|
||||
cancel()
|
||||
@@ -98,7 +83,6 @@ func (i *Imp) initStopChannels() {
|
||||
if i.stopCh == nil {
|
||||
i.stopCh = make(chan struct{})
|
||||
}
|
||||
|
||||
if i.doneCh == nil {
|
||||
i.doneCh = make(chan struct{})
|
||||
}
|
||||
@@ -124,6 +108,5 @@ func (i *Imp) shutdownTimeout() time.Duration {
|
||||
if i.config != nil && i.config.Runtime != nil {
|
||||
return i.config.Runtime.ShutdownTimeout()
|
||||
}
|
||||
|
||||
return defaultShutdownTimeout
|
||||
return 15 * time.Second
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Imp is the concrete implementation of the discovery server application.
|
||||
type Imp struct {
|
||||
logger mlogger.Logger
|
||||
file string
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package server provides the discovery service application factory.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -7,9 +6,6 @@ import (
|
||||
"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) {
|
||||
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
|
||||
|
||||
import (
|
||||
@@ -9,9 +8,8 @@ import (
|
||||
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) {
|
||||
return si.Create(logger, file, debug) //nolint:wrapcheck
|
||||
return si.Create(logger, file, debug)
|
||||
}
|
||||
|
||||
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