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