diff --git a/.woodpecker/bff.yml b/.woodpecker/bff.yml
index 18c0ec93..34a7666b 100644
--- a/.woodpecker/bff.yml
+++ b/.woodpecker/bff.yml
@@ -7,6 +7,9 @@ matrix:
BFF_VAULT_SECRET_PATH: sendico/edge/bff/vault
BFF_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -53,7 +56,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh bff
@@ -87,6 +90,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/bff/build-image.sh
- name: deploy
diff --git a/.woodpecker/billing_documents.yml b/.woodpecker/billing_documents.yml
index 00fd60fe..2add4db2 100644
--- a/.woodpecker/billing_documents.yml
+++ b/.woodpecker/billing_documents.yml
@@ -5,6 +5,9 @@ matrix:
DOCUMENTS_MONGO_SECRET_PATH: sendico/db
DOCUMENTS_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -48,7 +51,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh billing_documents
@@ -82,6 +85,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/billing_documents/build-image.sh
- name: deploy
diff --git a/.woodpecker/billing_fees.yml b/.woodpecker/billing_fees.yml
index 7f862c7d..9288883e 100644
--- a/.woodpecker/billing_fees.yml
+++ b/.woodpecker/billing_fees.yml
@@ -5,6 +5,9 @@ matrix:
FEES_MONGO_SECRET_PATH: sendico/db
FEES_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -48,7 +51,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh billing_fees
@@ -82,6 +85,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/billing_fees/build-image.sh
- name: deploy
diff --git a/.woodpecker/callbacks.yml b/.woodpecker/callbacks.yml
index dcceef4c..276af5d8 100644
--- a/.woodpecker/callbacks.yml
+++ b/.woodpecker/callbacks.yml
@@ -6,6 +6,9 @@ matrix:
CALLBACKS_VAULT_SECRET_PATH: sendico/edge/callbacks/vault
CALLBACKS_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -49,7 +52,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh callbacks
@@ -83,6 +86,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/callbacks/build-image.sh
- name: deploy
diff --git a/.woodpecker/discovery.yml b/.woodpecker/discovery.yml
index 574ea1ca..43f58144 100644
--- a/.woodpecker/discovery.yml
+++ b/.woodpecker/discovery.yml
@@ -4,6 +4,9 @@ matrix:
DISCOVERY_DOCKERFILE: ci/prod/compose/discovery.dockerfile
DISCOVERY_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -47,7 +50,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh discovery
@@ -81,6 +84,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/discovery/build-image.sh
- name: deploy
diff --git a/.woodpecker/frontend.yml b/.woodpecker/frontend.yml
index 794ac75f..79314c72 100644
--- a/.woodpecker/frontend.yml
+++ b/.woodpecker/frontend.yml
@@ -4,6 +4,9 @@ matrix:
FRONTEND_DOCKERFILE: ci/prod/compose/frontend.dockerfile
FRONTEND_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -22,7 +25,8 @@ steps:
- name: version
image: alpine:latest
commands:
- - set -euo pipefail 2>/dev/null || set -eu
+ - set -eu
+ - if set -o | grep -q pipefail 2>/dev/null; then set -o pipefail; fi
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
@@ -50,10 +54,21 @@ steps:
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
+ - name: frontend-tests
+ image: ghcr.io/cirruslabs/flutter:stable
+ depends_on: [ version ]
+ commands:
+ - set -eu
+ - if set -o | grep -q pipefail 2>/dev/null; then set -o pipefail; fi
+ - flutter --version
+ - (cd frontend/pshared && flutter pub get && dart run build_runner build --delete-conflicting-outputs && flutter test)
+ - (cd frontend/pweb && flutter pub get && dart run build_runner build --delete-conflicting-outputs && flutter test)
+
- name: build-image
image: gcr.io/kaniko-project/executor:debug
- depends_on: [ version, secrets ]
+ depends_on: [ frontend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/frontend/build-image.sh
- name: deploy
diff --git a/.woodpecker/fx_ingestor.yml b/.woodpecker/fx_ingestor.yml
index 24e886ea..c6421233 100644
--- a/.woodpecker/fx_ingestor.yml
+++ b/.woodpecker/fx_ingestor.yml
@@ -8,6 +8,9 @@ matrix:
FX_NEEDS_NATS: "true"
FX_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -53,7 +56,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh fx_ingestor
@@ -87,6 +90,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/fx/build-image.sh
- name: deploy
diff --git a/.woodpecker/fx_oracle.yml b/.woodpecker/fx_oracle.yml
index 32310eb1..a0d47719 100644
--- a/.woodpecker/fx_oracle.yml
+++ b/.woodpecker/fx_oracle.yml
@@ -8,6 +8,9 @@ matrix:
FX_NEEDS_NATS: "true"
FX_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -54,7 +57,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh fx_oracle
@@ -88,6 +91,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/fx/build-image.sh
- name: deploy
diff --git a/.woodpecker/gateway_chain.yml b/.woodpecker/gateway_chain.yml
index c99511eb..9addeb55 100644
--- a/.woodpecker/gateway_chain.yml
+++ b/.woodpecker/gateway_chain.yml
@@ -8,6 +8,9 @@ matrix:
CHAIN_GATEWAY_VAULT_SECRET_PATH: sendico/gateway/chain/vault
CHAIN_GATEWAY_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -52,7 +55,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh gateway_chain
@@ -86,6 +89,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "chain gateway image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/chain_gateway/build-image.sh
- name: deploy
diff --git a/.woodpecker/gateway_mntx.yml b/.woodpecker/gateway_mntx.yml
index 4458d0ec..fe78c8c4 100644
--- a/.woodpecker/gateway_mntx.yml
+++ b/.woodpecker/gateway_mntx.yml
@@ -7,6 +7,9 @@ matrix:
MNTX_GATEWAY_NATS_SECRET_PATH: sendico/nats
MNTX_GATEWAY_MONGO_SECRET_PATH: sendico/db
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -51,7 +54,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh gateway_mntx
@@ -85,6 +88,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/mntx/build-image.sh
- name: deploy
diff --git a/.woodpecker/gateway_tgsettle.yml b/.woodpecker/gateway_tgsettle.yml
index cac78f82..e5ec9e8d 100644
--- a/.woodpecker/gateway_tgsettle.yml
+++ b/.woodpecker/gateway_tgsettle.yml
@@ -5,6 +5,9 @@ matrix:
TGSETTLE_GATEWAY_MONGO_SECRET_PATH: sendico/db
TGSETTLE_GATEWAY_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -49,7 +52,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh gateway_tgsettle
@@ -83,6 +86,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/tgsettle/build-image.sh
- name: deploy
diff --git a/.woodpecker/gateway_tron.yml b/.woodpecker/gateway_tron.yml
index 1f3f5d38..18352d26 100644
--- a/.woodpecker/gateway_tron.yml
+++ b/.woodpecker/gateway_tron.yml
@@ -8,6 +8,9 @@ matrix:
TRON_GATEWAY_VAULT_SECRET_PATH: sendico/gateway/tron/vault
TRON_GATEWAY_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -52,7 +55,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh gateway_tron
@@ -86,6 +89,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/tron_gateway/build-image.sh
- name: deploy
diff --git a/.woodpecker/ledger.yml b/.woodpecker/ledger.yml
index 7fcfa570..8d260857 100644
--- a/.woodpecker/ledger.yml
+++ b/.woodpecker/ledger.yml
@@ -5,6 +5,9 @@ matrix:
LEDGER_MONGO_SECRET_PATH: sendico/db
LEDGER_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -48,7 +51,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh ledger
@@ -82,6 +85,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/ledger/build-image.sh
- name: deploy
diff --git a/.woodpecker/notification.yml b/.woodpecker/notification.yml
index c62abd2e..af8a77f6 100644
--- a/.woodpecker/notification.yml
+++ b/.woodpecker/notification.yml
@@ -8,6 +8,9 @@ matrix:
NOTIFICATION_TELEGRAM_SECRET_PATH: sendico/notification/telegram
NOTIFICATION_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -51,7 +54,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh notification
@@ -85,6 +88,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/notification/build-image.sh
- name: deploy
diff --git a/.woodpecker/payments_methods.yml b/.woodpecker/payments_methods.yml
index 62d88644..2dce8ff0 100644
--- a/.woodpecker/payments_methods.yml
+++ b/.woodpecker/payments_methods.yml
@@ -5,6 +5,9 @@ matrix:
PAYMENTS_METHODS_MONGO_SECRET_PATH: sendico/db
PAYMENTS_METHODS_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -49,7 +52,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh payments_methods
@@ -83,6 +86,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/payments_methods/build-image.sh
- name: deploy
diff --git a/.woodpecker/payments_orchestrator.yml b/.woodpecker/payments_orchestrator.yml
index 9e014e77..3bfd3916 100644
--- a/.woodpecker/payments_orchestrator.yml
+++ b/.woodpecker/payments_orchestrator.yml
@@ -5,6 +5,9 @@ matrix:
PAYMENTS_MONGO_SECRET_PATH: sendico/db
PAYMENTS_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -49,7 +52,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh payments_orchestrator
@@ -83,6 +86,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/payments_orchestrator/build-image.sh
- name: deploy
diff --git a/.woodpecker/payments_quotation.yml b/.woodpecker/payments_quotation.yml
index db3fee93..a88d3b1c 100644
--- a/.woodpecker/payments_quotation.yml
+++ b/.woodpecker/payments_quotation.yml
@@ -5,6 +5,9 @@ matrix:
PAYMENTS_QUOTATION_MONGO_SECRET_PATH: sendico/db
PAYMENTS_QUOTATION_ENV: prod
+labels:
+ platform: linux/amd64
+
when:
- event: push
branch: main
@@ -49,7 +52,7 @@ steps:
commands:
- set -eu
- apk add --no-cache bash git build-base
- - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ - CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- sh ci/scripts/common/run_backend_lint.sh payments_quotation
@@ -83,6 +86,7 @@ steps:
image: gcr.io/kaniko-project/executor:debug
depends_on: [ backend-lint, backend-tests, secrets ]
commands:
+ - '[ "$(uname -m)" = "x86_64" ] || { echo "image build requires an amd64 runner"; exit 1; }'
- sh ci/scripts/payments_quotation/build-image.sh
- name: deploy
diff --git a/Makefile b/Makefile
index 75689c95..9f02d03c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,48 @@
# 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 lint-api backend-up backend-down backend-rebuild
+.PHONY: \
+ help \
+ init \
+ build \
+ build-infra \
+ build-core \
+ build-fx \
+ build-payments \
+ build-gateways \
+ build-backend \
+ build-frontend \
+ up \
+ down \
+ restart \
+ infra-up \
+ services-up \
+ backend-up \
+ backend-down \
+ backend-rebuild \
+ status \
+ list-services \
+ logs \
+ rebuild \
+ clean \
+ health \
+ vault-init \
+ proto \
+ generate \
+ generate-backend \
+ generate-frontend \
+ update \
+ update-backend \
+ update-frontend \
+ test \
+ test-backend \
+ test-frontend \
+ lint-backend
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
SERVICE ?=
-API_GOCACHE ?= $(CURDIR)/.gocache
-API_GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache
+BACKEND_GOCACHE ?= $(CURDIR)/.gocache
+BACKEND_GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache
BACKEND_SERVICES := \
dev-discovery \
dev-fx-oracle \
@@ -47,8 +83,8 @@ help:
@echo " make down Stop all services"
@echo " make restart Restart all services"
@echo " make status Show service status"
- @echo " make logs [SERVICE=x] View logs (all or specific service)"
- @echo " make rebuild SERVICE=x Rebuild specific service"
+ @echo " make logs [SERVICE=dev-ledger] View logs (all or specific service)"
+ @echo " make rebuild SERVICE=dev-ledger Rebuild specific service"
@echo " make clean Remove all containers and volumes"
@echo ""
@echo "$(YELLOW)Selective Operations:$(NC)"
@@ -62,23 +98,23 @@ help:
@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-payments Build payment services (orchestrator, quotation, methods)"
@echo " make build-gateways Build gateway services (chain, tron, aurora, chsettle)"
- @echo " make build-api Build API services (notification, callbacks, bff)"
+ @echo " make build-backend Build backend edge services (notification, callbacks, 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-backend 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-backend 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 Run all tests (backend + frontend)"
+ @echo " make test-backend Run Go backend tests only"
@echo " make test-frontend Run Flutter tests only"
- @echo " make lint-api Run golangci-lint across all API Go modules"
+ @echo " make lint-backend Run golangci-lint across all backend Go modules"
@echo " make health Check service health"
@echo ""
@echo "Examples:"
@@ -140,7 +176,7 @@ up:
@echo " NATS UI: http://localhost:8222"
@echo " Vault: http://localhost:8200 (run 'make vault-init' first)"
@echo ""
- @echo "View logs: make logs [SERVICE=name]"
+ @echo "View logs: make logs [SERVICE=dev-ledger]"
@echo "Stop: make down"
# Stop all services
@@ -164,7 +200,7 @@ endif
# Rebuild specific service
rebuild:
ifndef SERVICE
- $(error SERVICE is required: make rebuild SERVICE=ledger)
+ $(error SERVICE is required: make rebuild SERVICE=dev-ledger)
endif
@echo "$(GREEN)Rebuilding $(SERVICE)...$(NC)"
@$(COMPOSE) build $(SERVICE)
@@ -173,13 +209,13 @@ endif
@echo "View logs: make logs SERVICE=$(SERVICE)"
# Generate protobuf code (alias)
-proto: generate-api
+proto: generate-backend
# Generate all code
-generate: generate-api generate-frontend
+generate: generate-backend generate-frontend
-# Generate protobuf code
-generate-api:
+# Generate backend protobuf code
+generate-backend:
@echo "$(GREEN)Generating protobuf code...$(NC)"
@./ci/scripts/proto/generate.sh
@echo "$(GREEN)✅ Protobuf generation complete$(NC)"
@@ -327,8 +363,8 @@ build-gateways:
@echo "$(GREEN)Building gateway services...$(NC)"
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-chsettle-gateway
-build-api:
- @echo "$(GREEN)Building API services...$(NC)"
+build-backend:
+ @echo "$(GREEN)Building backend edge services...$(NC)"
@$(COMPOSE) build dev-notification dev-callbacks dev-bff
build-frontend:
@@ -336,10 +372,10 @@ build-frontend:
@$(COMPOSE) build dev-frontend
# Update all dependencies
-update: update-api update-frontend
+update: update-backend update-frontend
-# Update Go API dependencies
-update-api:
+# Update Go backend dependencies
+update-backend:
@echo "$(GREEN)Updating Go dependencies...$(NC)"
@for dir in $$(find api -name go.mod -exec dirname {} \;); do \
echo "Updating $$dir..."; \
@@ -355,11 +391,11 @@ update-frontend:
@echo "$(GREEN)✅ Flutter dependencies updated$(NC)"
# Run all tests
-test: test-api test-frontend
+test: test-backend test-frontend
-# Run Go API tests
-test-api:
- @echo "$(GREEN)Running API tests...$(NC)"
+# Run Go backend tests
+test-backend:
+ @echo "$(GREEN)Running backend tests...$(NC)"
@failed=""; \
for dir in $$(find api -name go.mod -exec dirname {} \;); do \
echo "Testing $$dir..."; \
@@ -369,7 +405,7 @@ test-api:
echo "$(YELLOW)Failed:$$failed$(NC)"; \
exit 1; \
fi
- @echo "$(GREEN)✅ All API tests passed$(NC)"
+ @echo "$(GREEN)✅ All backend tests passed$(NC)"
# Run Flutter tests
test-frontend:
@@ -378,17 +414,17 @@ test-frontend:
@cd frontend/pweb && flutter test
@echo "$(GREEN)✅ All frontend tests passed$(NC)"
-# Run Go API linting
-lint-api:
- @echo "$(GREEN)Running API linting...$(NC)"
- @mkdir -p "$(API_GOCACHE)" "$(API_GOLANGCI_LINT_CACHE)"
+# Run Go backend linting
+lint-backend:
+ @echo "$(GREEN)Running backend linting...$(NC)"
+ @mkdir -p "$(BACKEND_GOCACHE)" "$(BACKEND_GOLANGCI_LINT_CACHE)"
@failed=""; \
for dir in $$(find api -name go.mod -exec dirname {} \;); do \
echo "Linting $$dir..."; \
- (cd "$$dir" && GOCACHE="$(API_GOCACHE)" GOLANGCI_LINT_CACHE="$(API_GOLANGCI_LINT_CACHE)" golangci-lint run --allow-serial-runners --allow-parallel-runners ./...) || failed="$$failed $$dir"; \
+ (cd "$$dir" && GOCACHE="$(BACKEND_GOCACHE)" GOLANGCI_LINT_CACHE="$(BACKEND_GOLANGCI_LINT_CACHE)" golangci-lint run --allow-serial-runners --allow-parallel-runners ./...) || failed="$$failed $$dir"; \
done; \
if [ -n "$$failed" ]; then \
echo "$(YELLOW)Lint failed:$$failed$(NC)"; \
exit 1; \
fi
- @echo "$(GREEN)✅ All API lint checks passed$(NC)"
+ @echo "$(GREEN)✅ All backend lint checks passed$(NC)"
diff --git a/README.md b/README.md
index 891dcb3f..8d75ec61 100644
--- a/README.md
+++ b/README.md
@@ -89,7 +89,7 @@ make build-core # discovery, ledger, fees, documents
make build-fx # oracle, ingestor
make build-payments # orchestrator, quotation, methods
make build-gateways # chain, tron, aurora, chsettle
-make build-api # notification, callbacks, bff
+make build-backend # notification, callbacks, bff
make build-frontend # Flutter web UI
```
@@ -97,16 +97,16 @@ make build-frontend # Flutter web UI
```bash
make generate # Generate all code (protobuf + Flutter)
-make generate-api # Generate protobuf code only
+make generate-backend # Generate protobuf code only
make generate-frontend # Generate Flutter code only (build_runner)
-make proto # Alias for generate-api
+make proto # Alias for generate-backend
```
### Testing
```bash
-make test # Run all tests (API + frontend)
-make test-api # Run Go API tests only
+make test # Run all tests (backend + frontend)
+make test-backend # Run Go backend tests only
make test-frontend # Run Flutter tests only
```
@@ -133,7 +133,7 @@ If you intentionally need to bypass checks for a specific commit, include one of
```bash
make update # Update all Go and Flutter dependencies
-make update-api # Update Go dependencies only
+make update-backend # Update Go dependencies only
make update-frontend # Update Flutter dependencies only
```
diff --git a/SETUP.md b/SETUP.md
index d4adbd40..d4bd514e 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -137,10 +137,11 @@ make infra-up
make services-up
# Or start specific service groups
-make build-core # discovery, ledger, billing-fees
+make build-core # discovery, ledger, billing-fees, billing-documents
make build-fx # fx-oracle, fx-ingestor
-make build-payments # payments-orchestrator
-make build-gateways # chain, mntx, tgsettle
+make build-payments # payments-orchestrator, payments-quotation, payments-methods
+make build-gateways # chain, tron, aurora, chsettle
+make build-backend # notification, callbacks, bff
```
---
diff --git a/api/edge/bff/go.mod b/api/edge/bff/go.mod
index 83aea5ef..6e07bc73 100644
--- a/api/edge/bff/go.mod
+++ b/api/edge/bff/go.mod
@@ -147,11 +147,11 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
- go.opentelemetry.io/otel v1.41.0 // indirect
+ go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
- go.opentelemetry.io/otel/metric v1.41.0 // indirect
+ go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
- go.opentelemetry.io/otel/trace v1.41.0 // indirect
+ go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
diff --git a/api/edge/bff/go.sum b/api/edge/bff/go.sum
index edfb22c5..f90a66ff 100644
--- a/api/edge/bff/go.sum
+++ b/api/edge/bff/go.sum
@@ -296,20 +296,20 @@ 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.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
-go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
-go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
-go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
-go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
-go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
diff --git a/api/edge/bff/interface/api/sresponse/payment.go b/api/edge/bff/interface/api/sresponse/payment.go
index acdd44c7..a30af970 100644
--- a/api/edge/bff/interface/api/sresponse/payment.go
+++ b/api/edge/bff/interface/api/sresponse/payment.go
@@ -14,8 +14,11 @@ import (
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"
+ endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
+ "github.com/tech/sendico/server/interface/api/srequest"
+ "go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -67,16 +70,24 @@ type PaymentQuotes struct {
}
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"`
+ PaymentRef string `json:"paymentRef,omitempty"`
+ State string `json:"state,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Source *PaymentEndpoint `json:"source"`
+ Destination *PaymentEndpoint `json:"destination"`
+ 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 PaymentEndpoint struct {
+ Type string `json:"type,omitempty"`
+ Data any `json:"data,omitempty"`
+ PaymentMethodRef string `json:"paymentMethodRef,omitempty"`
+ PayeeRef string `json:"payeeRef,omitempty"`
}
type PaymentOperation struct {
@@ -290,22 +301,257 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil {
return nil
}
- operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
+ intent := p.GetIntentSnapshot()
+ operations := toUserVisibleOperations(p.GetStepExecutions())
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: "",
+ PaymentRef: p.GetPaymentRef(),
+ State: enumJSONName(p.GetState().String()),
+ Comment: strings.TrimSpace(intent.GetComment()),
+ Source: toPaymentEndpoint(intent.GetSource()),
+ Destination: toPaymentEndpoint(intent.GetDestination()),
+ FailureCode: failureCode,
+ FailureReason: failureReason,
+ Operations: operations,
+ LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
+ CreatedAt: timestampAsTime(p.GetCreatedAt()),
+ Meta: paymentMeta(p),
}
}
+func toPaymentEndpoint(endpoint *endpointv1.PaymentEndpoint) *PaymentEndpoint {
+ if endpoint == nil {
+ return nil
+ }
+ if paymentMethodRef := strings.TrimSpace(endpoint.GetPaymentMethodRef()); paymentMethodRef != "" {
+ return &PaymentEndpoint{PaymentMethodRef: paymentMethodRef}
+ }
+ if payeeRef := strings.TrimSpace(endpoint.GetPayeeRef()); payeeRef != "" {
+ return &PaymentEndpoint{PayeeRef: payeeRef}
+ }
+ method := endpoint.GetPaymentMethod()
+ if method == nil {
+ return nil
+ }
+ return &PaymentEndpoint{
+ Type: paymentEndpointType(method.GetType()),
+ Data: paymentEndpointData(method),
+ }
+}
+
+func paymentEndpointType(methodType endpointv1.PaymentMethodType) string {
+ switch methodType {
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN:
+ return string(srequest.EndpointTypeIBAN)
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD:
+ return string(srequest.EndpointTypeCard)
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN:
+ return string(srequest.EndpointTypeCardToken)
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT:
+ return string(srequest.EndpointTypeBankAccount)
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET:
+ return string(srequest.EndpointTypeWallet)
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS:
+ return string(srequest.EndpointTypeExternalChain)
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER:
+ return string(srequest.EndpointTypeLedger)
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_ACCOUNT:
+ return "account"
+ default:
+ return "unspecified"
+ }
+}
+
+func paymentEndpointData(method *endpointv1.PaymentMethod) any {
+ if method == nil {
+ return nil
+ }
+ switch method.GetType() {
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER:
+ type ledgerMethodData struct {
+ LedgerAccountRef string `bson:"ledgerAccountRef"`
+ ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
+ }
+ var payload ledgerMethodData
+ if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
+ return toRawBSON(method.GetData())
+ }
+ return srequest.LedgerEndpoint{
+ LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef),
+ ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef),
+ }
+
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET:
+ type walletMethodData struct {
+ WalletID string `bson:"walletId"`
+ }
+ var payload walletMethodData
+ if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
+ return toRawBSON(method.GetData())
+ }
+ return srequest.WalletEndpoint{
+ WalletID: strings.TrimSpace(payload.WalletID),
+ }
+
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS:
+ type cryptoMethodData struct {
+ Currency string `bson:"currency"`
+ Address string `bson:"address"`
+ Network string `bson:"network"`
+ DestinationTag *string `bson:"destinationTag,omitempty"`
+ }
+ var payload cryptoMethodData
+ if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
+ return toRawBSON(method.GetData())
+ }
+ endpoint := srequest.ExternalChainEndpoint{
+ Asset: &srequest.Asset{
+ Chain: parseChainNetwork(payload.Network),
+ TokenSymbol: strings.ToUpper(strings.TrimSpace(payload.Currency)),
+ },
+ Address: strings.TrimSpace(payload.Address),
+ }
+ if memo := strings.TrimSpace(strPtr(payload.DestinationTag)); memo != "" {
+ endpoint.Memo = memo
+ }
+ if endpoint.Asset.Chain == srequest.ChainNetworkUnspecified && endpoint.Asset.TokenSymbol == "" {
+ endpoint.Asset = nil
+ }
+ return endpoint
+
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD:
+ type cardMethodData struct {
+ Pan string `bson:"pan"`
+ FirstName string `bson:"firstName"`
+ LastName string `bson:"lastName"`
+ ExpMonth string `bson:"expMonth"`
+ ExpYear string `bson:"expYear"`
+ Country string `bson:"country,omitempty"`
+ }
+ var payload cardMethodData
+ if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
+ return toRawBSON(method.GetData())
+ }
+ return srequest.CardEndpoint{
+ Pan: strings.TrimSpace(payload.Pan),
+ FirstName: strings.TrimSpace(payload.FirstName),
+ LastName: strings.TrimSpace(payload.LastName),
+ ExpMonth: parseUint32(payload.ExpMonth),
+ ExpYear: parseUint32(payload.ExpYear),
+ Country: strings.TrimSpace(payload.Country),
+ }
+
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN:
+ type cardTokenMethodData struct {
+ Token string `bson:"token"`
+ Last4 string `bson:"last4,omitempty"`
+ }
+ var payload cardTokenMethodData
+ if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
+ return toRawBSON(method.GetData())
+ }
+ return srequest.CardTokenEndpoint{
+ Token: strings.TrimSpace(payload.Token),
+ MaskedPan: strings.TrimSpace(payload.Last4),
+ }
+
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT:
+ type bankAccountMethodData struct {
+ RecipientName string `bson:"recipientName"`
+ Inn string `bson:"inn"`
+ Kpp string `bson:"kpp"`
+ BankName string `bson:"bankName"`
+ Bik string `bson:"bik"`
+ AccountNumber string `bson:"accountNumber"`
+ CorrespondentAccount string `bson:"correspondentAccount"`
+ }
+ var payload bankAccountMethodData
+ if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
+ return toRawBSON(method.GetData())
+ }
+ return srequest.BankAccountEndpoint{
+ RecipientName: strings.TrimSpace(payload.RecipientName),
+ Inn: strings.TrimSpace(payload.Inn),
+ Kpp: strings.TrimSpace(payload.Kpp),
+ BankName: strings.TrimSpace(payload.BankName),
+ Bik: strings.TrimSpace(payload.Bik),
+ AccountNumber: strings.TrimSpace(payload.AccountNumber),
+ CorrespondentAccount: strings.TrimSpace(payload.CorrespondentAccount),
+ }
+
+ case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN:
+ type ibanMethodData struct {
+ IBAN string `bson:"iban"`
+ AccountHolder string `bson:"accountHolder"`
+ BIC *string `bson:"bic,omitempty"`
+ BankName *string `bson:"bankName,omitempty"`
+ }
+ var payload ibanMethodData
+ if err := bson.Unmarshal(method.GetData(), &payload); err != nil {
+ return toRawBSON(method.GetData())
+ }
+ return srequest.IBANEndpoint{
+ IBAN: strings.TrimSpace(payload.IBAN),
+ AccountHolder: strings.TrimSpace(payload.AccountHolder),
+ BIC: strings.TrimSpace(strPtr(payload.BIC)),
+ BankName: strings.TrimSpace(strPtr(payload.BankName)),
+ }
+
+ default:
+ return toRawBSON(method.GetData())
+ }
+}
+
+func toRawBSON(raw []byte) map[string]any {
+ if len(raw) == 0 {
+ return nil
+ }
+ var data map[string]any
+ if err := bson.Unmarshal(raw, &data); err != nil {
+ return nil
+ }
+ if len(data) == 0 {
+ return nil
+ }
+ return data
+}
+
+func parseChainNetwork(value string) srequest.ChainNetwork {
+ switch strings.ToUpper(strings.TrimSpace(value)) {
+ case "ETHEREUM_MAINNET":
+ return srequest.ChainNetworkEthereumMainnet
+ case "ARBITRUM_ONE":
+ return srequest.ChainNetworkArbitrumOne
+ case "TRON_MAINNET":
+ return srequest.ChainNetworkTronMainnet
+ case "TRON_NILE":
+ return srequest.ChainNetworkTronNile
+ case "", "UNSPECIFIED":
+ return srequest.ChainNetworkUnspecified
+ default:
+ return srequest.ChainNetwork(strings.ToLower(strings.TrimSpace(value)))
+ }
+}
+
+func parseUint32(value string) uint32 {
+ clean := strings.TrimSpace(value)
+ if clean == "" {
+ return 0
+ }
+ parsed, err := strconv.ParseUint(clean, 10, 32)
+ if err != nil {
+ return 0
+ }
+ return uint32(parsed)
+}
+
+func strPtr(v *string) string {
+ if v == nil {
+ return ""
+ }
+ return *v
+}
+
func firstFailure(operations []PaymentOperation) (string, string) {
for _, op := range operations {
if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" {
@@ -316,7 +562,7 @@ func firstFailure(operations []PaymentOperation) (string, string) {
return "", ""
}
-func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) []PaymentOperation {
+func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation {
if len(steps) == 0 {
return nil
}
@@ -325,7 +571,7 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quot
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
continue
}
- ops = append(ops, toPaymentOperation(step, quote))
+ ops = append(ops, toPaymentOperation(step))
}
if len(ops) == 0 {
return nil
@@ -333,9 +579,10 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quot
return ops
}
-func toPaymentOperation(step *orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) PaymentOperation {
+func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs())
- amount, convertedAmount := operationAmounts(step.GetStepCode(), quote)
+ amount := normalizeOperationMoney(toMoney(step.GetExecutedMoney()))
+ convertedAmount := normalizeOperationMoney(toMoney(step.GetConvertedMoney()))
op := PaymentOperation{
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
@@ -360,52 +607,19 @@ func toPaymentOperation(step *orchestrationv2.StepExecution, quote *quotationv2.
return op
}
-func operationAmounts(stepCode string, quote *quotationv2.PaymentQuote) (*paymenttypes.Money, *paymenttypes.Money) {
- if quote == nil {
- return nil, nil
+func normalizeOperationMoney(value *paymenttypes.Money) *paymenttypes.Money {
+ if value == nil {
+ return nil
}
- operation := stepOperationToken(stepCode)
-
- primary := firstValidMoney(
- toMoney(quote.GetDestinationAmount()),
- toMoney(quote.GetTransferPrincipalAmount()),
- toMoney(quote.GetPayerTotalDebitAmount()),
- )
- if operation != "fx_convert" {
- return primary, nil
+ amount := strings.TrimSpace(value.GetAmount())
+ currency := strings.TrimSpace(value.GetCurrency())
+ if amount == "" || currency == "" {
+ return 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 &paymenttypes.Money{
+ Amount: amount,
+ Currency: currency,
}
- 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 (
diff --git a/api/edge/bff/interface/api/sresponse/payment_test.go b/api/edge/bff/interface/api/sresponse/payment_test.go
index a2256b81..fd3e6a01 100644
--- a/api/edge/bff/interface/api/sresponse/payment_test.go
+++ b/api/edge/bff/interface/api/sresponse/payment_test.go
@@ -5,9 +5,12 @@ import (
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
+ endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/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"
+ "github.com/tech/sendico/server/interface/api/srequest"
+ "go.mongodb.org/mongo-driver/v2/bson"
)
func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
@@ -34,7 +37,7 @@ func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
},
}
- ops := toUserVisibleOperations(steps, nil)
+ ops := toUserVisibleOperations(steps)
if len(ops) != 2 {
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
}
@@ -137,6 +140,125 @@ func TestToPaymentMapsIntentComment(t *testing.T) {
}
}
+func TestToPaymentMapsSourceAndDestination(t *testing.T) {
+ sourceRaw, err := bson.Marshal(struct {
+ WalletID string `bson:"walletId"`
+ }{
+ WalletID: "wallet-src-1",
+ })
+ if err != nil {
+ t.Fatalf("marshal source method data: %v", err)
+ }
+ destinationRaw, err := bson.Marshal(struct {
+ Currency string `bson:"currency"`
+ Address string `bson:"address"`
+ Network string `bson:"network"`
+ DestinationTag *string `bson:"destinationTag,omitempty"`
+ }{
+ Currency: "USDT",
+ Address: "TXabc",
+ Network: "TRON_MAINNET",
+ })
+ if err != nil {
+ t.Fatalf("marshal destination method data: %v", err)
+ }
+
+ dto := toPayment(&orchestrationv2.Payment{
+ PaymentRef: "pay-src-dst",
+ State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED,
+ IntentSnapshot: "ationv2.QuoteIntent{
+ Source: &endpointv1.PaymentEndpoint{
+ Source: &endpointv1.PaymentEndpoint_PaymentMethod{
+ PaymentMethod: &endpointv1.PaymentMethod{
+ Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
+ Data: sourceRaw,
+ },
+ },
+ },
+ Destination: &endpointv1.PaymentEndpoint{
+ Source: &endpointv1.PaymentEndpoint_PaymentMethod{
+ PaymentMethod: &endpointv1.PaymentMethod{
+ Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS,
+ Data: destinationRaw,
+ },
+ },
+ },
+ },
+ })
+ if dto == nil {
+ t.Fatal("expected non-nil payment dto")
+ }
+ if dto.Source == nil {
+ t.Fatal("expected source endpoint")
+ }
+ if got, want := dto.Source.Type, string(srequest.EndpointTypeWallet); got != want {
+ t.Fatalf("source type mismatch: got=%q want=%q", got, want)
+ }
+ sourceEndpoint, ok := dto.Source.Data.(srequest.WalletEndpoint)
+ if !ok {
+ t.Fatalf("source endpoint payload type mismatch: got=%T", dto.Source.Data)
+ }
+ if got, want := sourceEndpoint.WalletID, "wallet-src-1"; got != want {
+ t.Fatalf("source wallet id mismatch: got=%q want=%q", got, want)
+ }
+ if dto.Destination == nil {
+ t.Fatal("expected destination endpoint")
+ }
+ if got, want := dto.Destination.Type, string(srequest.EndpointTypeExternalChain); got != want {
+ t.Fatalf("destination type mismatch: got=%q want=%q", got, want)
+ }
+ destinationEndpoint, ok := dto.Destination.Data.(srequest.ExternalChainEndpoint)
+ if !ok {
+ t.Fatalf("destination endpoint payload type mismatch: got=%T", dto.Destination.Data)
+ }
+ if got, want := destinationEndpoint.Address, "TXabc"; got != want {
+ t.Fatalf("destination address mismatch: got=%q want=%q", got, want)
+ }
+ if destinationEndpoint.Asset == nil {
+ t.Fatal("expected destination asset")
+ }
+ if got, want := destinationEndpoint.Asset.TokenSymbol, "USDT"; got != want {
+ t.Fatalf("destination token mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := destinationEndpoint.Asset.Chain, srequest.ChainNetworkTronMainnet; got != want {
+ t.Fatalf("destination chain mismatch: got=%q want=%q", got, want)
+ }
+}
+
+func TestToPaymentMapsEndpointRefs(t *testing.T) {
+ dto := toPayment(&orchestrationv2.Payment{
+ PaymentRef: "pay-refs",
+ State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED,
+ IntentSnapshot: "ationv2.QuoteIntent{
+ Source: &endpointv1.PaymentEndpoint{
+ Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{
+ PaymentMethodRef: "pm-123",
+ },
+ },
+ Destination: &endpointv1.PaymentEndpoint{
+ Source: &endpointv1.PaymentEndpoint_PayeeRef{
+ PayeeRef: "payee-777",
+ },
+ },
+ },
+ })
+ if dto == nil {
+ t.Fatal("expected non-nil payment dto")
+ }
+ if dto.Source == nil {
+ t.Fatal("expected source endpoint")
+ }
+ if got, want := dto.Source.PaymentMethodRef, "pm-123"; got != want {
+ t.Fatalf("source payment_method_ref mismatch: got=%q want=%q", got, want)
+ }
+ if dto.Destination == nil {
+ t.Fatal("expected destination endpoint")
+ }
+ if got, want := dto.Destination.PayeeRef, "payee-777"; got != want {
+ t.Fatalf("destination payee_ref mismatch: got=%q want=%q", got, want)
+ }
+}
+
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
dto := toPaymentQuote("ationv2.PaymentQuote{
QuoteRef: "quote-1",
@@ -166,7 +288,7 @@ func TestToPaymentOperation_MapsOperationRefAndGateway(t *testing.T) {
Ref: "op-123",
},
},
- }, nil)
+ })
if got, want := op.OperationRef, "op-123"; got != want {
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
@@ -181,7 +303,7 @@ func TestToPaymentOperation_InfersGatewayFromStepCode(t *testing.T) {
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)
@@ -204,7 +326,7 @@ func TestToPaymentOperation_DoesNotFallbackToCardPayoutRef(t *testing.T) {
Ref: "payout-123",
},
},
- }, nil)
+ })
if got := op.OperationRef; got != "" {
t.Fatalf("expected empty operation_ref, got=%q", got)
@@ -219,15 +341,28 @@ func TestToPaymentOperation_MapsAmount(t *testing.T) {
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 got := op.Amount; got != nil {
+ t.Fatalf("expected nil amount without executed_money, got=%+v", got)
+ }
+ if got := op.ConvertedAmount; got != nil {
+ t.Fatalf("expected no converted_amount for non-fx operation, got=%+v", got)
+ }
+}
+
+func TestToPaymentOperation_PrefersExecutedMoney(t *testing.T) {
+ op := toPaymentOperation(&orchestrationv2.StepExecution{
+ StepRef: "step-4b",
+ StepCode: "hop.4.card_payout.send",
+ State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
+ ExecutedMoney: &moneyv1.Money{Amount: "99.95", Currency: "EUR"},
})
if op.Amount == nil {
t.Fatal("expected amount to be mapped")
}
- if got, want := op.Amount.Amount, "100.00"; got != want {
+ if got, want := op.Amount.Amount, "99.95"; got != want {
t.Fatalf("amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "EUR"; got != want {
@@ -240,22 +375,14 @@ func TestToPaymentOperation_MapsAmount(t *testing.T) {
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"},
+ StepRef: "step-5",
+ StepCode: "hop.2.settlement.fx_convert",
+ State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
+ ConvertedMoney: &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 got := op.Amount; got != nil {
+ t.Fatalf("expected nil base amount without executed_money, got=%+v", got)
}
if op.ConvertedAmount == nil {
t.Fatal("expected fx converted amount to be mapped")
@@ -267,3 +394,32 @@ func TestToPaymentOperation_MapsFxTwoAmounts(t *testing.T) {
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
}
}
+
+func TestToPaymentOperation_FxWithExecutedMoney_StillProvidesTwoAmounts(t *testing.T) {
+ op := toPaymentOperation(&orchestrationv2.StepExecution{
+ StepRef: "step-6",
+ StepCode: "hop.2.settlement.fx_convert",
+ State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
+ ExecutedMoney: &moneyv1.Money{Amount: "109.50", Currency: "USDT"},
+ ConvertedMoney: &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, "109.50"; 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 quote 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)
+ }
+}
diff --git a/api/fx/storage/go.sum b/api/fx/storage/go.sum
index 52cb78fe..0ec8db8a 100644
--- a/api/fx/storage/go.sum
+++ b/api/fx/storage/go.sum
@@ -152,8 +152,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
diff --git a/api/gateway/aurora/README.md b/api/gateway/aurora/README.md
index 763e06e0..601c9261 100644
--- a/api/gateway/aurora/README.md
+++ b/api/gateway/aurora/README.md
@@ -13,14 +13,38 @@ Aurora is a dev/test-only card payout gateway with the same gRPC contract as `mn
- No outbound payout/tokenization HTTP calls are made.
## Built-in test cards
-- `2200001111111111`: approved instantly (`success`, code `00`)
-- `2200002222222222`: pending issuer review (`waiting`, code `P01`)
-- `2200003333333333`: insufficient funds (`failed`, code `51`)
-- `2200004444444444`: issuer unavailable retryable (`failed`, code `10101`)
-- `2200005555555555`: stolen card (`failed`, code `43`)
-- `2200006666666666`: do not honor (`failed`, code `05`)
-- `2200007777777777`: expired card (`failed`, code `54`)
-- any other PAN: default queued processing (`waiting`, code `P00`)
+- Every scenario has multiple generated PANs (8 per case), all Luhn-valid.
+- PANs are generated from scenario prefixes in [`scenario_simulator.go`](./internal/service/gateway/scenario_simulator.go) via `generatePANSeriesWithLuhn`.
+- Scenario prefixes:
+ - `22000011`: approved instantly (`success`, code `00`)
+ - `22000022`: pending issuer review (`waiting`, code `P01`)
+ - `22000033`: insufficient funds (`failed`, code `51`)
+ - `22000044`: issuer unavailable retryable (`failed`, code `10101`)
+ - `22000055`: stolen card (`failed`, code `43`)
+ - `22000066`: do not honor (`failed`, code `05`)
+ - `22000077`: expired card (`failed`, code `54`)
+ - `22000088`: provider timeout transport error
+ - `22000098`: provider unreachable transport error
+ - `22000097`: provider maintenance (`failed`, code `91`)
+ - `22000096`: provider system malfunction (`failed`, code `96`)
+- Any other valid PAN defaults to queued processing (`waiting`, code `P00`).
+
+### Auxiliary PAN Table (Generated, Luhn-valid)
+
+| Scenario | Prefix | PANs |
+|---|---|---|
+| approved_instant | `22000011` | `2200001100000001`
`2200001100000019`
`2200001100000027`
`2200001100000035`
`2200001100000043`
`2200001100000050`
`2200001100000068`
`2200001100000076` |
+| pending_issuer_review | `22000022` | `2200002200000008`
`2200002200000016`
`2200002200000024`
`2200002200000032`
`2200002200000040`
`2200002200000057`
`2200002200000065`
`2200002200000073` |
+| insufficient_funds | `22000033` | `2200003300000005`
`2200003300000013`
`2200003300000021`
`2200003300000039`
`2200003300000047`
`2200003300000054`
`2200003300000062`
`2200003300000070` |
+| issuer_unavailable_retryable | `22000044` | `2200004400000002`
`2200004400000010`
`2200004400000028`
`2200004400000036`
`2200004400000044`
`2200004400000051`
`2200004400000069`
`2200004400000077` |
+| stolen_card | `22000055` | `2200005500000008`
`2200005500000016`
`2200005500000024`
`2200005500000032`
`2200005500000040`
`2200005500000057`
`2200005500000065`
`2200005500000073` |
+| do_not_honor | `22000066` | `2200006600000005`
`2200006600000013`
`2200006600000021`
`2200006600000039`
`2200006600000047`
`2200006600000054`
`2200006600000062`
`2200006600000070` |
+| expired_card | `22000077` | `2200007700000002`
`2200007700000010`
`2200007700000028`
`2200007700000036`
`2200007700000044`
`2200007700000051`
`2200007700000069`
`2200007700000077` |
+| provider_timeout_transport | `22000088` | `2200008800000009`
`2200008800000017`
`2200008800000025`
`2200008800000033`
`2200008800000041`
`2200008800000058`
`2200008800000066`
`2200008800000074` |
+| provider_unreachable_transport | `22000098` | `2200009800000007`
`2200009800000015`
`2200009800000023`
`2200009800000031`
`2200009800000049`
`2200009800000056`
`2200009800000064`
`2200009800000072` |
+| provider_maintenance | `22000097` | `2200009700000008`
`2200009700000016`
`2200009700000024`
`2200009700000032`
`2200009700000040`
`2200009700000057`
`2200009700000065`
`2200009700000073` |
+| provider_system_malfunction | `22000096` | `2200009600000009`
`2200009600000017`
`2200009600000025`
`2200009600000033`
`2200009600000041`
`2200009600000058`
`2200009600000066`
`2200009600000074` |
+| default_processing (example) | `22000999` | `2200099900000007` |
## Notes
- PAN is masked in logs.
diff --git a/api/gateway/aurora/internal/service/gateway/aurora_scenarios_test.go b/api/gateway/aurora/internal/service/gateway/aurora_scenarios_test.go
index edf5b444..32b330f6 100644
--- a/api/gateway/aurora/internal/service/gateway/aurora_scenarios_test.go
+++ b/api/gateway/aurora/internal/service/gateway/aurora_scenarios_test.go
@@ -33,7 +33,7 @@ func TestAuroraCardPayoutScenarios(t *testing.T) {
}{
{
name: "approved_instant",
- pan: "2200001111111111",
+ pan: scenarioPAN("approved_instant", 0),
wantAccepted: true,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS,
wantErrorCode: "00",
@@ -42,7 +42,7 @@ func TestAuroraCardPayoutScenarios(t *testing.T) {
},
{
name: "pending_issuer_review",
- pan: "2200002222222222",
+ pan: scenarioPAN("pending_issuer_review", 0),
wantAccepted: true,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING,
wantErrorCode: "P01",
@@ -51,7 +51,7 @@ func TestAuroraCardPayoutScenarios(t *testing.T) {
},
{
name: "insufficient_funds",
- pan: "2200003333333333",
+ pan: scenarioPAN("insufficient_funds", 0),
wantAccepted: false,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
wantErrorCode: "51",
@@ -60,7 +60,7 @@ func TestAuroraCardPayoutScenarios(t *testing.T) {
},
{
name: "unknown_card_default_queue",
- pan: "2200009999999999",
+ pan: defaultScenarioPAN(),
wantAccepted: true,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING,
wantErrorCode: "P00",
@@ -69,7 +69,7 @@ func TestAuroraCardPayoutScenarios(t *testing.T) {
},
{
name: "provider_maintenance",
- pan: "2200009999999997",
+ pan: scenarioPAN("provider_maintenance", 0),
wantAccepted: false,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
wantErrorCode: "91",
@@ -78,7 +78,7 @@ func TestAuroraCardPayoutScenarios(t *testing.T) {
},
{
name: "provider_system_malfunction",
- pan: "2200009999999996",
+ pan: scenarioPAN("provider_system_malfunction", 0),
wantAccepted: false,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
wantErrorCode: "96",
@@ -148,7 +148,7 @@ func TestAuroraTransportFailureScenarioEventuallyFails(t *testing.T) {
req.OperationRef = "op-transport-timeout"
req.ParentPaymentRef = "parent-transport-timeout"
req.IdempotencyKey = "idem-transport-timeout"
- req.CardPan = "2200008888888888"
+ req.CardPan = scenarioPAN("provider_timeout_transport", 0)
resp, err := processor.Submit(context.Background(), req)
if err != nil {
@@ -201,7 +201,7 @@ func TestAuroraRetryableScenarioEventuallyFails(t *testing.T) {
req.OperationRef = "op-retryable-issuer-unavailable"
req.ParentPaymentRef = "parent-retryable-issuer-unavailable"
req.IdempotencyKey = "idem-retryable-issuer-unavailable"
- req.CardPan = "2200004444444444"
+ req.CardPan = scenarioPAN("issuer_unavailable_retryable", 0)
resp, err := processor.Submit(context.Background(), req)
if err != nil {
@@ -248,7 +248,7 @@ func TestAuroraTokenPayoutUsesTokenizedPANScenario(t *testing.T) {
tokenizeReq := validCardTokenizeRequest()
tokenizeReq.RequestId = "tok-req-insufficient"
- tokenizeReq.CardPan = "2200003333333333"
+ tokenizeReq.CardPan = scenarioPAN("insufficient_funds", 0)
tokenizeResp, err := processor.Tokenize(context.Background(), tokenizeReq)
if err != nil {
diff --git a/api/gateway/aurora/internal/service/gateway/card_pan.go b/api/gateway/aurora/internal/service/gateway/card_pan.go
new file mode 100644
index 00000000..03f78e9e
--- /dev/null
+++ b/api/gateway/aurora/internal/service/gateway/card_pan.go
@@ -0,0 +1,149 @@
+package gateway
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/tech/sendico/pkg/merrors"
+)
+
+const (
+ minCardPANLength = 12
+ maxCardPANLength = 19
+)
+
+func normalizeCardNumber(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ var b strings.Builder
+ b.Grow(len(value))
+ for _, r := range value {
+ if r >= '0' && r <= '9' {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+func validateCardPAN(pan string, field string) error {
+ normalized, err := normalizedPANForValidation(pan, field)
+ if err != nil {
+ return err
+ }
+ if !isValidPANLuhn(normalized) {
+ return merrors.InvalidArgument("card_pan checksum is invalid", field)
+ }
+ return nil
+}
+
+func normalizedPANForValidation(pan string, field string) (string, error) {
+ field = strings.TrimSpace(field)
+ if field == "" {
+ field = "card_pan"
+ }
+
+ clean := strings.TrimSpace(pan)
+ if clean == "" {
+ return "", merrors.InvalidArgument("card_pan is required", field)
+ }
+
+ compact := strings.NewReplacer(" ", "", "-", "").Replace(clean)
+ if compact == "" {
+ return "", merrors.InvalidArgument("card_pan must contain only digits (spaces/hyphens allowed)", field)
+ }
+
+ normalized := normalizeCardNumber(compact)
+ if normalized != compact {
+ return "", merrors.InvalidArgument("card_pan must contain only digits (spaces/hyphens allowed)", field)
+ }
+ if len(normalized) < minCardPANLength || len(normalized) > maxCardPANLength {
+ return "", merrors.InvalidArgument(
+ fmt.Sprintf("card_pan length must be between %d and %d", minCardPANLength, maxCardPANLength),
+ field,
+ )
+ }
+ return normalized, nil
+}
+
+func isValidPANLuhn(number string) bool {
+ if len(number) < minCardPANLength || len(number) > maxCardPANLength {
+ return false
+ }
+
+ sum := 0
+ double := false
+ for i := len(number) - 1; i >= 0; i-- {
+ d := int(number[i] - '0')
+ if d < 0 || d > 9 {
+ return false
+ }
+ if double {
+ d *= 2
+ if d > 9 {
+ d -= 9
+ }
+ }
+ sum += d
+ double = !double
+ }
+ return sum%10 == 0
+}
+
+func generatePANWithLuhn(prefix string, sequence uint64, panLength int) (string, error) {
+ prefix = normalizeCardNumber(prefix)
+ if prefix == "" {
+ return "", merrors.InvalidArgument("prefix is required", "prefix")
+ }
+ if panLength < minCardPANLength || panLength > maxCardPANLength {
+ return "", merrors.InvalidArgument(
+ fmt.Sprintf("pan_length must be between %d and %d", minCardPANLength, maxCardPANLength),
+ "pan_length",
+ )
+ }
+
+ bodyLen := panLength - 1
+ if len(prefix) > bodyLen {
+ return "", merrors.InvalidArgument("prefix is too long for selected pan_length", "prefix")
+ }
+
+ seqWidth := bodyLen - len(prefix)
+ seqMod := pow10(seqWidth)
+ body := prefix + fmt.Sprintf("%0*d", seqWidth, sequence%seqMod)
+
+ for checksum := 0; checksum <= 9; checksum++ {
+ candidate := body + strconv.Itoa(checksum)
+ if isValidPANLuhn(candidate) {
+ return candidate, nil
+ }
+ }
+ return "", merrors.Internal("failed to generate PAN checksum digit")
+}
+
+func generatePANSeriesWithLuhn(prefix string, count int, panLength int) ([]string, error) {
+ if count <= 0 {
+ return nil, nil
+ }
+ out := make([]string, 0, count)
+ for i := 0; i < count; i++ {
+ pan, err := generatePANWithLuhn(prefix, uint64(i), panLength)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, pan)
+ }
+ return out, nil
+}
+
+func pow10(n int) uint64 {
+ if n <= 0 {
+ return 1
+ }
+ p := uint64(1)
+ for i := 0; i < n; i++ {
+ p *= 10
+ }
+ return p
+}
diff --git a/api/gateway/aurora/internal/service/gateway/card_pan_test.go b/api/gateway/aurora/internal/service/gateway/card_pan_test.go
new file mode 100644
index 00000000..9ecf9b30
--- /dev/null
+++ b/api/gateway/aurora/internal/service/gateway/card_pan_test.go
@@ -0,0 +1,36 @@
+package gateway
+
+import "testing"
+
+func TestValidateCardPAN_AllowsFormattedInput(t *testing.T) {
+ if err := validateCardPAN("4111 1111-1111 1111", "card_pan"); err != nil {
+ t.Fatalf("expected formatted PAN to pass validation, got %v", err)
+ }
+}
+
+func TestValidateCardPAN_RejectsInvalidChecksum(t *testing.T) {
+ if err := validateCardPAN("4111111111111112", "card_pan"); err == nil {
+ t.Fatal("expected invalid checksum to fail validation")
+ }
+}
+
+func TestGeneratePANSeriesWithLuhn_ProducesValidUniquePANs(t *testing.T) {
+ pans, err := generatePANSeriesWithLuhn("22000033", 6, 16)
+ if err != nil {
+ t.Fatalf("generatePANSeriesWithLuhn returned error: %v", err)
+ }
+ if len(pans) != 6 {
+ t.Fatalf("unexpected PAN count: got=%d want=6", len(pans))
+ }
+
+ seen := map[string]struct{}{}
+ for _, pan := range pans {
+ if !isValidPANLuhn(pan) {
+ t.Fatalf("generated PAN is not Luhn-valid: %q", pan)
+ }
+ if _, ok := seen[pan]; ok {
+ t.Fatalf("duplicate PAN generated: %q", pan)
+ }
+ seen[pan] = struct{}{}
+ }
+}
diff --git a/api/gateway/aurora/internal/service/gateway/card_payout_validation.go b/api/gateway/aurora/internal/service/gateway/card_payout_validation.go
index 138dd540..6178fb59 100644
--- a/api/gateway/aurora/internal/service/gateway/card_payout_validation.go
+++ b/api/gateway/aurora/internal/service/gateway/card_payout_validation.go
@@ -49,6 +49,9 @@ func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg provider.Confi
if pan == "" {
return newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card_pan"))
}
+ if err := validateCardPAN(pan, "card_pan"); err != nil {
+ return newPayoutError("invalid_card_pan_crc", err)
+ }
if strings.TrimSpace(req.GetCardHolder()) == "" {
return newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card_holder"))
}
diff --git a/api/gateway/aurora/internal/service/gateway/card_payout_validation_test.go b/api/gateway/aurora/internal/service/gateway/card_payout_validation_test.go
index 9a8f7c81..4f3c8882 100644
--- a/api/gateway/aurora/internal/service/gateway/card_payout_validation_test.go
+++ b/api/gateway/aurora/internal/service/gateway/card_payout_validation_test.go
@@ -75,6 +75,11 @@ func TestValidateCardPayoutRequest_Errors(t *testing.T) {
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" },
expected: "missing_card_pan",
},
+ {
+ name: "invalid_card_pan_crc",
+ mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "4111111111111112" },
+ expected: "invalid_card_pan_crc",
+ },
{
name: "missing_card_holder",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" },
diff --git a/api/gateway/aurora/internal/service/gateway/card_processor_test.go b/api/gateway/aurora/internal/service/gateway/card_processor_test.go
index 7453b06c..1c8f1486 100644
--- a/api/gateway/aurora/internal/service/gateway/card_processor_test.go
+++ b/api/gateway/aurora/internal/service/gateway/card_processor_test.go
@@ -295,14 +295,14 @@ func TestCardPayoutProcessor_Submit_SameParentDifferentOperationsStoredSeparatel
req1.OperationRef = op1
req1.IdempotencyKey = "idem-1"
req1.ParentPaymentRef = parentPaymentRef
- req1.CardPan = "2204310000002456"
+ req1.CardPan = scenarioPAN("approved_instant", 1)
req2 := validCardPayoutRequest()
req2.PayoutId = ""
req2.OperationRef = op2
req2.IdempotencyKey = "idem-2"
req2.ParentPaymentRef = parentPaymentRef
- req2.CardPan = "2204320000009754"
+ req2.CardPan = scenarioPAN("pending_issuer_review", 1)
if _, err := processor.Submit(context.Background(), req1); err != nil {
t.Fatalf("first submit failed: %v", err)
@@ -385,14 +385,14 @@ func TestCardPayoutProcessor_StrictMode_BlocksSecondOperationUntilFirstFinalCall
req1.OperationRef = "op-strict-1"
req1.ParentPaymentRef = "payment-strict-1"
req1.IdempotencyKey = "idem-strict-1"
- req1.CardPan = "2204310000002456"
+ req1.CardPan = scenarioPAN("approved_instant", 2)
req2 := validCardPayoutRequest()
req2.PayoutId = ""
req2.OperationRef = "op-strict-2"
req2.ParentPaymentRef = "payment-strict-2"
req2.IdempotencyKey = "idem-strict-2"
- req2.CardPan = "2204320000009754"
+ req2.CardPan = scenarioPAN("pending_issuer_review", 2)
if _, err := processor.Submit(context.Background(), req1); err != nil {
t.Fatalf("first submit failed: %v", err)
diff --git a/api/gateway/aurora/internal/service/gateway/card_tokenize_validation.go b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation.go
index 31c75aa8..907f4485 100644
--- a/api/gateway/aurora/internal/service/gateway/card_tokenize_validation.go
+++ b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation.go
@@ -41,6 +41,9 @@ func validateCardTokenizeRequest(req *mntxv1.CardTokenizeRequest, cfg provider.C
if card.pan == "" {
return nil, newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card.pan"))
}
+ if err := validateCardPAN(card.pan, "card.pan"); err != nil {
+ return nil, newPayoutError("invalid_card_pan_crc", err)
+ }
if card.holder == "" {
return nil, newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card.holder"))
}
diff --git a/api/gateway/aurora/internal/service/gateway/card_tokenize_validation_test.go b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation_test.go
index 31e02531..cedd48e1 100644
--- a/api/gateway/aurora/internal/service/gateway/card_tokenize_validation_test.go
+++ b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation_test.go
@@ -65,6 +65,15 @@ func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) {
requireReason(t, err, "missing_card_pan")
}
+func TestValidateCardTokenizeRequest_InvalidCardPanCRC(t *testing.T) {
+ cfg := testProviderConfig()
+ req := validCardTokenizeRequest()
+ req.CardPan = "4111111111111112"
+
+ _, err := validateCardTokenizeRequest(req, cfg)
+ requireReason(t, err, "invalid_card_pan_crc")
+}
+
func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) {
cfg := testProviderConfig()
cfg.RequireCustomerAddress = true
diff --git a/api/gateway/aurora/internal/service/gateway/scenario_simulator.go b/api/gateway/aurora/internal/service/gateway/scenario_simulator.go
index 3406b306..2f3bcc48 100644
--- a/api/gateway/aurora/internal/service/gateway/scenario_simulator.go
+++ b/api/gateway/aurora/internal/service/gateway/scenario_simulator.go
@@ -28,12 +28,31 @@ type payoutSimulator struct {
seq atomic.Uint64
}
+const (
+ scenarioPANLength = 16
+ scenarioPANVariants = 8
+)
+
+var scenarioPANPrefixes = map[string]string{
+ "approved_instant": "22000011",
+ "pending_issuer_review": "22000022",
+ "insufficient_funds": "22000033",
+ "issuer_unavailable_retryable": "22000044",
+ "stolen_card": "22000055",
+ "do_not_honor": "22000066",
+ "expired_card": "22000077",
+ "provider_timeout_transport": "22000088",
+ "provider_unreachable_transport": "22000098",
+ "provider_maintenance": "22000097",
+ "provider_system_malfunction": "22000096",
+}
+
func newPayoutSimulator() *payoutSimulator {
return &payoutSimulator{
scenarios: []simulatedCardScenario{
{
Name: "approved_instant",
- CardNumbers: []string{"2200001111111111"},
+ CardNumbers: mustScenarioPANs("approved_instant"),
Accepted: true,
ProviderStatus: "success",
ErrorCode: "00",
@@ -41,7 +60,7 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "pending_issuer_review",
- CardNumbers: []string{"2200002222222222"},
+ CardNumbers: mustScenarioPANs("pending_issuer_review"),
Accepted: true,
ProviderStatus: "processing",
ErrorCode: "P01",
@@ -49,7 +68,7 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "insufficient_funds",
- CardNumbers: []string{"2200003333333333"},
+ CardNumbers: mustScenarioPANs("insufficient_funds"),
CardLast4: []string{"3333"},
Accepted: false,
ErrorCode: "51",
@@ -57,7 +76,7 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "issuer_unavailable_retryable",
- CardNumbers: []string{"2200004444444444"},
+ CardNumbers: mustScenarioPANs("issuer_unavailable_retryable"),
CardLast4: []string{"4444"},
Accepted: false,
ErrorCode: "10101",
@@ -65,7 +84,7 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "stolen_card",
- CardNumbers: []string{"2200005555555555"},
+ CardNumbers: mustScenarioPANs("stolen_card"),
CardLast4: []string{"5555"},
Accepted: false,
ErrorCode: "43",
@@ -73,7 +92,7 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "do_not_honor",
- CardNumbers: []string{"2200006666666666"},
+ CardNumbers: mustScenarioPANs("do_not_honor"),
CardLast4: []string{"6666"},
Accepted: false,
ErrorCode: "05",
@@ -81,7 +100,7 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "expired_card",
- CardNumbers: []string{"2200007777777777"},
+ CardNumbers: mustScenarioPANs("expired_card"),
CardLast4: []string{"7777"},
Accepted: false,
ErrorCode: "54",
@@ -89,19 +108,19 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "provider_timeout_transport",
- CardNumbers: []string{"2200008888888888"},
+ CardNumbers: mustScenarioPANs("provider_timeout_transport"),
CardLast4: []string{"8888"},
DispatchError: "provider timeout while calling payout endpoint",
},
{
Name: "provider_unreachable_transport",
- CardNumbers: []string{"2200009999999998"},
+ CardNumbers: mustScenarioPANs("provider_unreachable_transport"),
CardLast4: []string{"9998"},
DispatchError: "provider host unreachable",
},
{
Name: "provider_maintenance",
- CardNumbers: []string{"2200009999999997"},
+ CardNumbers: mustScenarioPANs("provider_maintenance"),
CardLast4: []string{"9997"},
Accepted: false,
ErrorCode: "91",
@@ -109,7 +128,7 @@ func newPayoutSimulator() *payoutSimulator {
},
{
Name: "provider_system_malfunction",
- CardNumbers: []string{"2200009999999996"},
+ CardNumbers: mustScenarioPANs("provider_system_malfunction"),
CardLast4: []string{"9996"},
Accepted: false,
ErrorCode: "96",
@@ -126,6 +145,45 @@ func newPayoutSimulator() *payoutSimulator {
}
}
+func mustScenarioPANs(name string) []string {
+ pans := scenarioPANs(name)
+ if len(pans) == 0 {
+ panic("aurora simulator scenario pan generation failed for " + name)
+ }
+ return pans
+}
+
+func scenarioPANs(name string) []string {
+ prefix := strings.TrimSpace(scenarioPANPrefixes[name])
+ if prefix == "" {
+ return nil
+ }
+ pans, err := generatePANSeriesWithLuhn(prefix, scenarioPANVariants, scenarioPANLength)
+ if err != nil {
+ return nil
+ }
+ return pans
+}
+
+func scenarioPAN(name string, index int) string {
+ pans := scenarioPANs(name)
+ if len(pans) == 0 {
+ return ""
+ }
+ if index < 0 {
+ index = 0
+ }
+ return pans[index%len(pans)]
+}
+
+func defaultScenarioPAN() string {
+ pan, err := generatePANWithLuhn("22000999", 0, scenarioPANLength)
+ if err != nil {
+ return ""
+ }
+ return pan
+}
+
func (s *payoutSimulator) resolveByPAN(pan string) simulatedCardScenario {
return s.resolve(normalizeCardNumber(pan), "")
}
@@ -210,21 +268,6 @@ func scenarioMatchesLast4(scenario simulatedCardScenario, last4 string) bool {
return false
}
-func normalizeCardNumber(value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return ""
- }
- var b strings.Builder
- b.Grow(len(value))
- for _, r := range value {
- if r >= '0' && r <= '9' {
- b.WriteRune(r)
- }
- }
- return b.String()
-}
-
func normalizeExpiryYear(year uint32) string {
if year == 0 {
return ""
diff --git a/api/gateway/aurora/internal/service/gateway/scenario_simulator_test.go b/api/gateway/aurora/internal/service/gateway/scenario_simulator_test.go
index b82ee5ac..3f3c595f 100644
--- a/api/gateway/aurora/internal/service/gateway/scenario_simulator_test.go
+++ b/api/gateway/aurora/internal/service/gateway/scenario_simulator_test.go
@@ -10,7 +10,7 @@ import (
func TestPayoutSimulatorResolveByPAN_KnownCard(t *testing.T) {
sim := newPayoutSimulator()
- scenario := sim.resolveByPAN("2200003333333333")
+ scenario := sim.resolveByPAN(scenarioPAN("insufficient_funds", 0))
if scenario.Name != "insufficient_funds" {
t.Fatalf("unexpected scenario: got=%q", scenario.Name)
}
@@ -22,7 +22,7 @@ func TestPayoutSimulatorResolveByPAN_KnownCard(t *testing.T) {
func TestPayoutSimulatorResolveByPAN_Default(t *testing.T) {
sim := newPayoutSimulator()
- scenario := sim.resolveByPAN("2200009999999999")
+ scenario := sim.resolveByPAN(defaultScenarioPAN())
if scenario.Name != "default_processing" {
t.Fatalf("unexpected default scenario: got=%q", scenario.Name)
}
@@ -31,6 +31,21 @@ func TestPayoutSimulatorResolveByPAN_Default(t *testing.T) {
}
}
+func TestPayoutSimulatorScenarioPANs_AreLuhnValidAndExpanded(t *testing.T) {
+ sim := newPayoutSimulator()
+
+ for _, scenario := range sim.scenarios {
+ if got, want := len(scenario.CardNumbers), scenarioPANVariants; got != want {
+ t.Fatalf("expected %d PANs for scenario %q, got=%d", want, scenario.Name, got)
+ }
+ for _, pan := range scenario.CardNumbers {
+ if !isValidPANLuhn(pan) {
+ t.Fatalf("scenario %q has invalid Luhn PAN %q", scenario.Name, pan)
+ }
+ }
+ }
+}
+
func TestApplyCardPayoutSendResult_AcceptedSuccessStatus(t *testing.T) {
state := &model.CardPayout{}
result := &provider.CardPayoutSendResult{
diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum
index d94c4caf..4c41d3ef 100644
--- a/api/payments/orchestrator/go.sum
+++ b/api/payments/orchestrator/go.sum
@@ -155,16 +155,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.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
-go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
-go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
-go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
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.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
-go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
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=
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go
index 0a60069a..17488544 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go
@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mlogger"
pm "github.com/tech/sendico/pkg/model"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -68,6 +69,8 @@ type StepExecution struct {
FailureCode string `bson:"failureCode,omitempty" json:"failureCode,omitempty"`
FailureMsg string `bson:"failureMsg,omitempty" json:"failureMsg,omitempty"`
ExternalRefs []ExternalRef `bson:"externalRefs,omitempty" json:"externalRefs,omitempty"`
+ ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executedMoney,omitempty"`
+ ConvertedMoney *paymenttypes.Money `bson:"convertedMoney,omitempty" json:"convertedMoney,omitempty"`
}
// ExternalRef links step execution to an external operation.
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go
index bfb5e591..e94d7d2d 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go
@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/merrors"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
type normalizedEvent struct {
@@ -15,6 +16,7 @@ type normalizedEvent struct {
targetState agg.StepState `bson:"targetState"`
failureInfo *failureInfo `bson:"failure,omitempty"`
forceAggregate *forceAggregate `bson:"forceAggregate,omitempty"`
+ executedMoney *paymenttypes.Money
}
type failureInfo struct {
@@ -123,6 +125,7 @@ func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) {
targetState: target,
failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)),
forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention),
+ executedMoney: normalizeEventMoney(src.ExecutedMoney),
}
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
{
@@ -264,6 +267,21 @@ func normalizeCardStatus(status CardStatus) (CardStatus, bool) {
}
}
+func normalizeEventMoney(money *paymenttypes.Money) *paymenttypes.Money {
+ if money == nil {
+ return nil
+ }
+ amount := strings.TrimSpace(money.GetAmount())
+ currency := strings.TrimSpace(money.GetCurrency())
+ if amount == "" || currency == "" {
+ return nil
+ }
+ return &paymenttypes.Money{
+ Amount: amount,
+ Currency: currency,
+ }
+}
+
func mapFailureTarget(status any, retryable *bool) (agg.StepState, bool) {
switch status {
case GatewayStatusCreated, GatewayStatusProcessing, GatewayStatusWaiting:
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go
index b201c0e6..161dea75 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go
@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/mlogger"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
@@ -81,6 +82,7 @@ type GatewayEvent struct {
TransferRef string
GatewayInstanceID string
Status GatewayStatus
+ ExecutedMoney *paymenttypes.Money
FailureCode string
FailureMsg string
Retryable *bool
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go
index d85c6c47..0de918ac 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go
@@ -10,6 +10,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -124,6 +125,7 @@ func (s *svc) applyStepEvent(step *agg.StepExecution, event *normalizedEvent, sm
if out.State == target {
changed = s.applyStepDiagnostics(&out, event) || changed
+ changed = applyExecutedMoney(&out, event) || changed
*step = out
return changed, nil
}
@@ -140,11 +142,43 @@ func (s *svc) applyStepEvent(step *agg.StepExecution, event *normalizedEvent, sm
out = next
changed = changed || transitionChanged
changed = s.applyStepDiagnostics(&out, event) || changed
+ changed = applyExecutedMoney(&out, event) || changed
*step = out
return changed, nil
}
+func applyExecutedMoney(step *agg.StepExecution, event *normalizedEvent) bool {
+ if step == nil || event == nil {
+ return false
+ }
+ money := normalizeEventMoney(event.executedMoney)
+ if money == nil {
+ return false
+ }
+ if normalizeEventMoney(step.ExecutedMoney) != nil {
+ return false
+ }
+ if stepMoneyEqual(step.ExecutedMoney, money) {
+ return false
+ }
+ step.ExecutedMoney = money
+ return true
+}
+
+func stepMoneyEqual(left, right *paymenttypes.Money) bool {
+ left = normalizeEventMoney(left)
+ right = normalizeEventMoney(right)
+ switch {
+ case left == nil && right == nil:
+ return true
+ case left == nil || right == nil:
+ return false
+ default:
+ return left.Amount == right.Amount && left.Currency == right.Currency
+ }
+}
+
func transitionStepState(step agg.StepExecution, target agg.StepState, sm ostate.StateMachine) (agg.StepExecution, bool, error) {
if step.State == target {
return step, false, nil
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go
index 34b4c437..9f49ab88 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go
@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/merrors"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
@@ -32,6 +33,7 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
TransferRef: "tx-1",
GatewayInstanceID: "gw-1",
Status: GatewayStatusWaiting,
+ ExecutedMoney: &paymenttypes.Money{Amount: "5.84", Currency: "USDT"},
},
},
})
@@ -61,6 +63,15 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindTransfer, Ref: "tx-1"}) {
t.Fatalf("expected transfer_ref external reference")
}
+ if got.ExecutedMoney == nil {
+ t.Fatal("expected executed money to be mapped")
+ }
+ if gotAmt, want := got.ExecutedMoney.Amount, "5.84"; gotAmt != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", gotAmt, want)
+ }
+ if gotCur, want := got.ExecutedMoney.Currency, "USDT"; gotCur != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", gotCur, want)
+ }
if out.Payment.State != agg.StateExecuting {
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting)
}
@@ -109,6 +120,54 @@ func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
}
}
+func TestReconcile_GatewayExecutedMoney_DoesNotOverrideExisting(t *testing.T) {
+ now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
+ reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
+
+ out, err := reconciler.Reconcile(Input{
+ Payment: &agg.Payment{
+ PaymentRef: "p1",
+ State: agg.StateCreated,
+ Version: 1,
+ StepExecutions: []agg.StepExecution{
+ {
+ StepRef: "s1",
+ StepCode: "observe",
+ State: agg.StepStatePending,
+ Attempt: 1,
+ ExecutedMoney: &paymenttypes.Money{
+ Amount: "76.50",
+ Currency: "RUB",
+ },
+ },
+ },
+ },
+ Event: Event{
+ Gateway: &GatewayEvent{
+ StepRef: "s1",
+ Status: GatewayStatusSuccess,
+ ExecutedMoney: &paymenttypes.Money{
+ Amount: "7650",
+ Currency: "RUB",
+ },
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("Reconcile returned error: %v", err)
+ }
+ step := out.Payment.StepExecutions[0]
+ if step.ExecutedMoney == nil {
+ t.Fatal("expected executed money")
+ }
+ if got, want := step.ExecutedMoney.Amount, "76.50"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := step.ExecutedMoney.Currency, "RUB"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
+}
+
func TestReconcile_GatewayFailureMapping(t *testing.T) {
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go
index c1dac99e..4d0ed6d2 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go
@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/pkg/db/storable"
pm "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -108,6 +109,8 @@ func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
step.Attempt = 1
}
step.ExternalRefs = cloneExternalRefs(step.ExternalRefs)
+ step.ExecutedMoney = cloneStepMoney(step.ExecutedMoney)
+ step.ConvertedMoney = cloneStepMoney(step.ConvertedMoney)
step.StartedAt = cloneTime(step.StartedAt)
step.CompletedAt = cloneTime(step.CompletedAt)
out = append(out, step)
@@ -115,6 +118,21 @@ func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
return out
}
+func cloneStepMoney(money *paymenttypes.Money) *paymenttypes.Money {
+ if money == nil {
+ return nil
+ }
+ amount := strings.TrimSpace(money.GetAmount())
+ currency := strings.TrimSpace(money.GetCurrency())
+ if amount == "" || currency == "" {
+ return nil
+ }
+ return &paymenttypes.Money{
+ Amount: amount,
+ Currency: currency,
+ }
+}
+
func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef {
if len(refs) == 0 {
return nil
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go
index b3291681..fd6f16ee 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go
@@ -118,6 +118,24 @@ func TestMap_Success(t *testing.T) {
if got, want := steps[0].GetUserLabel(), "Card payout"; got != want {
t.Fatalf("user_label mismatch: got=%q want=%q", got, want)
}
+ if steps[0].GetExecutedMoney() == nil {
+ t.Fatal("expected executed_money to be mapped")
+ }
+ if got, want := steps[0].GetExecutedMoney().GetAmount(), "95"; got != want {
+ t.Fatalf("executed_money.amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := steps[0].GetExecutedMoney().GetCurrency(), "USD"; got != want {
+ t.Fatalf("executed_money.currency mismatch: got=%q want=%q", got, want)
+ }
+ if steps[0].GetConvertedMoney() == nil {
+ t.Fatal("expected converted_money to be mapped")
+ }
+ if got, want := steps[0].GetConvertedMoney().GetAmount(), "90"; got != want {
+ t.Fatalf("converted_money.amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := steps[0].GetConvertedMoney().GetCurrency(), "EUR"; got != want {
+ t.Fatalf("converted_money.currency mismatch: got=%q want=%q", got, want)
+ }
if got, want := steps[1].GetReportVisibility(), orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN; got != want {
t.Fatalf("report_visibility mismatch: got=%s want=%s", got.String(), want.String())
}
@@ -363,7 +381,15 @@ func newPaymentFixture() *agg.Payment {
UserLabel: " Card payout ",
State: agg.StepStateRunning,
Attempt: 0,
- StartedAt: &startedAt,
+ ExecutedMoney: &paymenttypes.Money{
+ Amount: "95",
+ Currency: "USD",
+ },
+ ConvertedMoney: &paymenttypes.Money{
+ Amount: "90",
+ Currency: "EUR",
+ },
+ StartedAt: &startedAt,
},
{
StepRef: "s2",
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go
index 25cbdcd3..af17feb4 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go
@@ -46,6 +46,8 @@ func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepE
Refs: mapExternalRefs(step.StepCode, step.ExternalRefs),
ReportVisibility: mapReportVisibility(step.ReportVisibility),
UserLabel: strings.TrimSpace(step.UserLabel),
+ ExecutedMoney: moneyToProto(step.ExecutedMoney),
+ ConvertedMoney: moneyToProto(step.ConvertedMoney),
}, nil
}
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go
index 9b3e7b17..6a62d3b8 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go
@@ -85,6 +85,8 @@ func (defaultObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, re
step := req.StepExecution
step.State = agg.StepStateRunning
step.ExternalRefs = refs
+ step.ExecutedMoney = inheritedExecutedMoney(req.Payment, req.Step, req.StepExecution)
+ step.ConvertedMoney = inheritedConvertedMoney(req.Payment, req.Step, req.StepExecution)
step.FailureCode = ""
step.FailureMsg = ""
return &sexec.ExecuteOutput{
@@ -198,6 +200,46 @@ func inheritedExternalRefs(payment *agg.Payment, step xplan.Step, current agg.St
return refs
}
+func inheritedExecutedMoney(payment *agg.Payment, step xplan.Step, current agg.StepExecution) *paymenttypes.Money {
+ if money := cloneStepMoney(current.ExecutedMoney); money != nil {
+ return money
+ }
+ if payment == nil || len(step.DependsOn) == 0 {
+ return nil
+ }
+ index := stepIndexByRef(payment.StepExecutions)
+ for i := range step.DependsOn {
+ idx, ok := index[strings.TrimSpace(step.DependsOn[i])]
+ if !ok || idx < 0 || idx >= len(payment.StepExecutions) {
+ continue
+ }
+ if money := cloneStepMoney(payment.StepExecutions[idx].ExecutedMoney); money != nil {
+ return money
+ }
+ }
+ return nil
+}
+
+func inheritedConvertedMoney(payment *agg.Payment, step xplan.Step, current agg.StepExecution) *paymenttypes.Money {
+ if money := cloneStepMoney(current.ConvertedMoney); money != nil {
+ return money
+ }
+ if payment == nil || len(step.DependsOn) == 0 {
+ return nil
+ }
+ index := stepIndexByRef(payment.StepExecutions)
+ for i := range step.DependsOn {
+ idx, ok := index[strings.TrimSpace(step.DependsOn[i])]
+ if !ok || idx < 0 || idx >= len(payment.StepExecutions) {
+ continue
+ }
+ if money := cloneStepMoney(payment.StepExecutions[idx].ConvertedMoney); money != nil {
+ return money
+ }
+ }
+ return nil
+}
+
func appendExternalRefs(existing []agg.ExternalRef, additions ...agg.ExternalRef) []agg.ExternalRef {
out := append([]agg.ExternalRef{}, existing...)
seen := map[string]struct{}{}
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go
index 8a7898de..b61e6e3d 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors_test.go
@@ -122,6 +122,14 @@ func TestDefaultObserveConfirmExecutor_InheritsDependencyRefs(t *testing.T) {
StepExecutions: []agg.StepExecution{
{
StepRef: "hop_1_crypto_send",
+ ExecutedMoney: &paymenttypes.Money{
+ Amount: "1.000000",
+ Currency: "USDT",
+ },
+ ConvertedMoney: &paymenttypes.Money{
+ Amount: "95.00",
+ Currency: "EUR",
+ },
ExternalRefs: []agg.ExternalRef{
{GatewayInstanceID: "crypto-gw", Kind: "operation_ref", Ref: "op-1"},
{GatewayInstanceID: "crypto-gw", Kind: "transfer_ref", Ref: "trf-1"},
@@ -161,4 +169,22 @@ func TestDefaultObserveConfirmExecutor_InheritsDependencyRefs(t *testing.T) {
if got, want := out.StepExecution.ExternalRefs[0].Ref, "op-1"; got != want {
t.Fatalf("first external ref value mismatch: got=%q want=%q", got, want)
}
+ if out.StepExecution.ExecutedMoney == nil {
+ t.Fatal("expected executed money to be inherited from dependency")
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
+ if out.StepExecution.ConvertedMoney == nil {
+ t.Fatal("expected converted money to be inherited from dependency")
+ }
+ if got, want := out.StepExecution.ConvertedMoney.Amount, "95.00"; got != want {
+ t.Fatalf("converted money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := out.StepExecution.ConvertedMoney.Currency, "EUR"; got != want {
+ t.Fatalf("converted money currency mismatch: got=%q want=%q", got, want)
+ }
}
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go
index 39fd00b3..6b85e613 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go
@@ -325,6 +325,8 @@ func normalizeExecutorOutput(current agg.StepExecution, out *sexec.ExecuteOutput
next.Attempt = out.StepExecution.Attempt
}
next.ExternalRefs = out.StepExecution.ExternalRefs
+ next.ExecutedMoney = cloneStepMoney(out.StepExecution.ExecutedMoney)
+ next.ConvertedMoney = cloneStepMoney(out.StepExecution.ConvertedMoney)
next.FailureCode = strings.TrimSpace(out.StepExecution.FailureCode)
next.FailureMsg = strings.TrimSpace(out.StepExecution.FailureMsg)
@@ -437,6 +439,12 @@ func stepExecutionEqual(left, right agg.StepExecution) bool {
return false
}
}
+ if !stepMoneyEqual(left.ExecutedMoney, right.ExecutedMoney) {
+ return false
+ }
+ if !stepMoneyEqual(left.ConvertedMoney, right.ConvertedMoney) {
+ return false
+ }
return true
}
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/step_money.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/step_money.go
new file mode 100644
index 00000000..6aed2204
--- /dev/null
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/step_money.go
@@ -0,0 +1,35 @@
+package psvc
+
+import (
+ "strings"
+
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
+)
+
+func cloneStepMoney(src *paymenttypes.Money) *paymenttypes.Money {
+ if src == nil {
+ return nil
+ }
+ amount := strings.TrimSpace(src.GetAmount())
+ currency := strings.TrimSpace(src.GetCurrency())
+ if amount == "" || currency == "" {
+ return nil
+ }
+ return &paymenttypes.Money{
+ Amount: amount,
+ Currency: currency,
+ }
+}
+
+func stepMoneyEqual(left, right *paymenttypes.Money) bool {
+ left = cloneStepMoney(left)
+ right = cloneStepMoney(right)
+ switch {
+ case left == nil && right == nil:
+ return true
+ case left == nil || right == nil:
+ return false
+ default:
+ return left.Amount == right.Amount && left.Currency == right.Currency
+ }
+}
diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go
index edc87c4f..5ee3e030 100644
--- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go
+++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go
@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func (s *svc) prepareInput(in Input) (*preparedInput, error) {
@@ -174,6 +175,8 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste
exec.Rail = model.ParseRail(string(exec.Rail))
exec.ReportVisibility = model.NormalizeReportVisibility(exec.ReportVisibility)
exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
+ exec.ExecutedMoney = cloneStepMoney(exec.ExecutedMoney)
+ exec.ConvertedMoney = cloneStepMoney(exec.ConvertedMoney)
if exec.StepRef == "" {
return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].step_ref is required")
}
@@ -257,9 +260,26 @@ func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
func cloneStepExecution(exec agg.StepExecution) agg.StepExecution {
out := exec
out.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
+ out.ExecutedMoney = cloneStepMoney(exec.ExecutedMoney)
+ out.ConvertedMoney = cloneStepMoney(exec.ConvertedMoney)
return out
}
+func cloneStepMoney(money *paymenttypes.Money) *paymenttypes.Money {
+ if money == nil {
+ return nil
+ }
+ amount := strings.TrimSpace(money.GetAmount())
+ currency := strings.TrimSpace(money.GetCurrency())
+ if amount == "" || currency == "" {
+ return nil
+ }
+ return &paymenttypes.Money{
+ Amount: amount,
+ Currency: currency,
+ }
+}
+
func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef {
if len(refs) == 0 {
return nil
diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go
index c5334ec9..e1711d35 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go
@@ -170,6 +170,11 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s
step := req.StepExecution
step.State = agg.StepStateCompleted
step.ExternalRefs = externalRefs
+ step.ExecutedMoney = cardMinorToMoney(responsePayout.GetAmountMinor(), responsePayout.GetCurrency())
+ if step.ExecutedMoney == nil {
+ step.ExecutedMoney = cardMinorToMoney(amountMinor, currency)
+ }
+ step.ConvertedMoney = nil
step.FailureCode = ""
step.FailureMsg = ""
return &sexec.ExecuteOutput{StepExecution: step}, nil
diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go
index 9f8bfd7a..c9578f3c 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go
@@ -100,9 +100,10 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
InstanceID: paymenttypes.DefaultCardsGatewayID,
},
StepExecution: agg.StepExecution{
- StepRef: "hop_4_card_payout_send",
- StepCode: "hop.4.card_payout.send",
- Attempt: 1,
+ StepRef: "hop_4_card_payout_send",
+ StepCode: "hop.4.card_payout.send",
+ Attempt: 1,
+ ConvertedMoney: &paymenttypes.Money{Amount: "1.00", Currency: "USD"},
},
}
@@ -116,6 +117,18 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
if out.StepExecution.State != agg.StepStateCompleted {
t.Fatalf("expected completed state, got=%q", out.StepExecution.State)
}
+ if out.StepExecution.ExecutedMoney == nil {
+ t.Fatal("expected executed money to be recorded")
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Amount, "76.50"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Currency, "RUB"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
+ if got := out.StepExecution.ConvertedMoney; got != nil {
+ t.Fatalf("expected converted money to be cleared for card payout step, got=%+v", got)
+ }
if payoutReq == nil {
t.Fatal("expected payout request to be submitted")
}
diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go
index 9282f65d..ed207ac6 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go
@@ -93,6 +93,11 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
return nil, refsErr
}
step.ExternalRefs = refs
+ step.ExecutedMoney = transferExecutedMoney(resp.GetTransfer())
+ if step.ExecutedMoney == nil {
+ step.ExecutedMoney = protoMoneyToPaymentMoney(amount)
+ }
+ step.ConvertedMoney = nil
if action == discovery.RailOperationSend {
if err := e.submitWalletFeeTransfer(ctx, req, client, gateway, sourceWalletRef, operationRef, idempotencyKey); err != nil {
diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go
index 103d0fde..e98811b7 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go
@@ -106,6 +106,15 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) {
if out.StepExecution.State != agg.StepStateCompleted {
t.Fatalf("expected completed state, got=%q", out.StepExecution.State)
}
+ if out.StepExecution.ExecutedMoney == nil {
+ t.Fatal("expected executed money to be recorded")
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
if submitReq == nil {
t.Fatal("expected transfer submission request")
}
diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go
index add5f3c1..a264e670 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go
@@ -159,6 +159,7 @@ func buildGatewayExecutionEvent(payment *agg.Payment, msg *pmodel.PaymentGateway
TransferRef: transferRef,
GatewayInstanceID: gatewayInstanceID,
Status: status,
+ ExecutedMoney: clonePaymentMoney(msg.ExecutedMoney),
}
switch status {
@@ -479,6 +480,7 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment
TransferRef: strings.TrimSpace(candidate.transferRef),
GatewayInstanceID: resolvedObserveGatewayID(candidate.gatewayInstanceID, gateway),
Status: status,
+ ExecutedMoney: transferExecutedMoney(transfer),
}
switch status {
case erecon.GatewayStatusFailed:
@@ -544,6 +546,10 @@ func (s *Service) resolveObserveGateway(ctx context.Context, payment *agg.Paymen
return item, nil
}
}
+ if s.logger != nil {
+ s.logger.Debug("Gateway for polling not found", zap.String("instance_id", hint.instanceID),
+ zap.String("expected_rail", string(expectedRail)), zap.String("hint_rail", string(hint.rail)))
+ }
return nil, errors.New("observe polling: gateway instance not found")
}
diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go
index ba0e19a8..8be98d85 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go
@@ -15,6 +15,7 @@ import (
pm "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/payments/rail"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
+ moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -43,9 +44,10 @@ func TestBuildGatewayExecutionEvent_MapsStatusAndMatchedStep(t *testing.T) {
}
event, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{
- PaymentRef: payment.PaymentRef,
- Status: rail.OperationResultSuccess,
- TransferRef: "trf-1",
+ PaymentRef: payment.PaymentRef,
+ Status: rail.OperationResultSuccess,
+ TransferRef: "trf-1",
+ ExecutedMoney: &paymenttypes.Money{Amount: "5.84", Currency: "USDT"},
})
if !ok {
t.Fatal("expected gateway execution event to be accepted")
@@ -59,6 +61,15 @@ func TestBuildGatewayExecutionEvent_MapsStatusAndMatchedStep(t *testing.T) {
if got, want := event.Status, erecon.GatewayStatusSuccess; got != want {
t.Fatalf("status mismatch: got=%q want=%q", got, want)
}
+ if event.ExecutedMoney == nil {
+ t.Fatal("expected executed money to be mapped")
+ }
+ if got, want := event.ExecutedMoney.Amount, "5.84"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := event.ExecutedMoney.Currency, "USDT"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
}
func TestBuildGatewayExecutionEvent_FailedSetsTerminalNeedsAttentionHint(t *testing.T) {
@@ -602,6 +613,7 @@ func TestPollObserveCandidate_UsesResolvedGatewayAfterInstanceRotation(t *testin
TransferRef: req.GetTransferRef(),
OperationRef: operationRef,
Status: chainv1.TransferStatus_TRANSFER_SUCCESS,
+ NetAmount: &moneyv1.Money{Amount: "460.00", Currency: "RUB"},
},
}, nil
},
@@ -676,6 +688,15 @@ func TestPollObserveCandidate_UsesResolvedGatewayAfterInstanceRotation(t *testin
if got, want := gw.GatewayInstanceID, "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4"; got != want {
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
}
+ if gw.ExecutedMoney == nil {
+ t.Fatal("expected executed money in reconciled gateway event")
+ }
+ if got, want := gw.ExecutedMoney.Amount, "460.00"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := gw.ExecutedMoney.Currency, "RUB"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
}
var _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil)
diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go
index ba17f60a..73aff9ba 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go
@@ -118,6 +118,8 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
step.State = agg.StepStateCompleted
step.FailureCode = ""
step.FailureMsg = ""
+ step.ExecutedMoney = protoMoneyToPaymentMoney(amount)
+ step.ConvertedMoney = nil
step.ExternalRefs = appendLedgerExternalRef(step.ExternalRefs, agg.ExternalRef{
GatewayInstanceID: firstNonEmpty(strings.TrimSpace(req.Step.InstanceID), strings.TrimSpace(req.Step.Gateway)),
Kind: erecon.ExternalRefKindLedger,
diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go
index 3ced6336..e4c62a4a 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go
@@ -42,9 +42,10 @@ func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRol
Rail: discovery.RailLedger,
},
StepExecution: agg.StepExecution{
- StepRef: "edge_1_2_ledger_credit",
- StepCode: "edge.1_2.ledger.credit",
- Attempt: 1,
+ StepRef: "edge_1_2_ledger_credit",
+ StepCode: "edge.1_2.ledger.credit",
+ Attempt: 1,
+ ConvertedMoney: &paymenttypes.Money{Amount: "42", Currency: "EUR"},
},
})
if err != nil {
@@ -71,6 +72,18 @@ func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRol
if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want {
t.Fatalf("state mismatch: got=%q want=%q", got, want)
}
+ if out.StepExecution.ExecutedMoney == nil {
+ t.Fatal("expected executed money to be recorded")
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
+ if got := out.StepExecution.ConvertedMoney; got != nil {
+ t.Fatalf("expected converted money to be cleared for ledger step, got=%+v", got)
+ }
if len(out.StepExecution.ExternalRefs) != 1 {
t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs))
}
diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go
index 6d1835b8..8608e6d4 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go
@@ -10,6 +10,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
@@ -55,6 +56,10 @@ func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx contex
if err != nil {
return nil, err
}
+ convertedAmount := settlementConvertedMoney(req.Payment)
+ if convertedAmount == nil {
+ return nil, merrors.InvalidArgument("settlement fx_convert: converted amount is required")
+ }
stepRef := strings.TrimSpace(req.Step.StepRef)
operationRef := strings.TrimSpace(req.Payment.PaymentRef) + ":" + stepRef
@@ -93,12 +98,21 @@ func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx contex
return nil, refsErr
}
step.ExternalRefs = refs
+ step.ExecutedMoney = protoMoneyToPaymentMoney(amount)
+ step.ConvertedMoney = convertedAmount
step.State = agg.StepStateCompleted
step.FailureCode = ""
step.FailureMsg = ""
return &sexec.ExecuteOutput{StepExecution: step}, nil
}
+func settlementConvertedMoney(payment *agg.Payment) *paymenttypes.Money {
+ if payment == nil || payment.QuoteSnapshot == nil {
+ return nil
+ }
+ return clonePaymentMoney(payment.QuoteSnapshot.ExpectedSettlementAmount)
+}
+
func (e *gatewayProviderSettlementExecutor) resolveGateway(ctx context.Context, step xplan.Step) (*model.GatewayInstanceDescriptor, error) {
if e.gatewayRegistry == nil {
return nil, merrors.InvalidArgument("settlement fx_convert: gateway registry is required")
diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go
index e3e2a7d4..be3fbe4e 100644
--- a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go
+++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go
@@ -101,6 +101,24 @@ func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_SubmitsTran
if out.StepExecution.State != agg.StepStateCompleted {
t.Fatalf("expected completed state, got=%q", out.StepExecution.State)
}
+ if out.StepExecution.ExecutedMoney == nil {
+ t.Fatal("expected executed money to be recorded")
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
+ t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
+ t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
+ }
+ if out.StepExecution.ConvertedMoney == nil {
+ t.Fatal("expected converted money to be recorded for settlement fx_convert")
+ }
+ if got, want := out.StepExecution.ConvertedMoney.Amount, "76.63"; got != want {
+ t.Fatalf("converted money amount mismatch: got=%q want=%q", got, want)
+ }
+ if got, want := out.StepExecution.ConvertedMoney.Currency, "RUB"; got != want {
+ t.Fatalf("converted money currency mismatch: got=%q want=%q", got, want)
+ }
if submitReq == nil {
t.Fatal("expected transfer submission request")
}
@@ -190,3 +208,64 @@ func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_MissingSett
t.Fatalf("unexpected error: %v", err)
}
}
+
+func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_MissingConvertedAmount(t *testing.T) {
+ orgID := bson.NewObjectID()
+
+ executor := &gatewayProviderSettlementExecutor{
+ gatewayInvokeResolver: &fakeGatewayInvokeResolver{
+ client: &chainclient.Fake{},
+ },
+ gatewayRegistry: &fakeGatewayRegistry{
+ items: []*model.GatewayInstanceDescriptor{
+ {
+ ID: "payment_gateway_settlement",
+ InstanceID: "payment_gateway_settlement",
+ Rail: discovery.RailProviderSettlement,
+ InvokeURI: "grpc://tgsettle-gateway",
+ IsEnabled: true,
+ },
+ },
+ },
+ }
+
+ _, err := executor.ExecuteProviderSettlement(context.Background(), sexec.StepRequest{
+ Payment: &agg.Payment{
+ OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
+ PaymentRef: "payment-3",
+ IdempotencyKey: "idem-3",
+ IntentSnapshot: model.PaymentIntent{
+ Ref: "intent-3",
+ Source: model.PaymentEndpoint{
+ Type: model.EndpointTypeManagedWallet,
+ ManagedWallet: &model.ManagedWalletEndpoint{
+ ManagedWalletRef: "wallet-src",
+ },
+ },
+ Amount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"},
+ },
+ QuoteSnapshot: &model.PaymentQuoteSnapshot{
+ DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"},
+ },
+ },
+ Step: xplan.Step{
+ StepRef: "hop_2_settlement_fx_convert",
+ StepCode: "hop.2.settlement.fx_convert",
+ Action: discovery.RailOperationFXConvert,
+ Rail: discovery.RailProviderSettlement,
+ Gateway: "payment_gateway_settlement",
+ InstanceID: "payment_gateway_settlement",
+ },
+ StepExecution: agg.StepExecution{
+ StepRef: "hop_2_settlement_fx_convert",
+ StepCode: "hop.2.settlement.fx_convert",
+ Attempt: 1,
+ },
+ })
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "converted amount is required") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
diff --git a/api/payments/orchestrator/internal/service/orchestrator/step_money.go b/api/payments/orchestrator/internal/service/orchestrator/step_money.go
new file mode 100644
index 00000000..eb8fef39
--- /dev/null
+++ b/api/payments/orchestrator/internal/service/orchestrator/step_money.go
@@ -0,0 +1,64 @@
+package orchestrator
+
+import (
+ "strings"
+
+ "github.com/shopspring/decimal"
+ paymenttypes "github.com/tech/sendico/pkg/payments/types"
+ moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
+ chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
+)
+
+var cardMinorUnitScale = decimal.NewFromInt(100)
+
+func clonePaymentMoney(src *paymenttypes.Money) *paymenttypes.Money {
+ if src == nil {
+ return nil
+ }
+ amount := strings.TrimSpace(src.GetAmount())
+ currency := strings.TrimSpace(src.GetCurrency())
+ if amount == "" || currency == "" {
+ return nil
+ }
+ return &paymenttypes.Money{
+ Amount: amount,
+ Currency: currency,
+ }
+}
+
+func protoMoneyToPaymentMoney(src *moneyv1.Money) *paymenttypes.Money {
+ if src == nil {
+ return nil
+ }
+ amount := strings.TrimSpace(src.GetAmount())
+ currency := strings.TrimSpace(src.GetCurrency())
+ if amount == "" || currency == "" {
+ return nil
+ }
+ return &paymenttypes.Money{Amount: amount, Currency: currency}
+}
+
+func transferExecutedMoney(transfer *chainv1.Transfer) *paymenttypes.Money {
+ if transfer == nil {
+ return nil
+ }
+ if money := protoMoneyToPaymentMoney(transfer.GetNetAmount()); money != nil {
+ return money
+ }
+ return protoMoneyToPaymentMoney(transfer.GetRequestedAmount())
+}
+
+func cardMinorToMoney(amountMinor int64, currency string) *paymenttypes.Money {
+ currency = strings.ToUpper(strings.TrimSpace(currency))
+ if idx := strings.Index(currency, "-"); idx > 0 {
+ currency = currency[:idx]
+ }
+ if currency == "" {
+ return nil
+ }
+ amount := decimal.NewFromInt(amountMinor).Div(cardMinorUnitScale)
+ return &paymenttypes.Money{
+ Amount: amount.StringFixed(2),
+ Currency: currency,
+ }
+}
diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum
index a357eb04..f1aefe34 100644
--- a/api/payments/quotation/go.sum
+++ b/api/payments/quotation/go.sum
@@ -155,16 +155,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.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
-go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
-go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
-go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
+go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
+go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
+go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
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.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
-go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
+go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
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=
diff --git a/api/proto/payments/orchestration/v2/orchestration.proto b/api/proto/payments/orchestration/v2/orchestration.proto
index 3ae411d3..27e6722c 100644
--- a/api/proto/payments/orchestration/v2/orchestration.proto
+++ b/api/proto/payments/orchestration/v2/orchestration.proto
@@ -6,6 +6,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/orchestration/v2
import "google/protobuf/timestamp.proto";
import "api/proto/common/gateway/v1/gateway.proto";
+import "api/proto/common/money/v1/money.proto";
import "api/proto/common/pagination/v1/cursor.proto";
import "api/proto/payments/shared/v1/shared.proto";
import "api/proto/payments/quotation/v2/quotation.proto";
@@ -175,6 +176,10 @@ message StepExecution {
ReportVisibility report_visibility = 9;
// Optional user-facing operation label.
string user_label = 10;
+ // Final amount processed by this step (if known).
+ common.money.v1.Money executed_money = 11;
+ // Converted amount produced by this step (for FX operations).
+ common.money.v1.Money converted_money = 12;
}
// Kept local on purpose: no shared enum exists for orchestration step runtime.
diff --git a/ci/dev/README.md b/ci/dev/README.md
index 0312b270..b69324f5 100644
--- a/ci/dev/README.md
+++ b/ci/dev/README.md
@@ -27,7 +27,7 @@ make up
make vault-init
# 4. View logs
-make logs SERVICE=ledger
+make logs SERVICE=dev-ledger
# 5. Stop everything
make down
@@ -46,8 +46,8 @@ All in `.env.dev` (plaintext for dev):
make build # Build all images
make up # Start environment
make down # Stop environment
-make logs SERVICE=ledger # View service logs
-make rebuild SERVICE=ledger # Rebuild specific service
+make logs SERVICE=dev-ledger # View service logs
+make rebuild SERVICE=dev-ledger # Rebuild specific service
make proto # Regenerate protobuf
make clean # Remove everything
make status # Check service status
@@ -78,11 +78,11 @@ After code changes:
```bash
# Rebuild specific service
-make rebuild SERVICE=ledger
+make rebuild SERVICE=dev-ledger
# Or manually
-docker compose -f docker-compose.dev.yml build ledger
-docker compose -f docker-compose.dev.yml up -d --force-recreate ledger
+docker compose -f docker-compose.dev.yml build dev-ledger
+docker compose -f docker-compose.dev.yml up -d --force-recreate dev-ledger
```
## Adding New Services
diff --git a/ci/prod/compose/bff.yml b/ci/prod/compose/bff.yml
index d452da18..dc732f6f 100644
--- a/ci/prod/compose/bff.yml
+++ b/ci/prod/compose/bff.yml
@@ -22,6 +22,7 @@ services:
sendico_bff:
<<: *common-env
container_name: sendico-bff
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/bff/service:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/billing_documents.yml b/ci/prod/compose/billing_documents.yml
index b1062c02..59382161 100644
--- a/ci/prod/compose/billing_documents.yml
+++ b/ci/prod/compose/billing_documents.yml
@@ -14,6 +14,7 @@ services:
sendico_billing_documents:
<<: *common-env
container_name: sendico-billing-documents
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/billing/documents:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/billing_fees.yml b/ci/prod/compose/billing_fees.yml
index 215cd305..5a996d17 100644
--- a/ci/prod/compose/billing_fees.yml
+++ b/ci/prod/compose/billing_fees.yml
@@ -14,6 +14,7 @@ services:
sendico_billing_fees:
<<: *common-env
container_name: sendico-billing-fees
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/billing/fees:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/callbacks.yml b/ci/prod/compose/callbacks.yml
index d368caca..3020c6b0 100644
--- a/ci/prod/compose/callbacks.yml
+++ b/ci/prod/compose/callbacks.yml
@@ -22,6 +22,7 @@ services:
sendico_callbacks:
<<: *common-env
container_name: sendico-callbacks
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/edge/callbacks:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/chain_gateway.yml b/ci/prod/compose/chain_gateway.yml
index fa56dd14..d052f537 100644
--- a/ci/prod/compose/chain_gateway.yml
+++ b/ci/prod/compose/chain_gateway.yml
@@ -22,6 +22,7 @@ services:
sendico_chain_gateway:
<<: *common-env
container_name: sendico-chain-gateway
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/gateway/chain:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/discovery.yml b/ci/prod/compose/discovery.yml
index 7bc3341c..c9fd1840 100644
--- a/ci/prod/compose/discovery.yml
+++ b/ci/prod/compose/discovery.yml
@@ -14,6 +14,7 @@ services:
sendico_discovery:
<<: *common-env
container_name: sendico-discovery
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/discovery/service:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/fx_ingestor.yml b/ci/prod/compose/fx_ingestor.yml
index af1b50d7..da891368 100644
--- a/ci/prod/compose/fx_ingestor.yml
+++ b/ci/prod/compose/fx_ingestor.yml
@@ -14,6 +14,7 @@ services:
sendico_fx_ingestor:
<<: *common-env
container_name: sendico-fx-ingestor
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/fx/ingestor:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/fx_oracle.yml b/ci/prod/compose/fx_oracle.yml
index b777ed7c..0a9e7a47 100644
--- a/ci/prod/compose/fx_oracle.yml
+++ b/ci/prod/compose/fx_oracle.yml
@@ -14,6 +14,7 @@ services:
sendico_fx_oracle:
<<: *common-env
container_name: sendico-fx-oracle
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/fx/oracle:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/ledger.yml b/ci/prod/compose/ledger.yml
index c32d6e46..905fdcf4 100644
--- a/ci/prod/compose/ledger.yml
+++ b/ci/prod/compose/ledger.yml
@@ -14,6 +14,7 @@ services:
sendico_ledger:
<<: *common-env
container_name: sendico-ledger
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/ledger/service:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/mntx_gateway.yml b/ci/prod/compose/mntx_gateway.yml
index 1c9feec6..5314c0f6 100644
--- a/ci/prod/compose/mntx_gateway.yml
+++ b/ci/prod/compose/mntx_gateway.yml
@@ -14,6 +14,7 @@ services:
sendico_mntx_gateway:
<<: *common-env
container_name: sendico-mntx-gateway
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/gateway/mntx:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/notification.yml b/ci/prod/compose/notification.yml
index 3ca2723e..8e9116de 100644
--- a/ci/prod/compose/notification.yml
+++ b/ci/prod/compose/notification.yml
@@ -14,6 +14,7 @@ services:
sendico_notification:
<<: *common-env
container_name: sendico-notification
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/notification/service:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/payments_methods.yml b/ci/prod/compose/payments_methods.yml
index 4c24fb89..c62673c3 100644
--- a/ci/prod/compose/payments_methods.yml
+++ b/ci/prod/compose/payments_methods.yml
@@ -14,6 +14,7 @@ services:
sendico_payments_methods:
<<: *common-env
container_name: sendico-payments-methods
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/payments/methods:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/payments_orchestrator.yml b/ci/prod/compose/payments_orchestrator.yml
index ab4de199..b42fa262 100644
--- a/ci/prod/compose/payments_orchestrator.yml
+++ b/ci/prod/compose/payments_orchestrator.yml
@@ -14,6 +14,7 @@ services:
sendico_payments_orchestrator:
<<: *common-env
container_name: sendico-payments-orchestrator
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/payments/orchestrator:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/payments_quotation.yml b/ci/prod/compose/payments_quotation.yml
index a3b8d50c..09a47c1a 100644
--- a/ci/prod/compose/payments_quotation.yml
+++ b/ci/prod/compose/payments_quotation.yml
@@ -14,6 +14,7 @@ services:
sendico_payments_quotation:
<<: *common-env
container_name: sendico-payments-quotation
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/payments/quotation:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/tgsettle_gateway.yml b/ci/prod/compose/tgsettle_gateway.yml
index 85cd61ae..1f505f7a 100644
--- a/ci/prod/compose/tgsettle_gateway.yml
+++ b/ci/prod/compose/tgsettle_gateway.yml
@@ -14,6 +14,7 @@ services:
sendico_tgsettle_gateway:
<<: *common-env
container_name: sendico-tgsettle-gateway
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/gateway/tgsettle:${APP_V}
pull_policy: always
diff --git a/ci/prod/compose/tron_gateway.yml b/ci/prod/compose/tron_gateway.yml
index 3b491437..c800b05d 100644
--- a/ci/prod/compose/tron_gateway.yml
+++ b/ci/prod/compose/tron_gateway.yml
@@ -22,6 +22,7 @@ services:
sendico_tron_gateway:
<<: *common-env
container_name: sendico-tron-gateway
+ platform: linux/amd64
restart: unless-stopped
image: ${REGISTRY_URL}/gateway/tron:${APP_V}
pull_policy: always
diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart
index 222d55f2..23d7d449 100644
--- a/frontend/pshared/lib/data/dto/payment/payment.dart
+++ b/frontend/pshared/lib/data/dto/payment/payment.dart
@@ -3,14 +3,16 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/operation.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
+import 'package:pshared/data/dto/payment/response_endpoint.dart';
part 'payment.g.dart';
@JsonSerializable()
class PaymentDTO {
final String? paymentRef;
- final String? idempotencyKey;
final String? state;
+ final PaymentResponseEndpointDTO? source;
+ final PaymentResponseEndpointDTO? destination;
final String? failureCode;
final String? failureReason;
final PaymentIntentDTO? intent;
@@ -21,8 +23,9 @@ class PaymentDTO {
const PaymentDTO({
this.paymentRef,
- this.idempotencyKey,
this.state,
+ this.source,
+ this.destination,
this.failureCode,
this.failureReason,
this.intent,
diff --git a/frontend/pshared/lib/data/dto/payment/response_endpoint.dart b/frontend/pshared/lib/data/dto/payment/response_endpoint.dart
new file mode 100644
index 00000000..5a8be677
--- /dev/null
+++ b/frontend/pshared/lib/data/dto/payment/response_endpoint.dart
@@ -0,0 +1,22 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'response_endpoint.g.dart';
+
+@JsonSerializable()
+class PaymentResponseEndpointDTO {
+ final String? type;
+ final Map? data;
+ final String? paymentMethodRef;
+ final String? payeeRef;
+
+ const PaymentResponseEndpointDTO({
+ this.type,
+ this.data,
+ this.paymentMethodRef,
+ this.payeeRef,
+ });
+
+ factory PaymentResponseEndpointDTO.fromJson(Map json) =>
+ _$PaymentResponseEndpointDTOFromJson(json);
+ Map toJson() => _$PaymentResponseEndpointDTOToJson(this);
+}
diff --git a/frontend/pshared/lib/data/mapper/payment/payment.dart b/frontend/pshared/lib/data/mapper/payment/payment.dart
index 39b6483d..93835344 100644
--- a/frontend/pshared/lib/data/mapper/payment/payment.dart
+++ b/frontend/pshared/lib/data/mapper/payment/payment.dart
@@ -2,20 +2,24 @@ import 'package:pshared/data/dto/payment/card.dart';
import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/external_chain.dart';
+import 'package:pshared/data/dto/payment/iban.dart';
import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/data/dto/payment/managed_wallet.dart';
+import 'package:pshared/data/dto/payment/russian_bank.dart';
+import 'package:pshared/data/dto/payment/wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
-import 'package:pshared/data/mapper/payment/type.dart';
import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart';
+import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
+import 'package:pshared/models/payment/methods/russian_bank.dart';
+import 'package:pshared/models/payment/methods/wallet.dart';
import 'package:pshared/models/payment/type.dart';
-
extension PaymentMethodDataEndpointMapper on PaymentMethodData {
PaymentEndpointDTO toDTO() {
final metadata = this.metadata;
@@ -76,8 +80,40 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData {
).toJson(),
metadata: metadata,
);
- default:
- throw UnsupportedError('Unsupported payment endpoint type: $type');
+ case PaymentType.wallet:
+ final payload = this as WalletPaymentMethod;
+ return PaymentEndpointDTO(
+ type: endpointTypeToValue(type),
+ data: WalletPaymentDataDTO(walletId: payload.walletId).toJson(),
+ metadata: metadata,
+ );
+ case PaymentType.bankAccount:
+ final payload = this as RussianBankAccountPaymentMethod;
+ return PaymentEndpointDTO(
+ type: endpointTypeToValue(type),
+ data: RussianBankAccountPaymentDataDTO(
+ recipientName: payload.recipientName,
+ inn: payload.inn,
+ kpp: payload.kpp,
+ bankName: payload.bankName,
+ bik: payload.bik,
+ accountNumber: payload.accountNumber,
+ correspondentAccount: payload.correspondentAccount,
+ ).toJson(),
+ metadata: metadata,
+ );
+ case PaymentType.iban:
+ final payload = this as IbanPaymentMethod;
+ return PaymentEndpointDTO(
+ type: endpointTypeToValue(type),
+ data: IbanPaymentDataDTO(
+ iban: payload.iban,
+ accountHolder: payload.accountHolder,
+ bic: payload.bic,
+ bankName: payload.bankName,
+ ).toJson(),
+ metadata: metadata,
+ );
}
}
}
@@ -127,14 +163,40 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
maskedPan: payload.maskedPan,
metadata: metadata,
);
- default:
- throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}');
+ case PaymentType.wallet:
+ final payload = WalletPaymentDataDTO.fromJson(data);
+ return WalletPaymentMethod(
+ walletId: payload.walletId,
+ metadata: metadata,
+ );
+ case PaymentType.bankAccount:
+ final payload = RussianBankAccountPaymentDataDTO.fromJson(data);
+ return RussianBankAccountPaymentMethod(
+ recipientName: payload.recipientName,
+ inn: payload.inn,
+ kpp: payload.kpp,
+ bankName: payload.bankName,
+ bik: payload.bik,
+ accountNumber: payload.accountNumber,
+ correspondentAccount: payload.correspondentAccount,
+ metadata: metadata,
+ );
+ case PaymentType.iban:
+ final payload = IbanPaymentDataDTO.fromJson(data);
+ return IbanPaymentMethod(
+ iban: payload.iban,
+ accountHolder: payload.accountHolder,
+ bic: payload.bic,
+ bankName: payload.bankName,
+ metadata: metadata,
+ );
}
}
}
PaymentType _resolveEndpointType(String type, Map data) {
- if (type == 'card' && (data.containsKey('token') || data.containsKey('masked_pan'))) {
+ if (type == 'card' &&
+ (data.containsKey('token') || data.containsKey('masked_pan'))) {
return PaymentType.cardToken;
}
return endpointTypeFromValue(type);
diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart
index e4a7152a..242db1ff 100644
--- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart
+++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart
@@ -2,14 +2,16 @@ import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/operation.dart';
import 'package:pshared/data/mapper/payment/quote.dart';
+import 'package:pshared/data/mapper/payment/response_endpoint.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/state.dart';
extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment(
paymentRef: paymentRef,
- idempotencyKey: idempotencyKey,
state: state,
+ source: source?.toDomain(),
+ destination: destination?.toDomain(),
orchestrationState: paymentOrchestrationStateFromValue(state),
failureCode: failureCode,
failureReason: failureReason,
@@ -24,8 +26,9 @@ extension PaymentDTOMapper on PaymentDTO {
extension PaymentMapper on Payment {
PaymentDTO toDTO() => PaymentDTO(
paymentRef: paymentRef,
- idempotencyKey: idempotencyKey,
state: state ?? paymentOrchestrationStateToValue(orchestrationState),
+ source: source?.toDTO(),
+ destination: destination?.toDTO(),
failureCode: failureCode,
failureReason: failureReason,
intent: intent?.toDTO(),
diff --git a/frontend/pshared/lib/data/mapper/payment/response_endpoint.dart b/frontend/pshared/lib/data/mapper/payment/response_endpoint.dart
new file mode 100644
index 00000000..28920508
--- /dev/null
+++ b/frontend/pshared/lib/data/mapper/payment/response_endpoint.dart
@@ -0,0 +1,66 @@
+import 'package:pshared/data/dto/payment/endpoint.dart';
+import 'package:pshared/data/dto/payment/response_endpoint.dart';
+import 'package:pshared/data/mapper/payment/payment.dart';
+import 'package:pshared/models/payment/endpoint.dart';
+import 'package:pshared/models/payment/methods/data.dart';
+
+extension PaymentResponseEndpointDTOMapper on PaymentResponseEndpointDTO {
+ PaymentEndpoint toDomain() {
+ final normalizedType = _normalize(type);
+ final normalizedData = _cloneData(data);
+
+ return PaymentEndpoint(
+ method: _tryParseMethod(normalizedType, normalizedData),
+ paymentMethodRef: _normalize(paymentMethodRef),
+ payeeRef: _normalize(payeeRef),
+ type: normalizedType,
+ rawData: normalizedData,
+ );
+ }
+}
+
+extension PaymentEndpointMapper on PaymentEndpoint {
+ PaymentResponseEndpointDTO toDTO() {
+ final methodData = method;
+ if (methodData != null) {
+ final endpoint = methodData.toDTO();
+ return PaymentResponseEndpointDTO(
+ type: endpoint.type,
+ data: endpoint.data,
+ paymentMethodRef: _normalize(paymentMethodRef),
+ payeeRef: _normalize(payeeRef),
+ );
+ }
+
+ return PaymentResponseEndpointDTO(
+ type: _normalize(type),
+ data: _cloneData(rawData),
+ paymentMethodRef: _normalize(paymentMethodRef),
+ payeeRef: _normalize(payeeRef),
+ );
+ }
+}
+
+PaymentMethodData? _tryParseMethod(String? type, Map? data) {
+ if (type == null || data == null) {
+ return null;
+ }
+
+ try {
+ return PaymentEndpointDTO(type: type, data: data).toDomain();
+ } catch (_) {
+ return null;
+ }
+}
+
+String? _normalize(String? value) {
+ final trimmed = value?.trim();
+ if (trimmed == null || trimmed.isEmpty) return null;
+ return trimmed;
+}
+
+Map? _cloneData(Map? data) {
+ if (data == null) return null;
+ if (data.isEmpty) return {};
+ return Map.from(data);
+}
diff --git a/frontend/pshared/lib/models/payment/endpoint.dart b/frontend/pshared/lib/models/payment/endpoint.dart
new file mode 100644
index 00000000..64c6f5e9
--- /dev/null
+++ b/frontend/pshared/lib/models/payment/endpoint.dart
@@ -0,0 +1,17 @@
+import 'package:pshared/models/payment/methods/data.dart';
+
+class PaymentEndpoint {
+ final PaymentMethodData? method;
+ final String? paymentMethodRef;
+ final String? payeeRef;
+ final String? type;
+ final Map? rawData;
+
+ const PaymentEndpoint({
+ required this.method,
+ required this.paymentMethodRef,
+ required this.payeeRef,
+ required this.type,
+ required this.rawData,
+ });
+}
diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart
index fa21e7b5..dad41559 100644
--- a/frontend/pshared/lib/models/payment/payment.dart
+++ b/frontend/pshared/lib/models/payment/payment.dart
@@ -1,3 +1,4 @@
+import 'package:pshared/models/payment/endpoint.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote/quote.dart';
@@ -5,8 +6,9 @@ import 'package:pshared/models/payment/state.dart';
class Payment {
final String? paymentRef;
- final String? idempotencyKey;
final String? state;
+ final PaymentEndpoint? source;
+ final PaymentEndpoint? destination;
final PaymentOrchestrationState orchestrationState;
final String? failureCode;
final String? failureReason;
@@ -18,8 +20,9 @@ class Payment {
const Payment({
required this.paymentRef,
- required this.idempotencyKey,
required this.state,
+ required this.source,
+ required this.destination,
required this.orchestrationState,
required this.failureCode,
required this.failureReason,
diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart
index f78e2209..341be14d 100644
--- a/frontend/pshared/lib/provider/payment/payments.dart
+++ b/frontend/pshared/lib/provider/payment/payments.dart
@@ -254,9 +254,7 @@ class PaymentsProvider with ChangeNotifier {
}
String? _paymentKey(Payment payment) {
- final ref = _normalize(payment.paymentRef);
- if (ref != null) return ref;
- return _normalize(payment.idempotencyKey);
+ return _normalize(payment.paymentRef);
}
List? _normalizeStates(List? states) {
diff --git a/frontend/pshared/lib/provider/payment/updates.dart b/frontend/pshared/lib/provider/payment/updates.dart
index dbaaad38..99a01e4c 100644
--- a/frontend/pshared/lib/provider/payment/updates.dart
+++ b/frontend/pshared/lib/provider/payment/updates.dart
@@ -79,8 +79,6 @@ class PaymentsUpdatesProvider extends ChangeNotifier {
String? _key(Payment payment) {
final ref = payment.paymentRef?.trim();
if (ref != null && ref.isNotEmpty) return ref;
- final idempotency = payment.idempotencyKey?.trim();
- if (idempotency != null && idempotency.isNotEmpty) return idempotency;
return null;
}
diff --git a/frontend/pshared/test/payment/payment_state_model_test.dart b/frontend/pshared/test/payment/payment_state_model_test.dart
index 5797458d..b16cff74 100644
--- a/frontend/pshared/test/payment/payment_state_model_test.dart
+++ b/frontend/pshared/test/payment/payment_state_model_test.dart
@@ -64,8 +64,9 @@ void main() {
test('isPending and isTerminal are derived from typed state', () {
const created = Payment(
paymentRef: 'p-1',
- idempotencyKey: 'idem-1',
state: 'orchestration_state_created',
+ source: null,
+ destination: null,
orchestrationState: PaymentOrchestrationState.created,
failureCode: null,
failureReason: null,
@@ -76,8 +77,9 @@ void main() {
);
const settled = Payment(
paymentRef: 'p-2',
- idempotencyKey: 'idem-2',
state: 'orchestration_state_settled',
+ source: null,
+ destination: null,
orchestrationState: PaymentOrchestrationState.settled,
failureCode: null,
failureReason: null,
@@ -96,8 +98,9 @@ void main() {
test('isFailure handles both explicit code and failed state', () {
const withFailureCode = Payment(
paymentRef: 'p-3',
- idempotencyKey: 'idem-3',
state: 'orchestration_state_executing',
+ source: null,
+ destination: null,
orchestrationState: PaymentOrchestrationState.executing,
failureCode: 'failure_ledger',
failureReason: 'ledger failed',
@@ -108,8 +111,9 @@ void main() {
);
const failedState = Payment(
paymentRef: 'p-4',
- idempotencyKey: 'idem-4',
state: 'orchestration_state_failed',
+ source: null,
+ destination: null,
orchestrationState: PaymentOrchestrationState.failed,
failureCode: null,
failureReason: null,
diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart
index f584b1d5..06eff0e2 100644
--- a/frontend/pshared/test/payment/request_dto_format_test.dart
+++ b/frontend/pshared/test/payment/request_dto_format_test.dart
@@ -5,6 +5,7 @@ import 'package:test/test.dart';
import 'package:pshared/api/requests/payment/initiate.dart';
import 'package:pshared/api/requests/payment/initiate_payments.dart';
import 'package:pshared/api/requests/payment/quote.dart';
+import 'package:pshared/api/responses/payment/payment.dart';
import 'package:pshared/api/responses/payment/quotation.dart';
import 'package:pshared/data/dto/money.dart';
import 'package:pshared/data/dto/payment/currency_pair.dart';
@@ -12,11 +13,13 @@ import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/intent/fx.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/payment.dart';
+import 'package:pshared/data/mapper/payment/payment_response.dart';
import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
+import 'package:pshared/models/payment/methods/wallet.dart';
void main() {
group('Payment request DTO contract', () {
@@ -185,5 +188,38 @@ void main() {
expect(json.containsKey('intentRef'), isFalse);
expect(json.containsKey('intentRefs'), isFalse);
});
+
+ test(
+ 'payment response parses source and destination endpoint snapshots',
+ () {
+ final response = PaymentResponse.fromJson({
+ 'accessToken': {
+ 'token': 'token',
+ 'expiration': '2026-02-25T00:00:00Z',
+ },
+ 'payment': {
+ 'paymentRef': 'pay-1',
+ 'state': 'orchestration_state_created',
+ 'source': {
+ 'type': 'wallet',
+ 'data': {'walletId': 'wallet-1'},
+ },
+ 'destination': {'paymentMethodRef': 'pm-123'},
+ 'operations': [],
+ 'meta': {'quotationRef': 'quote-1'},
+ },
+ });
+
+ final payment = response.payment.toDomain();
+ expect(payment.paymentRef, equals('pay-1'));
+ expect(payment.source, isNotNull);
+ expect(payment.destination, isNotNull);
+ expect(payment.destination?.paymentMethodRef, equals('pm-123'));
+ expect(payment.source?.method, isA());
+
+ final sourceMethod = payment.source?.method as WalletPaymentMethod;
+ expect(sourceMethod.walletId, equals('wallet-1'));
+ },
+ );
});
}
diff --git a/frontend/pweb/lib/controllers/payments/details.dart b/frontend/pweb/lib/controllers/payments/details.dart
index 1c31439a..5f3751a4 100644
--- a/frontend/pweb/lib/controllers/payments/details.dart
+++ b/frontend/pweb/lib/controllers/payments/details.dart
@@ -69,7 +69,6 @@ class PaymentDetailsController extends ChangeNotifier {
if (trimmed.isEmpty) return null;
for (final payment in payments) {
if (payment.paymentRef == trimmed) return payment;
- if (payment.idempotencyKey == trimmed) return payment;
}
return null;
}
diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart
index 567d2353..b57da710 100644
--- a/frontend/pweb/lib/utils/report/payment_mapper.dart
+++ b/frontend/pweb/lib/utils/report/payment_mapper.dart
@@ -20,15 +20,9 @@ OperationItem mapPaymentToOperation(Payment payment) {
: parseMoneyAmount(settlement.amount);
final toCurrency = settlement?.currency ?? currency;
- final payId =
- _firstNonEmpty([payment.paymentRef, payment.idempotencyKey]) ?? '-';
+ final payId = _firstNonEmpty([payment.paymentRef]) ?? '-';
final name =
- _firstNonEmpty([
- payment.lastQuote?.quoteRef,
- payment.paymentRef,
- payment.idempotencyKey,
- ]) ??
- '-';
+ _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-';
final comment =
_firstNonEmpty([
payment.failureReason,
diff --git a/infra/gitea/docker-compose.yml b/infra/gitea/docker-compose.yml
index 43dd8d25..450a6357 100644
--- a/infra/gitea/docker-compose.yml
+++ b/infra/gitea/docker-compose.yml
@@ -76,6 +76,10 @@ services:
gitea:
image: gitea/gitea:latest
networks: [cicd]
+ ports:
+ - target: 22
+ published: 2222
+ mode: host
depends_on:
- gitea-db
- vault-agent-gitea
diff --git a/infra/gitea/vault/agent.hcl b/infra/gitea/vault/agent.hcl
new file mode 100644
index 00000000..1041b88d
--- /dev/null
+++ b/infra/gitea/vault/agent.hcl
@@ -0,0 +1,35 @@
+auto_auth {
+ method "approle" {
+ mount_path = "auth/approle"
+ config = {
+ role_id_file_path = "/vault/secrets/role_id"
+ secret_id_file_path = "/vault/secrets/secret_id"
+ }
+ }
+ sink "file" { config = { path = "/vault/.token" } }
+}
+
+template {
+ source = "/etc/vault/templates/gitea_db_pass.ctmpl"
+ destination = "/vault/secrets/gitea_db_pass"
+}
+
+template {
+ source = "/etc/vault/templates/minio_access_key.ctmpl"
+ destination = "/vault/secrets/minio_access_key"
+}
+
+template {
+ source = "/etc/vault/templates/minio_secret_key.ctmpl"
+ destination = "/vault/secrets/minio_secret_key"
+}
+
+template {
+ source = "/etc/vault/templates/mail_account.ctmpl"
+ destination = "/vault/secrets/mail_account"
+}
+
+template {
+ source = "/etc/vault/templates/mail_secret.ctmpl"
+ destination = "/vault/secrets/mail_secret"
+}
diff --git a/infra/gitea/vault/templates/gitea_db_pass.ctmpl b/infra/gitea/vault/templates/gitea_db_pass.ctmpl
new file mode 100644
index 00000000..2891b362
--- /dev/null
+++ b/infra/gitea/vault/templates/gitea_db_pass.ctmpl
@@ -0,0 +1 @@
+{{ with secret "kv/data/cicd/gitea" }}{{ .Data.data.gitea_db_pass }}{{- end -}}
diff --git a/infra/gitea/vault/templates/mail_account.ctmpl b/infra/gitea/vault/templates/mail_account.ctmpl
new file mode 100644
index 00000000..d951280d
--- /dev/null
+++ b/infra/gitea/vault/templates/mail_account.ctmpl
@@ -0,0 +1 @@
+{{ with secret "kv/data/cicd/gitea" }}{{ .Data.data.mail_account }}{{- end -}}
diff --git a/infra/gitea/vault/templates/mail_secret.ctmpl b/infra/gitea/vault/templates/mail_secret.ctmpl
new file mode 100644
index 00000000..a0cafcae
--- /dev/null
+++ b/infra/gitea/vault/templates/mail_secret.ctmpl
@@ -0,0 +1 @@
+{{ with secret "kv/data/cicd/gitea" }}{{ .Data.data.mail_secret }}{{- end -}}
diff --git a/infra/gitea/vault/templates/minio_access_key.ctmpl b/infra/gitea/vault/templates/minio_access_key.ctmpl
new file mode 100644
index 00000000..333129e2
--- /dev/null
+++ b/infra/gitea/vault/templates/minio_access_key.ctmpl
@@ -0,0 +1 @@
+{{ with secret "kv/data/s3/gitea" }}{{ .Data.data.access_key_id }}{{- end -}}
diff --git a/infra/gitea/vault/templates/minio_secret_key.ctmpl b/infra/gitea/vault/templates/minio_secret_key.ctmpl
new file mode 100644
index 00000000..13b9a10c
--- /dev/null
+++ b/infra/gitea/vault/templates/minio_secret_key.ctmpl
@@ -0,0 +1 @@
+{{ with secret "kv/data/s3/gitea" }}{{ .Data.data.secret_access_key }}{{- end -}}
\ No newline at end of file
diff --git a/infra/monitoring/docker-compose.yml b/infra/monitoring/docker-compose.yml
new file mode 100644
index 00000000..7f83c83a
--- /dev/null
+++ b/infra/monitoring/docker-compose.yml
@@ -0,0 +1,145 @@
+secrets:
+ monitoring_vault_role_id:
+ external: true
+ monitoring_vault_secret_id:
+ external: true
+
+networks:
+ cicd:
+ external: true
+
+volumes:
+ loki_data:
+ grafana_data:
+ prometheus_data:
+ alertmanager_data:
+ alertmanager_config:
+ driver: local
+ driver_opts:
+ type: tmpfs
+ device: tmpfs
+ o: size=8m,uid=0,gid=0,mode=0755
+ vault_secrets:
+ driver: local
+ driver_opts:
+ type: tmpfs
+ device: tmpfs
+ o: size=32m,uid=472,gid=472,mode=0750
+
+services:
+ vault-agent-monitoring:
+ image: hashicorp/vault:latest
+ networks: [cicd]
+ cap_add: ["IPC_LOCK"]
+ environment:
+ VAULT_ADDR: "http://vault:8200"
+ command: >
+ sh -lc 'vault agent -config=/etc/vault/agent.hcl'
+ secrets:
+ - source: monitoring_vault_role_id
+ target: /etc/vault/role_id
+ - source: monitoring_vault_secret_id
+ target: /etc/vault/secret_id
+ volumes:
+ - ./vault-agent/agent.hcl:/etc/vault/agent.hcl:ro
+ - ./vault-agent/templates:/etc/vault/templates:ro
+ - vault_secrets:/vault/secrets:rw
+ - alertmanager_config:/vault/alertmanager:rw
+ healthcheck:
+ test: ["CMD-SHELL", "test -s /vault/secrets/grafana.env"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ deploy:
+ restart_policy:
+ condition: any
+
+ prometheus:
+ image: prom/prometheus:latest
+ networks: [cicd]
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.path=/prometheus
+ - --storage.tsdb.retention.time=30d
+ - --web.enable-lifecycle
+ volumes:
+ - ./prometheus/config.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/ready"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.prometheus.rule=Host(`prometheus.sendico.io`)"
+ - "traefik.http.routers.prometheus.entrypoints=websecure"
+ - "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
+ - "traefik.http.services.prometheus.loadbalancer.server.port=9090"
+ deploy:
+ restart_policy:
+ condition: any
+
+ alertmanager:
+ image: prom/alertmanager:latest
+ networks: [cicd]
+ command: >
+ sh -c 'while [ ! -s /vault/alertmanager/alertmanager.yml ]; do echo "⏳ waiting for alertmanager.yml"; sleep 2; done;
+ exec /bin/alertmanager --config.file=/vault/alertmanager/alertmanager.yml --storage.path=/alertmanager'
+ volumes:
+ - alertmanager_data:/alertmanager
+ - alertmanager_config:/vault/alertmanager:ro
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:9093/-/ready"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.alertmanager.rule=Host(`alertmanager.sendico.io`)"
+ - "traefik.http.routers.alertmanager.entrypoints=websecure"
+ - "traefik.http.routers.alertmanager.tls.certresolver=letsencrypt"
+ - "traefik.http.services.alertmanager.loadbalancer.server.port=9093"
+ deploy:
+ restart_policy:
+ condition: any
+
+ loki:
+ image: grafana/loki:latest
+ networks: [cicd]
+ command: ["-config.file=/etc/loki/config.yml"]
+ volumes:
+ - ./loki/config.yml:/etc/loki/config.yml:ro
+ - loki_data:/loki
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ deploy:
+ restart_policy:
+ condition: any
+
+ grafana:
+ image: grafana/grafana:latest
+ networks: [cicd]
+ command: >
+ sh -c 'while [ ! -s /vault/secrets/grafana.env ]; do echo "⏳ waiting for grafana.env"; sleep 2; done;
+ set -a; . /vault/secrets/grafana.env; set +a; exec /run.sh'
+ volumes:
+ - grafana_data:/var/lib/grafana
+ - vault_secrets:/vault/secrets:ro
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.grafana.rule=Host(`grafana.sendico.io`)"
+ - "traefik.http.routers.grafana.entrypoints=websecure"
+ - "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
+ - "traefik.http.services.grafana.loadbalancer.server.port=3000"
+ deploy:
+ restart_policy:
+ condition: any
\ No newline at end of file
diff --git a/infra/monitoring/loki/config.yml b/infra/monitoring/loki/config.yml
new file mode 100644
index 00000000..b41a7560
--- /dev/null
+++ b/infra/monitoring/loki/config.yml
@@ -0,0 +1,37 @@
+# loki/config.yml — single-binary, filesystem-backed TSDB storage, 7-day retention
+
+server:
+ http_listen_port: 3100
+ instance_addr: 127.0.0.1
+
+common:
+ path_prefix: /loki
+ storage:
+ filesystem:
+ chunks_directory: /loki/chunks
+ rules_directory: /loki/rules
+ replication_factor: 1
+ ring:
+ kvstore:
+ store: inmemory
+
+schema_config:
+ configs:
+ - from: "2025-01-01"
+ store: tsdb
+ object_store: filesystem
+ schema: v13
+ index:
+ prefix: index_
+ period: 24h
+
+limits_config:
+ retention_period: 168h
+ max_query_lookback: 168h
+ allow_structured_metadata: true
+
+compactor:
+ working_directory: /loki/compactor
+ compaction_interval: 5m
+ retention_enabled: true
+ delete_request_store: filesystem
\ No newline at end of file
diff --git a/infra/monitoring/prometheus/config.yml b/infra/monitoring/prometheus/config.yml
new file mode 100644
index 00000000..5d3e9583
--- /dev/null
+++ b/infra/monitoring/prometheus/config.yml
@@ -0,0 +1,22 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+alerting:
+ alertmanagers:
+ - static_configs:
+ - targets: ['alertmanager:9093']
+
+scrape_configs:
+ - job_name: prometheus
+ static_configs:
+ - targets: ['localhost:9090']
+
+ - job_name: loki
+ static_configs:
+ - targets: ['loki:3100']
+
+ # Uncomment if Grafana metrics are enabled:
+ # - job_name: grafana
+ # static_configs:
+ # - targets: ['grafana:3000']
\ No newline at end of file
diff --git a/infra/monitoring/vault-agent/agent.hcl b/infra/monitoring/vault-agent/agent.hcl
new file mode 100644
index 00000000..3b730237
--- /dev/null
+++ b/infra/monitoring/vault-agent/agent.hcl
@@ -0,0 +1,31 @@
+exit_after_auth = false
+pid_file = "/tmp/vault-agent.pid"
+
+vault {
+ address = "http://vault:8200"
+}
+
+auto_auth {
+ method "approle" {
+ mount_path = "auth/approle"
+ config = {
+ role_id_file_path = "/etc/vault/role_id"
+ secret_id_file_path = "/etc/vault/secret_id"
+ remove_secret_id_file_after_reading = false
+ }
+ }
+ sink "file" { config = { path = "/vault/secrets/.vault-token" } }
+}
+
+template {
+ source = "/etc/vault/templates/grafana.env.ctmpl"
+ destination = "/vault/secrets/grafana.env"
+ perms = "0644"
+ command = "chown 472:472 /vault/secrets/grafana.env"
+}
+
+template {
+ source = "/etc/vault/templates/alertmanager.yml.ctmpl"
+ destination = "/vault/alertmanager/alertmanager.yml"
+ perms = "0644"
+}
diff --git a/infra/monitoring/vault-agent/templates/alertmanager.yml.ctmpl b/infra/monitoring/vault-agent/templates/alertmanager.yml.ctmpl
new file mode 100644
index 00000000..30c1cc3f
--- /dev/null
+++ b/infra/monitoring/vault-agent/templates/alertmanager.yml.ctmpl
@@ -0,0 +1,18 @@
+route:
+ receiver: 'telegram'
+ group_by: ['alertname', 'instance']
+ group_wait: 30s
+ group_interval: 5m
+ repeat_interval: 3h
+
+receivers:
+ - name: 'telegram'
+ telegram_configs:
+ - bot_token: '{{ with secret "kv/data/monitoring/telegram" }}{{ .Data.data.token }}{{ end }}'
+ chat_id: {{ with secret "kv/data/monitoring/telegram" }}{{ .Data.data.admin_chat_id }}{{ end }} # put your numeric chat id here, or also render from Vault if you want
+ message: |
+ 🚨 *{{ "{{ .Status | toUpper }}" }}* — {{ "{{ .CommonLabels.alertname }}" }}
+ *Instance:* {{ "{{ .CommonLabels.instance }}" }}
+ *Summary:* {{ "{{ .CommonAnnotations.summary }}" }}
+ *Description:* {{ "{{ .CommonAnnotations.description }}" }}
+ parse_mode: 'Markdown'
diff --git a/infra/monitoring/vault-agent/templates/grafana.env.ctmpl b/infra/monitoring/vault-agent/templates/grafana.env.ctmpl
new file mode 100644
index 00000000..84f1dc1a
--- /dev/null
+++ b/infra/monitoring/vault-agent/templates/grafana.env.ctmpl
@@ -0,0 +1,4 @@
+GF_SECURITY_ADMIN_USER="{{ with secret "kv/data/monitoring/grafana" }}{{ .Data.data.username }}{{ end }}"
+GF_SECURITY_ADMIN_PASSWORD="{{ with secret "kv/data/monitoring/grafana" }}{{ .Data.data.password }}{{ end }}"
+GF_AUTH_ANONYMOUS_ENABLED="false"
+GF_USERS_ALLOW_SIGN_UP="false"
\ No newline at end of file
diff --git a/infra/registry/config.yml b/infra/registry/config.yml
new file mode 100644
index 00000000..2a12a1b2
--- /dev/null
+++ b/infra/registry/config.yml
@@ -0,0 +1,30 @@
+version: 0.1
+log:
+ level: info
+
+storage:
+ s3:
+ accesskey: registry
+ secretkey: "88m]6uu:5^B>"
+ bucket: registry
+ region: us-east-1
+ regionendpoint: https://s3.sendico.io
+ secure: true
+ v4auth: true
+ forcepathstyle: true # required for MinIO path-style
+ delete:
+ enabled: true
+http:
+ addr: :5000
+
+auth:
+ htpasswd:
+ realm: "Registry Realm"
+ path: /vault/secrets/htpasswd
+
+health:
+ storagedriver:
+ enabled: true
+
+monitoring:
+ enabled: false
\ No newline at end of file
diff --git a/infra/registry/docker-compose.yml b/infra/registry/docker-compose.yml
new file mode 100644
index 00000000..f1417e5c
--- /dev/null
+++ b/infra/registry/docker-compose.yml
@@ -0,0 +1,79 @@
+configs:
+ registry_wait_sh:
+ file: ./registry-wait.sh
+ registry_config_yml:
+ file: ./config.yml
+
+services:
+ vault-agent-registry:
+ image: hashicorp/vault:latest
+ command: >
+ sh -lc 'vault agent -config=/etc/vault/agent.hcl'
+ cap_add: ["IPC_LOCK"]
+ environment:
+ VAULT_ADDR: "http://vault:8200"
+ secrets:
+ - source: registry_vault_role_id
+ target: /vault/secrets/role_id
+ - source: registry_vault_secret_id
+ target: /vault/secrets/secret_id
+ volumes:
+ - ./vault:/etc/vault:ro
+ - vault-secrets:/vault/secrets:rw
+ networks: [cicd]
+ healthcheck:
+ test: ["CMD-SHELL", "test -s /vault/secrets/htpasswd -a -s /vault/secrets/env"]
+ interval: 10s
+ timeout: 3s
+ retries: 10
+ deploy:
+ placement:
+ constraints: [node.role == manager]
+
+ registry:
+ image: registry:latest
+ entrypoint: ["/usr/local/bin/registry-wait"]
+ command: ["serve", "/etc/registry/config.yml"]
+ configs:
+ - source: registry_wait_sh
+ target: /usr/local/bin/registry-wait
+ mode: 0755
+ - source: registry_config_yml
+ target: /etc/registry/config.yml
+ volumes:
+ - registry_data:/var/lib/registry
+ - vault-secrets:/vault/secrets:ro
+ environment:
+ OTEL_TRACES_EXPORTER: "none"
+ networks: [cicd]
+ deploy:
+ placement:
+ constraints: [node.role == manager]
+ labels:
+ - "traefik.enable=true"
+ - "traefik.docker.network=cicd"
+
+ - "traefik.http.services.registry.loadbalancer.server.port=5000"
+ - "traefik.http.routers.registry.rule=Host(`registry.sendico.io`)"
+ - "traefik.http.routers.registry.entrypoints=websecure"
+ - "traefik.http.routers.registry.tls=true"
+ - "traefik.http.routers.registry.tls.certresolver=letsencrypt"
+
+networks:
+ cicd:
+ external: true
+
+volumes:
+ vault-secrets:
+ driver: local
+ driver_opts:
+ type: tmpfs
+ device: tmpfs
+ o: size=16m,uid=1000,gid=1000,mode=0750
+ registry_data:
+
+secrets:
+ registry_vault_role_id:
+ external: true
+ registry_vault_secret_id:
+ external: true
\ No newline at end of file
diff --git a/infra/registry/vault/agent.hcl b/infra/registry/vault/agent.hcl
new file mode 100644
index 00000000..751ed6e4
--- /dev/null
+++ b/infra/registry/vault/agent.hcl
@@ -0,0 +1,22 @@
+auto_auth {
+ method "approle" {
+ mount_path = "auth/approle"
+ config = {
+ role_id_file_path = "/vault/secrets/role_id"
+ secret_id_file_path = "/vault/secrets/secret_id"
+ }
+ }
+ sink "file" { config = { path = "/vault/.token" } }
+}
+
+template {
+ source = "/etc/vault/templates/htpasswd.ctmpl"
+ destination = "/vault/secrets/htpasswd"
+ perms = "0440"
+}
+
+template {
+ source = "/etc/vault/templates/s3.env.ctmpl"
+ destination = "/vault/secrets/env"
+ perms = "0440"
+}
diff --git a/infra/registry/vault/templates/htpasswd.ctmpl b/infra/registry/vault/templates/htpasswd.ctmpl
new file mode 100644
index 00000000..f889308c
--- /dev/null
+++ b/infra/registry/vault/templates/htpasswd.ctmpl
@@ -0,0 +1,3 @@
+{{- with secret "kv/data/registry" -}}
+{{ .Data.data.htpasswd }}
+{{- end -}}
\ No newline at end of file
diff --git a/infra/registry/vault/templates/s3.env.ctmpl b/infra/registry/vault/templates/s3.env.ctmpl
new file mode 100644
index 00000000..e782a611
--- /dev/null
+++ b/infra/registry/vault/templates/s3.env.ctmpl
@@ -0,0 +1,8 @@
+{{- with secret "kv/data/s3/registry" -}}
+REGISTRY_STORAGE_S3_ACCESSKEY={{ .Data.data.access_key_id }}
+REGISTRY_STORAGE_S3_SECRETKEY="{{ .Data.data.secret_access_key }}"
+{{ end }}
+
+{{- with secret "kv/data/registry" -}}
+REGISTRY_HTTP_SECRET="{{ .Data.data.http_secret }}"
+{{ end }}
\ No newline at end of file
diff --git a/infra/s3/docker-compose.yml b/infra/s3/docker-compose.yml
new file mode 100644
index 00000000..795bbe57
--- /dev/null
+++ b/infra/s3/docker-compose.yml
@@ -0,0 +1,198 @@
+configs:
+ minio_wait_sh:
+ file: ./minio-wait.sh
+
+services:
+ vault-agent-s3:
+ image: hashicorp/vault:latest
+ command: >
+ sh -lc 'vault agent -config=/etc/vault/agent.hcl'
+ cap_add: ["IPC_LOCK"]
+ environment:
+ VAULT_ADDR: "http://vault:8200"
+ secrets:
+ - source: s3_vault_role_id
+ target: /vault/secrets/role_id
+ - source: s3_vault_secret_id
+ target: /vault/secrets/secret_id
+ volumes:
+ - ./vault:/etc/vault:ro
+ - vault-secrets:/vault/secrets:rw
+ networks: [cicd]
+ healthcheck:
+ test: ["CMD-SHELL", "test -s /vault/secrets/MINIO_ROOT_USER -a -s /vault/secrets/MINIO_ROOT_PASSWORD"]
+ interval: 10s
+ timeout: 3s
+ retries: 10
+ deploy:
+ placement:
+ constraints: [node.role == manager]
+
+
+ minio1:
+ image: quay.io/minio/minio:latest
+ hostname: minio1
+ entrypoint: ["/usr/local/bin/minio-wait"]
+ command:
+ - server
+ - --console-address
+ - :9001
+ - http://minio1:9000/data
+ - http://minio2:9000/data
+ - http://minio3:9000/data
+ - http://minio4:9000/data
+ configs:
+ - source: minio_wait_sh
+ target: /usr/local/bin/minio-wait
+ mode: 0755
+ environment:
+ MINIO_ROOT_USER_FILE: /vault/secrets/MINIO_ROOT_USER
+ MINIO_ROOT_PASSWORD_FILE: /vault/secrets/MINIO_ROOT_PASSWORD
+ MINIO_SERVER_URL: https://s3.sendico.io
+ MINIO_BROWSER_REDIRECT_URL: https://minio.sendico.io
+ volumes:
+ - minio1_data:/data
+ - vault-secrets:/vault/secrets:ro
+ networks: [cicd]
+ deploy:
+ placement:
+ constraints: [node.role == manager]
+ labels:
+ - "traefik.enable=true"
+ - "traefik.docker.network=cicd"
+
+ # services (чётко укажем порты)
+ - "traefik.http.services.s3-minio-api.loadbalancer.server.port=9000"
+ - "traefik.http.services.s3-minio-console.loadbalancer.server.port=9001"
+
+ # router для API
+ - "traefik.http.routers.s3-minio-api.rule=Host(`s3.sendico.io`)"
+ - "traefik.http.routers.s3-minio-api.entrypoints=websecure"
+ - "traefik.http.routers.s3-minio-api.tls=true"
+ - "traefik.http.routers.s3-minio-api.tls.certresolver=letsencrypt"
+ - "traefik.http.routers.s3-minio-api.service=s3-minio-api"
+
+ # router для Console
+ - "traefik.http.routers.s3-minio-console.rule=Host(`minio.sendico.io`)"
+ - "traefik.http.routers.s3-minio-console.entrypoints=websecure"
+ - "traefik.http.routers.s3-minio-console.tls=true"
+ - "traefik.http.routers.s3-minio-console.tls.certresolver=letsencrypt"
+ - "traefik.http.routers.s3-minio-console.service=s3-minio-console"
+
+
+ minio2:
+ image: quay.io/minio/minio:latest
+ hostname: minio2
+ entrypoint: ["/usr/local/bin/minio-wait"]
+ command:
+ - server
+ - --console-address
+ - :9001
+ - http://minio1:9000/data
+ - http://minio2:9000/data
+ - http://minio3:9000/data
+ - http://minio4:9000/data
+ configs:
+ - source: minio_wait_sh
+ target: /usr/local/bin/minio-wait
+ mode: 0755
+ environment:
+ MINIO_ROOT_USER_FILE: /vault/secrets/MINIO_ROOT_USER
+ MINIO_ROOT_PASSWORD_FILE: /vault/secrets/MINIO_ROOT_PASSWORD
+ MINIO_SERVER_URL: https://s3.sendico.io
+ MINIO_BROWSER_REDIRECT_URL: https://minio.sendico.io
+ volumes:
+ - minio2_data:/data
+ - vault-secrets:/vault/secrets:ro
+ networks: [cicd]
+ deploy:
+ placement:
+ constraints: [node.role == manager]
+ labels:
+ - "traefik.enable=false"
+
+ minio3:
+ image: quay.io/minio/minio:latest
+ hostname: minio3
+ entrypoint: ["/usr/local/bin/minio-wait"]
+ command:
+ - server
+ - --console-address
+ - :9001
+ - http://minio1:9000/data
+ - http://minio2:9000/data
+ - http://minio3:9000/data
+ - http://minio4:9000/data
+ configs:
+ - source: minio_wait_sh
+ target: /usr/local/bin/minio-wait
+ mode: 0755
+ environment:
+ MINIO_ROOT_USER_FILE: /vault/secrets/MINIO_ROOT_USER
+ MINIO_ROOT_PASSWORD_FILE: /vault/secrets/MINIO_ROOT_PASSWORD
+ MINIO_SERVER_URL: https://s3.sendico.io
+ MINIO_BROWSER_REDIRECT_URL: https://minio.sendico.io
+ volumes:
+ - minio3_data:/data
+ - vault-secrets:/vault/secrets:ro
+ networks:
+ - cicd
+ deploy:
+ placement:
+ constraints: [node.role == manager]
+ labels:
+ - "traefik.enable=false"
+
+ minio4:
+ image: quay.io/minio/minio:latest
+ hostname: minio4
+ entrypoint: ["/usr/local/bin/minio-wait"]
+ command:
+ - server
+ - --console-address
+ - :9001
+ - http://minio1:9000/data
+ - http://minio2:9000/data
+ - http://minio3:9000/data
+ - http://minio4:9000/data
+ configs:
+ - source: minio_wait_sh
+ target: /usr/local/bin/minio-wait
+ mode: 0755
+ environment:
+ MINIO_ROOT_USER_FILE: /vault/secrets/MINIO_ROOT_USER
+ MINIO_ROOT_PASSWORD_FILE: /vault/secrets/MINIO_ROOT_PASSWORD
+ MINIO_SERVER_URL: https://s3.sendico.io
+ MINIO_BROWSER_REDIRECT_URL: https://minio.sendico.io
+ volumes:
+ - minio4_data:/data
+ - vault-secrets:/vault/secrets:ro
+ networks:
+ - cicd
+ deploy:
+ placement:
+ constraints: [node.role == manager]
+ labels:
+ - "traefik.enable=false"
+
+networks:
+ cicd:
+ external: true
+
+volumes:
+ vault-secrets:
+ driver: local
+ driver_opts:
+ type: tmpfs
+ device: tmpfs
+ o: size=16m,uid=1000,gid=1000,mode=0750
+ minio1_data:
+ minio2_data:
+ minio3_data:
+ minio4_data:
+
+secrets:
+ s3_vault_role_id:
+ external: true
+ s3_vault_secret_id:
+ external: true
\ No newline at end of file
diff --git a/infra/s3/minio-entrypoint.sh b/infra/s3/minio-entrypoint.sh
new file mode 100644
index 00000000..56d283bd
--- /dev/null
+++ b/infra/s3/minio-entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -e
+
+echo "Waiting for Vault Agent to render /vault/secrets/minio.env..."
+while [ ! -f /vault/secrets/minio.env ]; do
+ sleep 0.5
+done
+
+echo "Vault secrets ready, starting MinIO..."
+exec minio "$@"
\ No newline at end of file
diff --git a/infra/s3/minio-wait.sh b/infra/s3/minio-wait.sh
new file mode 100644
index 00000000..5a417506
--- /dev/null
+++ b/infra/s3/minio-wait.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -eu
+until [ -s /vault/secrets/MINIO_ROOT_USER ] && [ -s /vault/secrets/MINIO_ROOT_PASSWORD ]; do
+ echo "waiting for MINIO creds"; sleep 1
+done
+exec /usr/bin/minio "$@"
\ No newline at end of file
diff --git a/infra/s3/vault/agent.hcl b/infra/s3/vault/agent.hcl
new file mode 100644
index 00000000..0b783c37
--- /dev/null
+++ b/infra/s3/vault/agent.hcl
@@ -0,0 +1,29 @@
+auto_auth {
+ method "approle" {
+ mount_path = "auth/approle"
+ config = {
+ role_id_file_path = "/vault/secrets/role_id"
+ secret_id_file_path = "/vault/secrets/secret_id"
+ }
+ }
+
+ sink "file" {
+ config = { path = "/vault/token" }
+ }
+}
+
+template {
+ source = "/etc/vault/templates/user.ctmpl"
+ destination = "/vault/secrets/MINIO_ROOT_USER"
+ perms = "0440"
+}
+
+template {
+ source = "/etc/vault/templates/password.ctmpl"
+ destination = "/vault/secrets/MINIO_ROOT_PASSWORD"
+ perms = "0440"
+}
+
+vault {
+ address = "http://vault_vault:8200"
+}
\ No newline at end of file
diff --git a/infra/s3/vault/templates/password.ctmpl b/infra/s3/vault/templates/password.ctmpl
new file mode 100644
index 00000000..73f44a3c
--- /dev/null
+++ b/infra/s3/vault/templates/password.ctmpl
@@ -0,0 +1 @@
+{{ with secret "kv/data/s3/minio" }}{{ .Data.data.password }}{{ end }}
diff --git a/infra/s3/vault/templates/user.ctmpl b/infra/s3/vault/templates/user.ctmpl
new file mode 100644
index 00000000..74f6acf0
--- /dev/null
+++ b/infra/s3/vault/templates/user.ctmpl
@@ -0,0 +1 @@
+{{ with secret "kv/data/s3/minio" }}{{ .Data.data.user }}{{ end }}
diff --git a/infra/traefik/config.yml b/infra/traefik/config.yml
new file mode 100644
index 00000000..dea30e03
--- /dev/null
+++ b/infra/traefik/config.yml
@@ -0,0 +1,47 @@
+log:
+ level: INFO
+ format: json
+
+accessLog: {}
+
+entryPoints:
+ web:
+ address: ":80"
+ http:
+ redirections:
+ entryPoint:
+ to: websecure
+ scheme: https
+ websecure:
+ address: ":443"
+ http3: {}
+ http:
+ encodedCharacters:
+ allowEncodedSlash: true
+
+providers:
+ docker:
+ endpoint: "unix:///var/run/docker.sock"
+ exposedByDefault: false
+ network: cicd
+ watch: true
+ constraints:
+ swarm:
+ endpoint: "unix:///var/run/docker.sock"
+ exposedByDefault: false
+ network: cicd
+ watch: true
+ file:
+ filename: /etc/traefik/dynamic.yml
+ watch: true
+
+certificatesResolvers:
+ letsencrypt:
+ acme:
+ email: si@sendico.io
+ storage: /sendico.json
+ httpChallenge:
+ entryPoint: web
+
+api:
+ dashboard: true
\ No newline at end of file
diff --git a/infra/traefik/docker-compose.yml b/infra/traefik/docker-compose.yml
new file mode 100644
index 00000000..dd1fcc4f
--- /dev/null
+++ b/infra/traefik/docker-compose.yml
@@ -0,0 +1,43 @@
+services:
+ traefik:
+ image: traefik:latest
+ command:
+ - "--configFile=/etc/traefik/traefik.yml"
+ ports:
+ - "80:80"
+ - "443:443"
+ networks:
+ - cicd
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - ./config.yml:/etc/traefik/traefik.yml:ro
+ - ./dynamic.yml:/etc/traefik/dynamic.yml:ro
+ - ./sendico.json:/sendico.json
+ - traefik_letsencrypt:/letsencrypt
+ labels:
+ - "traefik.enable=true"
+ - "traefik.docker.network=cicd"
+ - "traefik.http.routers.traefik.rule=Host(`traefik.sendico.io`)"
+ - "traefik.http.routers.traefik.entrypoints=websecure"
+ - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
+ - "traefik.http.routers.traefik.service=api@internal"
+ - "traefik.http.routers.traefik.middlewares=secure-headers@file,dashboard-auth@file"
+
+ mail-cert-proxy:
+ image: traefik/whoami
+ networks:
+ - cicd
+ deploy:
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.mail-cert.rule=Host(`mail.sendico.io`)"
+ - "traefik.http.routers.mail-cert.entrypoints=websecure"
+ - "traefik.http.routers.mail-cert.tls.certresolver=letsencrypt"
+ - "traefik.http.services.mail-cert.loadbalancer.server.port=80"
+
+networks:
+ cicd:
+ external: true
+
+volumes:
+ traefik_letsencrypt:
\ No newline at end of file
diff --git a/infra/traefik/dynamic.yml b/infra/traefik/dynamic.yml
new file mode 100644
index 00000000..e443792e
--- /dev/null
+++ b/infra/traefik/dynamic.yml
@@ -0,0 +1,17 @@
+http:
+ middlewares:
+ secure-headers:
+ headers:
+ stsSeconds: 63072000
+ stsIncludeSubdomains: true
+ stsPreload: true
+ frameDeny: true
+ contentTypeNosniff: true
+ browserXssFilter: true
+ referrerPolicy: "strict-origin-when-cross-origin"
+
+ dashboard-auth:
+ basicAuth:
+ users:
+ - "admin:$2y$05$m22ds4RLIsR9UY3DdZHB8umL4FHXmLvc8ZUE/RrFvNKrDP0GMIyeS"
+
diff --git a/infra/vault/config/vault1.hcl b/infra/vault/config/vault1.hcl
new file mode 100644
index 00000000..3005bb29
--- /dev/null
+++ b/infra/vault/config/vault1.hcl
@@ -0,0 +1,16 @@
+disable_mlock = true
+ui = true
+
+listener "tcp" {
+ address = "0.0.0.0:8200"
+ cluster_address = "0.0.0.0:8201"
+ tls_disable = 1
+}
+
+storage "raft" {
+ path = "/vault/file"
+ node_id = "vault-1"
+}
+
+api_addr = "http://vault_vault:8200"
+cluster_addr = "http://vault_vault:8201"
\ No newline at end of file
diff --git a/infra/vault/config/vault2.hcl b/infra/vault/config/vault2.hcl
new file mode 100644
index 00000000..e9970337
--- /dev/null
+++ b/infra/vault/config/vault2.hcl
@@ -0,0 +1,17 @@
+disable_mlock = true
+ui = true
+
+listener "tcp" {
+ address = "0.0.0.0:8200"
+ cluster_address = "0.0.0.0:8201"
+ tls_disable = 1
+}
+
+storage "raft" {
+ path = "/vault/file"
+ node_id = "vault-2"
+ retry_join { leader_api_addr = "http://vault_vault:8200" }
+}
+
+api_addr = "http://vault_vault2:8200"
+cluster_addr = "http://vault_vault2:8201"
\ No newline at end of file
diff --git a/infra/vault/config/vault3.hcl b/infra/vault/config/vault3.hcl
new file mode 100644
index 00000000..27e86e93
--- /dev/null
+++ b/infra/vault/config/vault3.hcl
@@ -0,0 +1,17 @@
+disable_mlock = true
+ui = true
+
+listener "tcp" {
+ address = "0.0.0.0:8200"
+ cluster_address = "0.0.0.0:8201"
+ tls_disable = 1
+}
+
+storage "raft" {
+ path = "/vault/file"
+ node_id = "vault-3"
+ retry_join { leader_api_addr = "http://vault_vault:8200" }
+}
+
+api_addr = "http://vault_vault3:8200"
+cluster_addr = "http://vault_vault3:8201"
diff --git a/infra/woodpecker/docker-compose.yml b/infra/woodpecker/docker-compose.yml
index 4fda3a2a..7ff1402b 100644
--- a/infra/woodpecker/docker-compose.yml
+++ b/infra/woodpecker/docker-compose.yml
@@ -1,4 +1,5 @@
networks:
+ # Overlay network used by your Swarm services (Traefik, Vault, etc.)
cicd:
external: true
@@ -21,6 +22,7 @@ configs:
file: ./vault/templates/pg_dsn.ctmpl
volumes:
+ # tmpfs volume for rendered secrets (read by server/agent)
vault_secrets:
driver: local
driver_opts:
@@ -29,12 +31,14 @@ volumes:
o: size=32m,uid=0,gid=0,mode=0750
services:
+ # Vault Agent sidecar to render secrets from Vault into files
vault-agent-woodpecker:
image: hashicorp/vault:latest
networks: [cicd]
cap_add: ["IPC_LOCK"]
environment:
- VAULT_ADDR: "http://vault:8200" # or your HTTPS URL
+ # Use the actual Swarm service DNS name of Vault inside the overlay
+ VAULT_ADDR: "http://vault_vault:8200"
secrets:
- source: woodpecker_vault_role_id
target: /vault/secrets/role_id
@@ -53,15 +57,17 @@ services:
target: /etc/vault/templates/gitea_client_secret.ctmpl
- source: tpl_pg_dsn
target: /etc/vault/templates/pg_dsn.ctmpl
- command: [ "sh", "-lc", "vault agent -config=/etc/vault/agent.hcl" ]
+ command: ["sh", "-lc", "vault agent -config=/etc/vault/agent.hcl"]
healthcheck:
test: ["CMD-SHELL", "test -s /vault/secrets/agent_secret -a -s /vault/secrets/gitea_client_id -a -s /vault/secrets/gitea_client_secret -a -s /vault/secrets/pg_dsn" ]
interval: 10s
timeout: 3s
retries: 30
+ # Woodpecker Server (HTTP UI on :8000, gRPC on :9000)
woodpecker-server:
- image: woodpeckerci/woodpecker-server:latest
+ user: "0:0" # ensures read access to tmpfs secrets (mode 0750)
+ image: woodpeckerci/woodpecker-server:v3-alpine
networks: [cicd]
depends_on: [vault-agent-woodpecker]
volumes:
@@ -70,29 +76,37 @@ services:
WOODPECKER_HOST: "https://ci.sendico.io"
WOODPECKER_OPEN: "false"
- # Gitea (now your URL)
+ # Gitea OAuth
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: "https://git.sendico.io"
WOODPECKER_GITEA_CLIENT_FILE: "/vault/secrets/gitea_client_id"
WOODPECKER_GITEA_SECRET_FILE: "/vault/secrets/gitea_client_secret"
- # Agent shared secret (lowercase file, env stays uppercase)
+ # Shared secret between server and agent
WOODPECKER_AGENT_SECRET_FILE: "/vault/secrets/agent_secret"
- # Postgres (from Vault Agent rendered file)
+ # Postgres DSN from Vault Agent rendered file
WOODPECKER_DATABASE_DRIVER: "postgres"
WOODPECKER_DATABASE_DATASOURCE_FILE: "/vault/secrets/pg_dsn"
-
- WOODPECKER_BACKEND_DOCKER_NETWORK: "cicd"
deploy:
labels:
traefik.enable: "true"
traefik.docker.network: "cicd"
+
traefik.http.routers.woodpecker-server.rule: "Host(`ci.sendico.io`)"
traefik.http.routers.woodpecker-server.entrypoints: "websecure"
traefik.http.routers.woodpecker-server.tls: "true"
traefik.http.routers.woodpecker-server.tls.certresolver: "letsencrypt"
- traefik.http.services.woodpecker-server.loadbalancer.server.port: "3000"
+ traefik.http.routers.woodpecker-server.service: "woodpecker-server"
+ traefik.http.services.woodpecker-server.loadbalancer.server.port: "8000"
+
+ traefik.http.routers.woodpecker-grpc.rule: "Host(`woodpecker-grpc.sendico.io`)"
+ traefik.http.routers.woodpecker-grpc.entrypoints: "websecure"
+ traefik.http.routers.woodpecker-grpc.tls: "true"
+ traefik.http.routers.woodpecker-grpc.tls.certresolver: "letsencrypt"
+ traefik.http.routers.woodpecker-grpc.service: "woodpecker-grpc"
+ traefik.http.services.woodpecker-grpc.loadbalancer.server.port: "9000"
+ traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme: "h2c"
healthcheck:
test: ["CMD", "/bin/woodpecker-server", "ping"]
interval: 10s
@@ -100,18 +114,27 @@ services:
retries: 10
start_period: 20s
+ # Woodpecker Agent (creates step containers)
woodpecker-agent:
- image: woodpeckerci/woodpecker-agent:latest
+ user: "0:0" # ensures read access to tmpfs secrets (mode 0750)
+ image: woodpeckerci/woodpecker-agent:v3-alpine
networks: [cicd]
depends_on: [woodpecker-server, vault-agent-woodpecker]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- vault_secrets:/vault/secrets:ro
environment:
- WOODPECKER_SERVER: "woodpecker-server:9000" # gRPC in overlay
+ # gRPC connection to server (overlay DNS)
+ WOODPECKER_SERVER: "woodpecker-server:9000"
+ # Shared secret file
WOODPECKER_AGENT_SECRET_FILE: "/vault/secrets/agent_secret"
+ # Docker backend for steps
WOODPECKER_BACKEND: "docker"
- WOODPECKER_BACKEND_DOCKER_NETWORK: "cicd"
+ # Agent display name
+ WOODPECKER_HOSTNAME: "infra-builder"
+ # Attach all step containers to a stable bridge network (created outside the stack)
+ WOODPECKER_BACKEND_DOCKER_NETWORK: "wp-ci"
+ # Concurrency limit
WOODPECKER_MAX_WORKFLOWS: "2"
healthcheck:
test: ["CMD", "/bin/woodpecker-agent", "ping"]
diff --git a/infra/woodpecker/vault/agent.hcl b/infra/woodpecker/vault/agent.hcl
new file mode 100644
index 00000000..149b7284
--- /dev/null
+++ b/infra/woodpecker/vault/agent.hcl
@@ -0,0 +1,38 @@
+exit_after_auth = false
+pid_file = "/vault/secrets/vault-agent.pid"
+
+auto_auth {
+ method "approle" {
+ mount_path = "auth/approle"
+ config = {
+ role_id_file_path = "/vault/secrets/role_id"
+ secret_id_file_path = "/vault/secrets/secret_id"
+ }
+ }
+ sink "file" { config = { path = "/vault/secrets/.vault-token" } }
+}
+
+# Render secrets to lowercase files
+template {
+ source = "/etc/vault/templates/agent_secret.ctmpl"
+ destination = "/vault/secrets/agent_secret"
+ perms = "0440"
+}
+
+template {
+ source = "/etc/vault/templates/gitea_client_id.ctmpl"
+ destination = "/vault/secrets/gitea_client_id"
+ perms = "0440"
+}
+
+template {
+ source = "/etc/vault/templates/gitea_client_secret.ctmpl"
+ destination = "/vault/secrets/gitea_client_secret"
+ perms = "0440"
+}
+
+template {
+ source = "/etc/vault/templates/pg_dsn.ctmpl"
+ destination = "/vault/secrets/pg_dsn"
+ perms = "0644"
+}
diff --git a/infra/woodpecker/vault/templates/agent_secret.ctmpl b/infra/woodpecker/vault/templates/agent_secret.ctmpl
new file mode 100644
index 00000000..d5227c91
--- /dev/null
+++ b/infra/woodpecker/vault/templates/agent_secret.ctmpl
@@ -0,0 +1,3 @@
+{{ with secret "kv/data/cicd/woodpecker/agent" -}}
+{{ .Data.data.secret }}
+{{- end }}
\ No newline at end of file
diff --git a/infra/woodpecker/vault/templates/gitea_client_id.ctmpl b/infra/woodpecker/vault/templates/gitea_client_id.ctmpl
new file mode 100644
index 00000000..52d803c2
--- /dev/null
+++ b/infra/woodpecker/vault/templates/gitea_client_id.ctmpl
@@ -0,0 +1,3 @@
+{{ with secret "kv/data/cicd/woodpecker" -}}
+{{ .Data.data.gitea_client_id }}
+{{- end }}
\ No newline at end of file
diff --git a/infra/woodpecker/vault/templates/gitea_client_secret.ctmpl b/infra/woodpecker/vault/templates/gitea_client_secret.ctmpl
new file mode 100644
index 00000000..4e6b2c32
--- /dev/null
+++ b/infra/woodpecker/vault/templates/gitea_client_secret.ctmpl
@@ -0,0 +1,3 @@
+{{ with secret "kv/data/cicd/woodpecker" -}}
+{{ .Data.data.gitea_client_secret }}
+{{- end }}
\ No newline at end of file
diff --git a/infra/woodpecker/vault/templates/pg_dsn.ctmpl b/infra/woodpecker/vault/templates/pg_dsn.ctmpl
new file mode 100644
index 00000000..5c7cdaed
--- /dev/null
+++ b/infra/woodpecker/vault/templates/pg_dsn.ctmpl
@@ -0,0 +1 @@
+{{- with secret "kv/data/cicd/woodpecker" -}}{{ .Data.data.pg_dsn }}{{- end -}}
diff --git a/interface/models/payment/payment.yaml b/interface/models/payment/payment.yaml
index cd5604b1..c8f0b3bc 100644
--- a/interface/models/payment/payment.yaml
+++ b/interface/models/payment/payment.yaml
@@ -116,6 +116,51 @@ components:
description: Unique identifier of the wallet.
type: string
+ BankAccountEndpoint:
+ description: Domestic bank-account payout endpoint.
+ type: object
+ additionalProperties: false
+ required:
+ - recipientName
+ - inn
+ - kpp
+ - bankName
+ - bik
+ - accountNumber
+ - correspondentAccount
+ properties:
+ recipientName:
+ type: string
+ inn:
+ type: string
+ kpp:
+ type: string
+ bankName:
+ type: string
+ bik:
+ type: string
+ accountNumber:
+ type: string
+ correspondentAccount:
+ type: string
+
+ IBANEndpoint:
+ description: International bank-account payout endpoint.
+ type: object
+ additionalProperties: false
+ required:
+ - iban
+ - accountHolder
+ properties:
+ iban:
+ type: string
+ accountHolder:
+ type: string
+ bic:
+ type: string
+ bankName:
+ type: string
+
Endpoint:
description: Polymorphic payment endpoint definition.
type: object
@@ -142,6 +187,41 @@ components:
additionalProperties:
type: string
+ PaymentResponseEndpoint:
+ description: Endpoint snapshot attached to a persisted payment.
+ type: object
+ additionalProperties: false
+ properties:
+ paymentMethodRef:
+ description: Reference to a stored payment method when endpoint is referenced by ID.
+ type: string
+ payeeRef:
+ description: Reference to a payee profile when endpoint is resolved by payee.
+ type: string
+ type:
+ description: Endpoint type for inline endpoint snapshots.
+ type: string
+ enum:
+ - ledger
+ - managedWallet
+ - cryptoAddress
+ - card
+ - cardToken
+ - wallet
+ - bankAccount
+ - iban
+ data:
+ description: Inline endpoint payload snapshot; shape depends on `type`.
+ oneOf:
+ - $ref: ./payment.yaml#/components/schemas/LedgerEndpoint
+ - $ref: ./payment.yaml#/components/schemas/ManagedWalletEndpoint
+ - $ref: ./payment.yaml#/components/schemas/ExternalChainEndpoint
+ - $ref: ./payment.yaml#/components/schemas/CardEndpoint
+ - $ref: ./payment.yaml#/components/schemas/CardTokenEndpoint
+ - $ref: ./payment.yaml#/components/schemas/WalletEndpoint
+ - $ref: ./payment.yaml#/components/schemas/BankAccountEndpoint
+ - $ref: ./payment.yaml#/components/schemas/IBANEndpoint
+
Customer:
description: Customer identity and address attributes for compliance and routing.
type: object
@@ -262,6 +342,9 @@ components:
type: object
additionalProperties:
type: string
+ comment:
+ description: Optional free-form comment attached to the payment intent.
+ type: string
customer:
description: Optional customer information attached to the payment intent.
$ref: ./payment.yaml#/components/schemas/Customer
@@ -432,12 +515,18 @@ components:
paymentRef:
description: Unique payment reference identifier.
type: string
- idempotencyKey:
- description: Idempotency key used to safely deduplicate create requests.
- type: string
state:
description: Current lifecycle state of the payment.
$ref: ../../external/payment_state.yaml#/components/schemas/PaymentState
+ comment:
+ description: Optional comment copied from the original payment intent.
+ type: string
+ source:
+ description: Source endpoint snapshot captured from intent.
+ $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint
+ destination:
+ description: Destination endpoint snapshot captured from intent.
+ $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint
failureCode:
description: Failure code set when the payment cannot be completed.
type: string