From 396a0c0c884ad8dee8889ec5b05b2df7f9975f57 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 4 Dec 2025 21:16:15 +0100 Subject: [PATCH 1/2] monetix gateway --- .woodpecker/mntx_gateway.yml | 74 ++++ api/gateway/mntx/.gitignore | 1 + api/gateway/mntx/README.md | 46 +++ api/gateway/mntx/config.yml | 40 ++ api/gateway/mntx/entrypoint.sh | 4 + api/gateway/mntx/go.mod | 54 +++ api/gateway/mntx/go.sum | 227 ++++++++++++ .../mntx/internal/appversion/version.go | 27 ++ .../internal/server/internal/serverimp.go | 345 ++++++++++++++++++ api/gateway/mntx/internal/server/server.go | 12 + .../mntx/internal/service/gateway/callback.go | 134 +++++++ .../service/gateway/card_payout_handlers.go | 255 +++++++++++++ .../service/gateway/card_payout_store.go | 55 +++ .../service/gateway/card_payout_validation.go | 83 +++++ .../service/gateway/card_processor.go | 311 ++++++++++++++++ .../service/gateway/card_token_validation.go | 63 ++++ .../gateway/card_tokenize_validation.go | 108 ++++++ .../mntx/internal/service/gateway/metrics.go | 174 +++++++++ .../mntx/internal/service/gateway/options.go | 44 +++ .../internal/service/gateway/payout_get.go | 30 ++ .../internal/service/gateway/payout_store.go | 46 +++ .../internal/service/gateway/payout_submit.go | 131 +++++++ .../service/gateway/payout_validation.go | 106 ++++++ .../mntx/internal/service/gateway/service.go | 119 ++++++ .../mntx/internal/service/monetix/client.go | 66 ++++ .../mntx/internal/service/monetix/config.go | 78 ++++ .../mntx/internal/service/monetix/mask.go | 21 ++ .../mntx/internal/service/monetix/metrics.go | 71 ++++ .../mntx/internal/service/monetix/payloads.go | 96 +++++ .../mntx/internal/service/monetix/sender.go | 289 +++++++++++++++ api/gateway/mntx/main.go | 17 + api/pkg/model/card.go | 4 + .../{crypto_address.go => cryptoaddress.go} | 0 api/pkg/model/ctoken.go | 32 ++ api/pkg/model/payment.go | 3 + api/pkg/mservice/services.go | 3 +- api/proto/gateway/mntx/v1/mntx.proto | 238 ++++++++++++ ci/prod/.env.runtime | 10 + ci/prod/compose/mntx_gateway.dockerfile | 42 +++ ci/prod/compose/mntx_gateway.yml | 40 ++ ci/prod/scripts/deploy/mntx_gateway.sh | 149 ++++++++ ci/scripts/mntx/build-image.sh | 85 +++++ ci/scripts/mntx/deploy.sh | 64 ++++ ci/scripts/proto/generate.sh | 6 + frontend/pweb/caddy/Caddyfile | 31 ++ frontend/pweb/pubspec.yaml | 2 +- version | 2 +- 47 files changed, 3835 insertions(+), 3 deletions(-) create mode 100644 .woodpecker/mntx_gateway.yml create mode 100644 api/gateway/mntx/.gitignore create mode 100644 api/gateway/mntx/README.md create mode 100644 api/gateway/mntx/config.yml create mode 100755 api/gateway/mntx/entrypoint.sh create mode 100644 api/gateway/mntx/go.mod create mode 100644 api/gateway/mntx/go.sum create mode 100644 api/gateway/mntx/internal/appversion/version.go create mode 100644 api/gateway/mntx/internal/server/internal/serverimp.go create mode 100644 api/gateway/mntx/internal/server/server.go create mode 100644 api/gateway/mntx/internal/service/gateway/callback.go create mode 100644 api/gateway/mntx/internal/service/gateway/card_payout_handlers.go create mode 100644 api/gateway/mntx/internal/service/gateway/card_payout_store.go create mode 100644 api/gateway/mntx/internal/service/gateway/card_payout_validation.go create mode 100644 api/gateway/mntx/internal/service/gateway/card_processor.go create mode 100644 api/gateway/mntx/internal/service/gateway/card_token_validation.go create mode 100644 api/gateway/mntx/internal/service/gateway/card_tokenize_validation.go create mode 100644 api/gateway/mntx/internal/service/gateway/metrics.go create mode 100644 api/gateway/mntx/internal/service/gateway/options.go create mode 100644 api/gateway/mntx/internal/service/gateway/payout_get.go create mode 100644 api/gateway/mntx/internal/service/gateway/payout_store.go create mode 100644 api/gateway/mntx/internal/service/gateway/payout_submit.go create mode 100644 api/gateway/mntx/internal/service/gateway/payout_validation.go create mode 100644 api/gateway/mntx/internal/service/gateway/service.go create mode 100644 api/gateway/mntx/internal/service/monetix/client.go create mode 100644 api/gateway/mntx/internal/service/monetix/config.go create mode 100644 api/gateway/mntx/internal/service/monetix/mask.go create mode 100644 api/gateway/mntx/internal/service/monetix/metrics.go create mode 100644 api/gateway/mntx/internal/service/monetix/payloads.go create mode 100644 api/gateway/mntx/internal/service/monetix/sender.go create mode 100644 api/gateway/mntx/main.go rename api/pkg/model/{crypto_address.go => cryptoaddress.go} (100%) create mode 100644 api/pkg/model/ctoken.go create mode 100644 api/proto/gateway/mntx/v1/mntx.proto create mode 100644 ci/prod/compose/mntx_gateway.dockerfile create mode 100644 ci/prod/compose/mntx_gateway.yml create mode 100644 ci/prod/scripts/deploy/mntx_gateway.sh create mode 100644 ci/scripts/mntx/build-image.sh create mode 100644 ci/scripts/mntx/deploy.sh diff --git a/.woodpecker/mntx_gateway.yml b/.woodpecker/mntx_gateway.yml new file mode 100644 index 0000000..3150be3 --- /dev/null +++ b/.woodpecker/mntx_gateway.yml @@ -0,0 +1,74 @@ +matrix: + include: + - MNTX_GATEWAY_IMAGE_PATH: gateway/mntx + MNTX_GATEWAY_DOCKERFILE: ci/prod/compose/mntx_gateway.dockerfile + MNTX_GATEWAY_ENV: prod + MNTX_GATEWAY_MONETIX_SECRET_PATH: sendico/gateway/monetix + MNTX_GATEWAY_NATS_SECRET_PATH: sendico/nats + +when: + - event: push + branch: main + +steps: + - name: version + image: alpine:latest + commands: + - set -euo pipefail 2>/dev/null || set -eu + - apk add --no-cache git + - GIT_REV="$(git rev-parse --short HEAD)" + - BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + - APP_V="$(cat version)" + - BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + - BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}" + - printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \ + "$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version + + - name: proto + image: golang:alpine + depends_on: [ version ] + commands: + - set -eu + - apk add --no-cache bash git build-base protoc protobuf-dev + - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + - export PATH="$(go env GOPATH)/bin:$PATH" + - bash ci/scripts/proto/generate.sh + + - name: secrets + image: alpine:latest + depends_on: [ version ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash coreutils openssh-keygen curl sed python3 + - mkdir -p secrets + - ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600 + - base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY + - chmod 600 secrets/SSH_KEY + - ssh-keygen -y -f secrets/SSH_KEY >/dev/null + - ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER + - ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD + + - name: build-image + image: gcr.io/kaniko-project/executor:debug + depends_on: [ proto, secrets ] + commands: + - sh ci/scripts/mntx/build-image.sh + + - name: deploy + image: alpine:latest + depends_on: [ secrets, build-image ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash openssh-client rsync coreutils curl sed python3 + - mkdir -p /root/.ssh + - install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa + - sh ci/scripts/mntx/deploy.sh diff --git a/api/gateway/mntx/.gitignore b/api/gateway/mntx/.gitignore new file mode 100644 index 0000000..4cb3c7e --- /dev/null +++ b/api/gateway/mntx/.gitignore @@ -0,0 +1 @@ +/mntx-gateway diff --git a/api/gateway/mntx/README.md b/api/gateway/mntx/README.md new file mode 100644 index 0000000..ba8e2cd --- /dev/null +++ b/api/gateway/mntx/README.md @@ -0,0 +1,46 @@ +# Monetix Gateway – Card Payouts + +This service now supports Monetix “payout by card”. + +## Runtime entry points +- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`. +- Callback HTTP server (default): `:8080/monetix/callback` for Monetix payout status notifications. +- Metrics: Prometheus on `:9404/metrics`. + +## Required config/env +`api/gateway/mntx/config.yml` shows defaults. Key values (usually injected via env): +- `MONETIX_BASE_URL` – e.g. `https://gate.monetix.com` +- `MONETIX_PROJECT_ID` – integer project ID +- `MONETIX_SECRET_KEY` – signature secret +- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds` +- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8080), `http.callback.path`, optional `allowed_cidrs` + +## Outbound request (CreateCardPayout) +Payload is built per Monetix spec: +``` +{ + "general": { "project_id": , "payment_id": "", "signature": "" }, + "customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? }, + "payment": { amount: , currency: "" }, + "card": { pan, year?, month?, card_holder } +} +``` +Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_SECRET_KEY`. + +## Callback handling +- Endpoint only accepts POST with Monetix JSON body. Signature is verified with the same HMAC-SHA256 algorithm; invalid signatures return 403. +- Maps Monetix statuses: + - `payment.status=success` AND `operation.status=success` AND `operation.code` empty/`0` → `PAYOUT_STATUS_PROCESSED` + - `processing` → `PAYOUT_STATUS_PENDING` + - otherwise → `PAYOUT_STATUS_FAILED` +- Emits `CardPayoutStatusChangedEvent` over messaging (event type: `mntx_gateway`, action: `updated`). + +## Metrics +- `sendico_mntx_gateway_card_payout_requests_total{outcome}` +- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}` +- `sendico_mntx_gateway_card_payout_callbacks_total{status}` +- Existing RPC/payout counters remain for compatibility. + +## Notes / PCI +- PAN is only logged in masked form; do not persist raw PAN. +- Callback allows CIDR allow-listing; leave empty to accept all while testing. diff --git a/api/gateway/mntx/config.yml b/api/gateway/mntx/config.yml new file mode 100644 index 0000000..8935dcd --- /dev/null +++ b/api/gateway/mntx/config.yml @@ -0,0 +1,40 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50075" + enable_reflection: true + enable_health: true + +metrics: + address: ":9404" + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Monetix Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + +monetix: + base_url_env: MONETIX_BASE_URL + project_id_env: MONETIX_PROJECT_ID + secret_key_env: MONETIX_SECRET_KEY + allowed_currencies: ["USD", "EUR"] + require_customer_address: false + request_timeout_seconds: 15 + status_success: "success" + status_processing: "processing" + +http: + callback: + address: ":8080" + path: "/monetix/callback" + allowed_cidrs: [] + max_body_bytes: 1048576 diff --git a/api/gateway/mntx/entrypoint.sh b/api/gateway/mntx/entrypoint.sh new file mode 100755 index 0000000..9b10aea --- /dev/null +++ b/api/gateway/mntx/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +exec /app/mntx-gateway "$@" diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod new file mode 100644 index 0000000..2bf1384 --- /dev/null +++ b/api/gateway/mntx/go.mod @@ -0,0 +1,54 @@ +module github.com/tech/sendico/gateway/mntx + +go 1.25.3 + +replace github.com/tech/sendico/pkg => ../../pkg + +require ( + github.com/google/uuid v1.6.0 + github.com/prometheus/client_golang v1.23.2 + github.com/shopspring/decimal v1.4.0 + github.com/tech/sendico/pkg v0.1.0 + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.10 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/casbin/casbin/v2 v2.134.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.47.0 // indirect + github.com/nats-io/nkeys v0.4.12 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver v1.17.6 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect +) diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum new file mode 100644 index 0000000..79911b6 --- /dev/null +++ b/api/gateway/mntx/go.sum @@ -0,0 +1,227 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM= +github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E= +github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= +github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/gateway/mntx/internal/appversion/version.go b/api/gateway/mntx/internal/appversion/version.go new file mode 100644 index 0000000..573e137 --- /dev/null +++ b/api/gateway/mntx/internal/appversion/version.go @@ -0,0 +1,27 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + info := version.Info{ + Program: "Sendico Monetix Gateway Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&info) +} diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go new file mode 100644 index 0000000..c3fd661 --- /dev/null +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -0,0 +1,345 @@ +package serverimp + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + app *grpcapp.App[struct{}] + http *http.Server +} + +type config struct { + *grpcapp.Config `yaml:",inline"` + Monetix monetixConfig `yaml:"monetix"` + HTTP httpConfig `yaml:"http"` +} + +type monetixConfig struct { + BaseURL string `yaml:"base_url"` + BaseURLEnv string `yaml:"base_url_env"` + ProjectID int64 `yaml:"project_id"` + ProjectIDEnv string `yaml:"project_id_env"` + SecretKey string `yaml:"secret_key"` + SecretKeyEnv string `yaml:"secret_key_env"` + AllowedCurrencies []string `yaml:"allowed_currencies"` + RequireCustomerAddress bool `yaml:"require_customer_address"` + RequestTimeoutSeconds int `yaml:"request_timeout_seconds"` + StatusSuccess string `yaml:"status_success"` + StatusProcessing string `yaml:"status_processing"` +} + +type httpConfig struct { + Callback callbackConfig `yaml:"callback"` +} + +type callbackConfig struct { + Address string `yaml:"address"` + Path string `yaml:"path"` + AllowedCIDRs []string `yaml:"allowed_cidrs"` + MaxBodyBytes int64 `yaml:"max_body_bytes"` +} + +// Create initialises the Monetix gateway server implementation. +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Shutdown() { + if i.app == nil { + return + } + + timeout := 15 * time.Second + if i.config != nil && i.config.Runtime != nil { + timeout = i.config.Runtime.ShutdownTimeout() + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if i.http != nil { + _ = i.http.Shutdown(ctx) + i.http = nil + } + + i.app.Shutdown(ctx) +} + +func (i *Imp) Start() error { + cfg, err := i.loadConfig() + if err != nil { + return err + } + i.config = cfg + + monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix) + if err != nil { + return err + } + + callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback) + if err != nil { + return err + } + + serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) { + svc := mntxservice.NewService(logger, + mntxservice.WithProducer(producer), + mntxservice.WithMonetixConfig(monetixCfg), + mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}), + ) + + if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil { + return nil, err + } + + return svc, nil + } + + app, err := grpcapp.NewApp(i.logger, "mntx_gateway", cfg.Config, i.debug, nil, serviceFactory) + if err != nil { + return err + } + i.app = app + + return i.app.Start() +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + + cfg := &config{ + Config: &grpcapp.Config{}, + } + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("failed to parse configuration", zap.Error(err)) + return nil, err + } + + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + + if cfg.GRPC == nil { + cfg.GRPC = &routers.GRPCConfig{ + Network: "tcp", + Address: ":50075", + EnableReflection: true, + EnableHealth: true, + } + } + + if cfg.Metrics == nil { + cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9404"} + } + + return cfg, nil +} + +func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (mntxservice.MonetixConfig, error) { + baseURL := strings.TrimSpace(cfg.BaseURL) + if env := strings.TrimSpace(cfg.BaseURLEnv); env != "" { + if val := strings.TrimSpace(os.Getenv(env)); val != "" { + baseURL = val + } + } + + projectID := cfg.ProjectID + if projectID == 0 && strings.TrimSpace(cfg.ProjectIDEnv) != "" { + raw := strings.TrimSpace(os.Getenv(cfg.ProjectIDEnv)) + if raw != "" { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + projectID = id + } else { + return mntxservice.MonetixConfig{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "monetix.project_id") + } + } + } + + secret := strings.TrimSpace(cfg.SecretKey) + if env := strings.TrimSpace(cfg.SecretKeyEnv); env != "" { + if val := strings.TrimSpace(os.Getenv(env)); val != "" { + secret = val + } + } + + timeout := time.Duration(cfg.RequestTimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 15 * time.Second + } + + statusSuccess := strings.TrimSpace(cfg.StatusSuccess) + statusProcessing := strings.TrimSpace(cfg.StatusProcessing) + + return mntxservice.MonetixConfig{ + BaseURL: baseURL, + ProjectID: projectID, + SecretKey: secret, + AllowedCurrencies: cfg.AllowedCurrencies, + RequireCustomerAddress: cfg.RequireCustomerAddress, + RequestTimeout: timeout, + StatusSuccess: statusSuccess, + StatusProcessing: statusProcessing, + }, nil +} + +type callbackRuntimeConfig struct { + Address string + Path string + AllowedCIDRs []*net.IPNet + MaxBodyBytes int64 +} + +func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, error) { + addr := strings.TrimSpace(cfg.Address) + if addr == "" { + addr = ":8080" + } + path := strings.TrimSpace(cfg.Path) + if path == "" { + path = "/monetix/callback" + } + maxBody := cfg.MaxBodyBytes + if maxBody <= 0 { + maxBody = 1 << 20 // 1MB + } + + var cidrs []*net.IPNet + for _, raw := range cfg.AllowedCIDRs { + clean := strings.TrimSpace(raw) + if clean == "" { + continue + } + _, block, err := net.ParseCIDR(clean) + if err != nil { + i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err)) + continue + } + cidrs = append(cidrs, block) + } + + return callbackRuntimeConfig{ + Address: addr, + Path: path, + AllowedCIDRs: cidrs, + MaxBodyBytes: maxBody, + }, nil +} + +func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRuntimeConfig) error { + if svc == nil { + return errors.New("nil service provided for callback server") + } + if strings.TrimSpace(cfg.Address) == "" { + i.logger.Info("Monetix callback server disabled: address is empty") + return nil + } + + router := chi.NewRouter() + router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) { + if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes)) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + status, err := svc.ProcessMonetixCallback(r.Context(), body) + if err != nil { + http.Error(w, err.Error(), status) + return + } + w.WriteHeader(status) + }) + + server := &http.Server{ + Addr: cfg.Address, + Handler: router, + } + + ln, err := net.Listen("tcp", cfg.Address) + if err != nil { + return err + } + + i.http = server + + go func() { + if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + i.logger.Error("Monetix callback server stopped with error", zap.Error(err)) + } + }() + + i.logger.Info("Monetix callback server listening", zap.String("address", cfg.Address), zap.String("path", cfg.Path)) + return nil +} + +func clientAllowed(r *http.Request, cidrs []*net.IPNet) bool { + if len(cidrs) == 0 { + return true + } + + host := clientIPFromRequest(r) + if host == nil { + return false + } + for _, block := range cidrs { + if block.Contains(host) { + return true + } + } + return false +} + +func clientIPFromRequest(r *http.Request) net.IP { + if r == nil { + return nil + } + if xfwd := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xfwd != "" { + parts := strings.Split(xfwd, ",") + if len(parts) > 0 { + if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil { + return ip + } + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return nil + } + return net.ParseIP(host) +} diff --git a/api/gateway/mntx/internal/server/server.go b/api/gateway/mntx/internal/server/server.go new file mode 100644 index 0000000..13aa4d4 --- /dev/null +++ b/api/gateway/mntx/internal/server/server.go @@ -0,0 +1,12 @@ +package server + +import ( + serverimp "github.com/tech/sendico/gateway/mntx/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +// Create constructs the Monetix gateway server implementation. +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/gateway/mntx/internal/service/gateway/callback.go b/api/gateway/mntx/internal/service/gateway/callback.go new file mode 100644 index 0000000..c09da3e --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/callback.go @@ -0,0 +1,134 @@ +package gateway + +import ( + "context" + "crypto/hmac" + "net/http" + "strings" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type callbackPayment struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Date string `json:"date"` + Method string `json:"method"` + Description string `json:"description"` + Sum struct { + Amount int64 `json:"amount"` + Currency string `json:"currency"` + } `json:"sum"` +} + +type callbackOperation struct { + ID int64 `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Date string `json:"date"` + CreatedDate string `json:"created_date"` + RequestID string `json:"request_id"` + SumInitial struct { + Amount int64 `json:"amount"` + Currency string `json:"currency"` + } `json:"sum_initial"` + SumConverted struct { + Amount int64 `json:"amount"` + Currency string `json:"currency"` + } `json:"sum_converted"` + Provider struct { + ID int64 `json:"id"` + PaymentID string `json:"payment_id"` + Date string `json:"date"` + AuthCode string `json:"auth_code"` + } `json:"provider"` + Code string `json:"code"` + Message string `json:"message"` +} + +type monetixCallback struct { + ProjectID int64 `json:"project_id"` + Payment callbackPayment `json:"payment"` + Account struct { + Number string `json:"number"` + } `json:"account"` + Customer struct { + ID string `json:"id"` + } `json:"customer"` + Operation callbackOperation `json:"operation"` + Signature string `json:"signature"` +} + +// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state. +func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) { + if s.card == nil { + return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") + } + return s.card.ProcessCallback(ctx, payload) +} + +func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCallback) (*mntxv1.CardPayoutState, string) { + status := strings.ToLower(strings.TrimSpace(cb.Payment.Status)) + opStatus := strings.ToLower(strings.TrimSpace(cb.Operation.Status)) + code := strings.TrimSpace(cb.Operation.Code) + + outcome := monetix.OutcomeDecline + internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + + if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") { + internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED + outcome = monetix.OutcomeSuccess + } else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() { + internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + outcome = monetix.OutcomeProcessing + } + + now := timestamppb.New(clock.Now()) + state := &mntxv1.CardPayoutState{ + PayoutId: cb.Payment.ID, + ProjectId: cb.ProjectID, + CustomerId: cb.Customer.ID, + AmountMinor: cb.Payment.Sum.Amount, + Currency: strings.ToUpper(strings.TrimSpace(cb.Payment.Sum.Currency)), + Status: internalStatus, + ProviderCode: cb.Operation.Code, + ProviderMessage: cb.Operation.Message, + ProviderPaymentId: fallbackProviderPaymentID(cb), + UpdatedAt: now, + CreatedAt: now, + } + + return state, outcome +} + +func fallbackProviderPaymentID(cb monetixCallback) string { + if cb.Operation.Provider.PaymentID != "" { + return cb.Operation.Provider.PaymentID + } + if cb.Operation.RequestID != "" { + return cb.Operation.RequestID + } + return cb.Payment.ID +} + +func verifyCallbackSignature(cb monetixCallback, secret string) error { + expected := cb.Signature + cb.Signature = "" + calculated, err := monetix.SignPayload(cb, secret) + if err != nil { + return err + } + if subtleConstantTimeCompare(expected, calculated) { + return nil + } + return merrors.DataConflict("signature mismatch") +} + +func subtleConstantTimeCompare(a, b string) bool { + return hmac.Equal([]byte(strings.TrimSpace(a)), []byte(strings.TrimSpace(b))) +} diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go new file mode 100644 index 0000000..55aebdf --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_payout_handlers.go @@ -0,0 +1,255 @@ +package gateway + +import ( + "context" + "strings" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/proto" +) + +func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + return executeUnary(ctx, s, "CreateCardPayout", s.handleCreateCardPayout, req) +} + +func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] { + if s.card == nil { + return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + resp, err := s.card.Submit(ctx, req) + if err != nil { + return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err) + } + return gsresponse.Success(resp) +} + +func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + return executeUnary(ctx, s, "CreateCardTokenPayout", s.handleCreateCardTokenPayout, req) +} + +func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] { + if s.card == nil { + return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + resp, err := s.card.SubmitToken(ctx, req) + if err != nil { + return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err) + } + return gsresponse.Success(resp) +} + +func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) { + return executeUnary(ctx, s, "CreateCardToken", s.handleCreateCardToken, req) +} + +func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] { + if s.card == nil { + return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + resp, err := s.card.Tokenize(ctx, req) + if err != nil { + return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err) + } + return gsresponse.Success(resp) +} + +func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { + return executeUnary(ctx, s, "GetCardPayoutStatus", s.handleGetCardPayoutStatus, req) +} + +func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] { + if s.card == nil { + return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + state, err := s.card.Status(context.Background(), req.GetPayoutId()) + if err != nil { + return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err) + } + return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state}) +} + +func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayoutRequest { + if req == nil { + return nil + } + clean := proto.Clone(req) + r, ok := clean.(*mntxv1.CardPayoutRequest) + if !ok { + return req + } + r.PayoutId = strings.TrimSpace(r.GetPayoutId()) + r.CustomerId = strings.TrimSpace(r.GetCustomerId()) + r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) + r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) + r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName()) + r.CustomerIp = strings.TrimSpace(r.GetCustomerIp()) + r.CustomerZip = strings.TrimSpace(r.GetCustomerZip()) + r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry()) + r.CustomerState = strings.TrimSpace(r.GetCustomerState()) + r.CustomerCity = strings.TrimSpace(r.GetCustomerCity()) + r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress()) + r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency())) + r.CardPan = strings.TrimSpace(r.GetCardPan()) + r.CardHolder = strings.TrimSpace(r.GetCardHolder()) + return r +} + +func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.CardTokenPayoutRequest { + if req == nil { + return nil + } + clean := proto.Clone(req) + r, ok := clean.(*mntxv1.CardTokenPayoutRequest) + if !ok { + return req + } + r.PayoutId = strings.TrimSpace(r.GetPayoutId()) + r.CustomerId = strings.TrimSpace(r.GetCustomerId()) + r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) + r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) + r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName()) + r.CustomerIp = strings.TrimSpace(r.GetCustomerIp()) + r.CustomerZip = strings.TrimSpace(r.GetCustomerZip()) + r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry()) + r.CustomerState = strings.TrimSpace(r.GetCustomerState()) + r.CustomerCity = strings.TrimSpace(r.GetCustomerCity()) + r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress()) + r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency())) + r.CardToken = strings.TrimSpace(r.GetCardToken()) + r.CardHolder = strings.TrimSpace(r.GetCardHolder()) + r.MaskedPan = strings.TrimSpace(r.GetMaskedPan()) + return r +} + +func sanitizeCardTokenizeRequest(req *mntxv1.CardTokenizeRequest) *mntxv1.CardTokenizeRequest { + if req == nil { + return nil + } + clean := proto.Clone(req) + r, ok := clean.(*mntxv1.CardTokenizeRequest) + if !ok { + return req + } + r.RequestId = strings.TrimSpace(r.GetRequestId()) + r.CustomerId = strings.TrimSpace(r.GetCustomerId()) + r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) + r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) + r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName()) + r.CustomerIp = strings.TrimSpace(r.GetCustomerIp()) + r.CustomerZip = strings.TrimSpace(r.GetCustomerZip()) + r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry()) + r.CustomerState = strings.TrimSpace(r.GetCustomerState()) + r.CustomerCity = strings.TrimSpace(r.GetCustomerCity()) + r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress()) + r.CardPan = strings.TrimSpace(r.GetCardPan()) + r.CardHolder = strings.TrimSpace(r.GetCardHolder()) + r.CardCvv = strings.TrimSpace(r.GetCardCvv()) + if card := r.GetCard(); card != nil { + card.Pan = strings.TrimSpace(card.GetPan()) + card.CardHolder = strings.TrimSpace(card.GetCardHolder()) + card.Cvv = strings.TrimSpace(card.GetCvv()) + r.Card = card + } + return r +} + +func buildCardPayoutRequest(projectID int64, req *mntxv1.CardPayoutRequest) monetix.CardPayoutRequest { + card := monetix.Card{ + PAN: req.GetCardPan(), + Year: int(req.GetCardExpYear()), + Month: int(req.GetCardExpMonth()), + CardHolder: req.GetCardHolder(), + } + + return monetix.CardPayoutRequest{ + General: monetix.General{ + ProjectID: projectID, + PaymentID: req.GetPayoutId(), + }, + Customer: monetix.Customer{ + ID: req.GetCustomerId(), + FirstName: req.GetCustomerFirstName(), + Middle: req.GetCustomerMiddleName(), + LastName: req.GetCustomerLastName(), + IP: req.GetCustomerIp(), + Zip: req.GetCustomerZip(), + Country: req.GetCustomerCountry(), + State: req.GetCustomerState(), + City: req.GetCustomerCity(), + Address: req.GetCustomerAddress(), + }, + Payment: monetix.Payment{ + Amount: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + }, + Card: card, + } +} + +func buildCardTokenPayoutRequest(projectID int64, req *mntxv1.CardTokenPayoutRequest) monetix.CardTokenPayoutRequest { + return monetix.CardTokenPayoutRequest{ + General: monetix.General{ + ProjectID: projectID, + PaymentID: req.GetPayoutId(), + }, + Customer: monetix.Customer{ + ID: req.GetCustomerId(), + FirstName: req.GetCustomerFirstName(), + Middle: req.GetCustomerMiddleName(), + LastName: req.GetCustomerLastName(), + IP: req.GetCustomerIp(), + Zip: req.GetCustomerZip(), + Country: req.GetCustomerCountry(), + State: req.GetCustomerState(), + City: req.GetCustomerCity(), + Address: req.GetCustomerAddress(), + }, + Payment: monetix.Payment{ + Amount: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + }, + Token: monetix.Token{ + CardToken: req.GetCardToken(), + CardHolder: req.GetCardHolder(), + MaskedPAN: req.GetMaskedPan(), + }, + } +} + +func buildCardTokenizeRequest(projectID int64, req *mntxv1.CardTokenizeRequest, card *tokenizeCardInput) monetix.CardTokenizeRequest { + tokenizeCard := monetix.CardTokenize{ + PAN: card.pan, + Year: int(card.year), + Month: int(card.month), + CardHolder: card.holder, + CVV: card.cvv, + } + + return monetix.CardTokenizeRequest{ + General: monetix.General{ + ProjectID: projectID, + PaymentID: req.GetRequestId(), + }, + Customer: monetix.Customer{ + ID: req.GetCustomerId(), + FirstName: req.GetCustomerFirstName(), + Middle: req.GetCustomerMiddleName(), + LastName: req.GetCustomerLastName(), + IP: req.GetCustomerIp(), + Zip: req.GetCustomerZip(), + Country: req.GetCustomerCountry(), + State: req.GetCustomerState(), + City: req.GetCustomerCity(), + Address: req.GetCustomerAddress(), + }, + Card: tokenizeCard, + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_store.go b/api/gateway/mntx/internal/service/gateway/card_payout_store.go new file mode 100644 index 0000000..6233496 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_payout_store.go @@ -0,0 +1,55 @@ +package gateway + +import ( + "strings" + "sync" + + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/proto" +) + +type cardPayoutStore struct { + mu sync.RWMutex + payouts map[string]*mntxv1.CardPayoutState +} + +func newCardPayoutStore() *cardPayoutStore { + return &cardPayoutStore{ + payouts: make(map[string]*mntxv1.CardPayoutState), + } +} + +func (s *cardPayoutStore) Save(p *mntxv1.CardPayoutState) { + if p == nil { + return + } + key := strings.TrimSpace(p.GetPayoutId()) + if key == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.payouts[key] = cloneCardPayoutState(p) +} + +func (s *cardPayoutStore) Get(payoutID string) (*mntxv1.CardPayoutState, bool) { + id := strings.TrimSpace(payoutID) + if id == "" { + return nil, false + } + s.mu.RLock() + defer s.mu.RUnlock() + val, ok := s.payouts[id] + return cloneCardPayoutState(val), ok +} + +func cloneCardPayoutState(p *mntxv1.CardPayoutState) *mntxv1.CardPayoutState { + if p == nil { + return nil + } + cloned := proto.Clone(p) + if cp, ok := cloned.(*mntxv1.CardPayoutState); ok { + return cp + } + return nil +} diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_validation.go b/api/gateway/mntx/internal/service/gateway/card_payout_validation.go new file mode 100644 index 0000000..0fc4795 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_payout_validation.go @@ -0,0 +1,83 @@ +package gateway + +import ( + "strconv" + "strings" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg monetix.Config) error { + if req == nil { + return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) + } + + if strings.TrimSpace(req.GetPayoutId()) == "" { + return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id")) + } + if strings.TrimSpace(req.GetCustomerId()) == "" { + return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) + } + if strings.TrimSpace(req.GetCustomerFirstName()) == "" { + return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name")) + } + if strings.TrimSpace(req.GetCustomerLastName()) == "" { + return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name")) + } + if strings.TrimSpace(req.GetCustomerIp()) == "" { + return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip")) + } + + if req.GetAmountMinor() <= 0 { + return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor")) + } + + currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency())) + if currency == "" { + return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency")) + } + if !cfg.CurrencyAllowed(currency) { + return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency")) + } + + pan := strings.TrimSpace(req.GetCardPan()) + if pan == "" { + return newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card_pan")) + } + if strings.TrimSpace(req.GetCardHolder()) == "" { + return newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card_holder")) + } + if err := validateCardExpiryFields(req.GetCardExpMonth(), req.GetCardExpYear()); err != nil { + return err + } + + if cfg.RequireCustomerAddress { + if strings.TrimSpace(req.GetCustomerCountry()) == "" { + return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country")) + } + if strings.TrimSpace(req.GetCustomerCity()) == "" { + return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city")) + } + if strings.TrimSpace(req.GetCustomerAddress()) == "" { + return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address")) + } + if strings.TrimSpace(req.GetCustomerZip()) == "" { + return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip")) + } + } + + return nil +} + +func validateCardExpiryFields(month uint32, year uint32) error { + if month == 0 || month > 12 { + return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card_exp_month")) + } + yearStr := strconv.Itoa(int(year)) + if len(yearStr) < 2 || year == 0 { + return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card_exp_year")) + } + return nil +} diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go new file mode 100644 index 0000000..7cc10db --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -0,0 +1,311 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + messaging "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + nm "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type cardPayoutProcessor struct { + logger mlogger.Logger + config monetix.Config + clock clockpkg.Clock + store *cardPayoutStore + httpClient *http.Client + producer msg.Producer +} + +func newCardPayoutProcessor(logger mlogger.Logger, cfg monetix.Config, clock clockpkg.Clock, store *cardPayoutStore, client *http.Client, producer msg.Producer) *cardPayoutProcessor { + return &cardPayoutProcessor{ + logger: logger.Named("card_payout_processor"), + config: cfg, + clock: clock, + store: store, + httpClient: client, + producer: producer, + } +} + +func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { + return nil, merrors.Internal("monetix configuration is incomplete") + } + + req = sanitizeCardPayoutRequest(req) + if err := validateCardPayoutRequest(req, p.config); err != nil { + return nil, err + } + + projectID := req.GetProjectId() + if projectID == 0 { + projectID = p.config.ProjectID + } + if projectID == 0 { + return nil, merrors.Internal("monetix project_id is not configured") + } + + now := timestamppb.New(p.clock.Now()) + state := &mntxv1.CardPayoutState{ + PayoutId: req.GetPayoutId(), + ProjectId: projectID, + CustomerId: req.GetCustomerId(), + AmountMinor: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, + CreatedAt: now, + UpdatedAt: now, + } + + if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil { + if existing.GetCreatedAt() != nil { + state.CreatedAt = existing.GetCreatedAt() + } + } + + client := monetix.NewClient(p.config, p.httpClient, p.logger) + apiReq := buildCardPayoutRequest(projectID, req) + result, err := client.CreateCardPayout(ctx, apiReq) + if err != nil { + state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + state.ProviderMessage = err.Error() + state.UpdatedAt = timestamppb.New(p.clock.Now()) + p.store.Save(state) + return nil, err + } + + state.ProviderPaymentId = result.ProviderRequestID + if result.Accepted { + state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + } else { + state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + state.ProviderCode = result.ErrorCode + state.ProviderMessage = result.ErrorMessage + } + state.UpdatedAt = timestamppb.New(p.clock.Now()) + p.store.Save(state) + + resp := &mntxv1.CardPayoutResponse{ + Payout: state, + Accepted: result.Accepted, + ProviderRequestId: result.ProviderRequestID, + ErrorCode: result.ErrorCode, + ErrorMessage: result.ErrorMessage, + } + + return resp, nil +} + +func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { + return nil, merrors.Internal("monetix configuration is incomplete") + } + + req = sanitizeCardTokenPayoutRequest(req) + if err := validateCardTokenPayoutRequest(req, p.config); err != nil { + return nil, err + } + + projectID := req.GetProjectId() + if projectID == 0 { + projectID = p.config.ProjectID + } + if projectID == 0 { + return nil, merrors.Internal("monetix project_id is not configured") + } + + now := timestamppb.New(p.clock.Now()) + state := &mntxv1.CardPayoutState{ + PayoutId: req.GetPayoutId(), + ProjectId: projectID, + CustomerId: req.GetCustomerId(), + AmountMinor: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, + CreatedAt: now, + UpdatedAt: now, + } + + if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil { + if existing.GetCreatedAt() != nil { + state.CreatedAt = existing.GetCreatedAt() + } + } + + client := monetix.NewClient(p.config, p.httpClient, p.logger) + apiReq := buildCardTokenPayoutRequest(projectID, req) + result, err := client.CreateCardTokenPayout(ctx, apiReq) + if err != nil { + state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + state.ProviderMessage = err.Error() + state.UpdatedAt = timestamppb.New(p.clock.Now()) + p.store.Save(state) + return nil, err + } + + state.ProviderPaymentId = result.ProviderRequestID + if result.Accepted { + state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + } else { + state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + state.ProviderCode = result.ErrorCode + state.ProviderMessage = result.ErrorMessage + } + state.UpdatedAt = timestamppb.New(p.clock.Now()) + p.store.Save(state) + + resp := &mntxv1.CardTokenPayoutResponse{ + Payout: state, + Accepted: result.Accepted, + ProviderRequestId: result.ProviderRequestID, + ErrorCode: result.ErrorCode, + ErrorMessage: result.ErrorMessage, + } + + return resp, nil +} + +func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + cardInput, err := validateCardTokenizeRequest(req, p.config) + if err != nil { + return nil, err + } + + projectID := req.GetProjectId() + if projectID == 0 { + projectID = p.config.ProjectID + } + if projectID == 0 { + return nil, merrors.Internal("monetix project_id is not configured") + } + + req = sanitizeCardTokenizeRequest(req) + cardInput = extractTokenizeCard(req) + client := monetix.NewClient(p.config, p.httpClient, p.logger) + apiReq := buildCardTokenizeRequest(projectID, req, cardInput) + result, err := client.CreateCardTokenization(ctx, apiReq) + if err != nil { + return nil, err + } + + resp := &mntxv1.CardTokenizeResponse{ + RequestId: req.GetRequestId(), + Success: result.Accepted, + ErrorCode: result.ErrorCode, + ErrorMessage: result.ErrorMessage, + } + resp.Token = result.Token + resp.MaskedPan = result.MaskedPAN + resp.ExpiryMonth = result.ExpiryMonth + resp.ExpiryYear = result.ExpiryYear + resp.CardBrand = result.CardBrand + + return resp, nil +} + +func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv1.CardPayoutState, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + + id := strings.TrimSpace(payoutID) + if id == "" { + return nil, merrors.InvalidArgument("payout_id is required", "payout_id") + } + + state, ok := p.store.Get(id) + if !ok || state == nil { + return nil, merrors.NoData("payout not found") + } + return state, nil +} + +func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byte) (int, error) { + if p == nil { + return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") + } + if len(payload) == 0 { + return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty") + } + if strings.TrimSpace(p.config.SecretKey) == "" { + return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured") + } + + var cb monetixCallback + if err := json.Unmarshal(payload, &cb); err != nil { + return http.StatusBadRequest, err + } + + if strings.TrimSpace(cb.Signature) == "" { + return http.StatusBadRequest, merrors.InvalidArgument("signature is missing") + } + if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil { + p.logger.Warn("Monetix callback signature check failed", zap.Error(err)) + return http.StatusForbidden, err + } + + state, statusLabel := mapCallbackToState(p.clock, p.config, cb) + if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil { + if existing.GetCreatedAt() != nil { + state.CreatedAt = existing.GetCreatedAt() + } + } + p.store.Save(state) + p.emitCardPayoutEvent(state) + monetix.ObserveCallback(statusLabel) + + p.logger.Info("Monetix payout callback processed", + zap.String("payout_id", state.GetPayoutId()), + zap.String("status", statusLabel), + zap.String("provider_code", state.GetProviderCode()), + zap.String("provider_message", state.GetProviderMessage()), + zap.String("masked_account", cb.Account.Number), + ) + + return http.StatusOK, nil +} + +func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState) { + if state == nil || p.producer == nil { + return + } + + event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state} + payload, err := protojson.Marshal(event) + if err != nil { + p.logger.Warn("failed to marshal payout callback event", zap.Error(err)) + return + } + + env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated)) + if _, err := env.Wrap(payload); err != nil { + p.logger.Warn("failed to wrap payout callback event payload", zap.Error(err)) + return + } + if err := p.producer.SendMessage(env); err != nil { + p.logger.Warn("failed to publish payout callback event", zap.Error(err)) + } +} diff --git a/api/gateway/mntx/internal/service/gateway/card_token_validation.go b/api/gateway/mntx/internal/service/gateway/card_token_validation.go new file mode 100644 index 0000000..9392490 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_token_validation.go @@ -0,0 +1,63 @@ +package gateway + +import ( + "strings" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg monetix.Config) error { + if req == nil { + return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) + } + + if strings.TrimSpace(req.GetPayoutId()) == "" { + return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id")) + } + if strings.TrimSpace(req.GetCustomerId()) == "" { + return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) + } + if strings.TrimSpace(req.GetCustomerFirstName()) == "" { + return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name")) + } + if strings.TrimSpace(req.GetCustomerLastName()) == "" { + return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name")) + } + if strings.TrimSpace(req.GetCustomerIp()) == "" { + return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip")) + } + if req.GetAmountMinor() <= 0 { + return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor")) + } + + currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency())) + if currency == "" { + return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency")) + } + if !cfg.CurrencyAllowed(currency) { + return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency")) + } + + if strings.TrimSpace(req.GetCardToken()) == "" { + return newPayoutError("missing_card_token", merrors.InvalidArgument("card_token is required", "card_token")) + } + + if cfg.RequireCustomerAddress { + if strings.TrimSpace(req.GetCustomerCountry()) == "" { + return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country")) + } + if strings.TrimSpace(req.GetCustomerCity()) == "" { + return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city")) + } + if strings.TrimSpace(req.GetCustomerAddress()) == "" { + return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address")) + } + if strings.TrimSpace(req.GetCustomerZip()) == "" { + return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip")) + } + } + + return nil +} diff --git a/api/gateway/mntx/internal/service/gateway/card_tokenize_validation.go b/api/gateway/mntx/internal/service/gateway/card_tokenize_validation.go new file mode 100644 index 0000000..d528369 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_tokenize_validation.go @@ -0,0 +1,108 @@ +package gateway + +import ( + "strings" + "time" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +type tokenizeCardInput struct { + pan string + month uint32 + year uint32 + holder string + cvv string +} + +func validateCardTokenizeRequest(req *mntxv1.CardTokenizeRequest, cfg monetix.Config) (*tokenizeCardInput, error) { + if req == nil { + return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) + } + if strings.TrimSpace(req.GetRequestId()) == "" { + return nil, newPayoutError("missing_request_id", merrors.InvalidArgument("request_id is required", "request_id")) + } + if strings.TrimSpace(req.GetCustomerId()) == "" { + return nil, newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) + } + if strings.TrimSpace(req.GetCustomerFirstName()) == "" { + return nil, newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name")) + } + if strings.TrimSpace(req.GetCustomerLastName()) == "" { + return nil, newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name")) + } + if strings.TrimSpace(req.GetCustomerIp()) == "" { + return nil, newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip")) + } + + card := extractTokenizeCard(req) + if card.pan == "" { + return nil, newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card.pan")) + } + if card.holder == "" { + return nil, newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card.holder")) + } + if card.month == 0 || card.month > 12 { + return nil, newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card.exp_month")) + } + if card.year == 0 { + return nil, newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card.exp_year")) + } + if card.cvv == "" { + return nil, newPayoutError("missing_cvv", merrors.InvalidArgument("card_cvv is required", "card.cvv")) + } + if expired(card.month, card.year) { + return nil, newPayoutError("expired_card", merrors.InvalidArgument("card expiry is in the past", "card.expiry")) + } + + if cfg.RequireCustomerAddress { + if strings.TrimSpace(req.GetCustomerCountry()) == "" { + return nil, newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country")) + } + if strings.TrimSpace(req.GetCustomerCity()) == "" { + return nil, newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city")) + } + if strings.TrimSpace(req.GetCustomerAddress()) == "" { + return nil, newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address")) + } + if strings.TrimSpace(req.GetCustomerZip()) == "" { + return nil, newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip")) + } + } + + return card, nil +} + +func extractTokenizeCard(req *mntxv1.CardTokenizeRequest) *tokenizeCardInput { + card := req.GetCard() + if card != nil { + return &tokenizeCardInput{ + pan: strings.TrimSpace(card.GetPan()), + month: card.GetExpMonth(), + year: card.GetExpYear(), + holder: strings.TrimSpace(card.GetCardHolder()), + cvv: strings.TrimSpace(card.GetCvv()), + } + } + return &tokenizeCardInput{ + pan: strings.TrimSpace(req.GetCardPan()), + month: req.GetCardExpMonth(), + year: req.GetCardExpYear(), + holder: strings.TrimSpace(req.GetCardHolder()), + cvv: strings.TrimSpace(req.GetCardCvv()), + } +} + +func expired(month uint32, year uint32) bool { + now := time.Now() + y := int(year) + m := time.Month(month) + // Normalize 2-digit years: assume 2000-2099. + if y < 100 { + y += 2000 + } + expiry := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, -1) + return now.After(expiry) +} diff --git a/api/gateway/mntx/internal/service/gateway/metrics.go b/api/gateway/mntx/internal/service/gateway/metrics.go new file mode 100644 index 0000000..9b14c4d --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/metrics.go @@ -0,0 +1,174 @@ +package gateway + +import ( + "errors" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/shopspring/decimal" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +var ( + metricsOnce sync.Once + + rpcLatency *prometheus.HistogramVec + rpcStatus *prometheus.CounterVec + + payoutCounter *prometheus.CounterVec + payoutAmountTotal *prometheus.CounterVec + payoutErrorCount *prometheus.CounterVec + payoutMissedAmounts *prometheus.CounterVec +) + +func initMetrics() { + metricsOnce.Do(func() { + rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "rpc_latency_seconds", + Help: "Latency distribution for Monetix gateway RPC handlers.", + Buckets: prometheus.DefBuckets, + }, []string{"method"}) + + rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "rpc_requests_total", + Help: "Total number of RPC invocations grouped by method and status.", + }, []string{"method", "status"}) + + payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "payouts_total", + Help: "Total payouts processed grouped by outcome.", + }, []string{"status"}) + + payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "payout_amount_total", + Help: "Total payout amount grouped by outcome and currency.", + }, []string{"status", "currency"}) + + payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "payout_errors_total", + Help: "Payout failures grouped by reason.", + }, []string{"reason"}) + + payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "payout_missed_amount_total", + Help: "Total payout volume that failed grouped by reason and currency.", + }, []string{"reason", "currency"}) + }) +} + +func observeRPC(method string, err error, duration time.Duration) { + if rpcLatency != nil { + rpcLatency.WithLabelValues(method).Observe(duration.Seconds()) + } + if rpcStatus != nil { + rpcStatus.WithLabelValues(method, statusLabel(err)).Inc() + } +} + +func observePayoutSuccess(amount *moneyv1.Money) { + if payoutCounter != nil { + payoutCounter.WithLabelValues("processed").Inc() + } + value, currency := monetaryValue(amount) + if value > 0 && payoutAmountTotal != nil { + payoutAmountTotal.WithLabelValues("processed", currency).Add(value) + } +} + +func observePayoutError(reason string, amount *moneyv1.Money) { + reason = reasonLabel(reason) + if payoutCounter != nil { + payoutCounter.WithLabelValues("failed").Inc() + } + if payoutErrorCount != nil { + payoutErrorCount.WithLabelValues(reason).Inc() + } + value, currency := monetaryValue(amount) + if value <= 0 { + return + } + if payoutAmountTotal != nil { + payoutAmountTotal.WithLabelValues("failed", currency).Add(value) + } + if payoutMissedAmounts != nil { + payoutMissedAmounts.WithLabelValues(reason, currency).Add(value) + } +} + +func monetaryValue(amount *moneyv1.Money) (float64, string) { + if amount == nil { + return 0, "unknown" + } + val := strings.TrimSpace(amount.Amount) + if val == "" { + return 0, currencyLabel(amount.Currency) + } + dec, err := decimal.NewFromString(val) + if err != nil { + return 0, currencyLabel(amount.Currency) + } + f, _ := dec.Float64() + if f < 0 { + return 0, currencyLabel(amount.Currency) + } + return f, currencyLabel(amount.Currency) +} + +func currencyLabel(code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + return "unknown" + } + return code +} + +func reasonLabel(reason string) string { + reason = strings.TrimSpace(reason) + if reason == "" { + return "unknown" + } + return strings.ToLower(reason) +} + +func statusLabel(err error) string { + switch { + case err == nil: + return "ok" + case errors.Is(err, merrors.ErrInvalidArg): + return "invalid_argument" + case errors.Is(err, merrors.ErrNoData): + return "not_found" + case errors.Is(err, merrors.ErrDataConflict): + return "conflict" + case errors.Is(err, merrors.ErrAccessDenied): + return "denied" + case errors.Is(err, merrors.ErrInternal): + return "internal" + default: + return "error" + } +} + +func normalizeCallbackStatus(status string) string { + status = strings.TrimSpace(status) + if status == "" { + return "unknown" + } + return strings.ToLower(status) +} diff --git a/api/gateway/mntx/internal/service/gateway/options.go b/api/gateway/mntx/internal/service/gateway/options.go new file mode 100644 index 0000000..12e4b3c --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/options.go @@ -0,0 +1,44 @@ +package gateway + +import ( + "net/http" + + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/pkg/clock" + msg "github.com/tech/sendico/pkg/messaging" +) + +// Option configures optional service dependencies. +type Option func(*Service) + +// WithClock injects a custom clock (useful for tests). +func WithClock(c clock.Clock) Option { + return func(s *Service) { + if c != nil { + s.clock = c + } + } +} + +// WithProducer attaches a messaging producer to the service. +func WithProducer(p msg.Producer) Option { + return func(s *Service) { + s.producer = p + } +} + +// WithHTTPClient injects a custom HTTP client (useful for tests). +func WithHTTPClient(client *http.Client) Option { + return func(s *Service) { + if client != nil { + s.httpClient = client + } + } +} + +// WithMonetixConfig sets the Monetix connectivity options. +func WithMonetixConfig(cfg monetix.Config) Option { + return func(s *Service) { + s.config = cfg + } +} diff --git a/api/gateway/mntx/internal/service/gateway/payout_get.go b/api/gateway/mntx/internal/service/gateway/payout_get.go new file mode 100644 index 0000000..5ed5af3 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/payout_get.go @@ -0,0 +1,30 @@ +package gateway + +import ( + "context" + "fmt" + "strings" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) { + return executeUnary(ctx, s, "GetPayout", s.handleGetPayout, req) +} + +func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] { + ref := strings.TrimSpace(req.GetPayoutRef()) + if ref == "" { + return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref")) + } + + payout, ok := s.store.Get(ref) + if !ok { + return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref))) + } + + return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout}) +} diff --git a/api/gateway/mntx/internal/service/gateway/payout_store.go b/api/gateway/mntx/internal/service/gateway/payout_store.go new file mode 100644 index 0000000..c12405a --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/payout_store.go @@ -0,0 +1,46 @@ +package gateway + +import ( + "sync" + + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/proto" +) + +type payoutStore struct { + mu sync.RWMutex + payouts map[string]*mntxv1.Payout +} + +func newPayoutStore() *payoutStore { + return &payoutStore{ + payouts: make(map[string]*mntxv1.Payout), + } +} + +func (s *payoutStore) Save(p *mntxv1.Payout) { + if p == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.payouts[p.GetPayoutRef()] = clonePayout(p) +} + +func (s *payoutStore) Get(ref string) (*mntxv1.Payout, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + p, ok := s.payouts[ref] + return clonePayout(p), ok +} + +func clonePayout(p *mntxv1.Payout) *mntxv1.Payout { + if p == nil { + return nil + } + cloned := proto.Clone(p) + if cp, ok := cloned.(*mntxv1.Payout); ok { + return cp + } + return nil +} diff --git a/api/gateway/mntx/internal/service/gateway/payout_submit.go b/api/gateway/mntx/internal/service/gateway/payout_submit.go new file mode 100644 index 0000000..02ba67e --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/payout_submit.go @@ -0,0 +1,131 @@ +package gateway + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + messaging "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/model" + nm "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequest) (*mntxv1.SubmitPayoutResponse, error) { + return executeUnary(ctx, s, "SubmitPayout", s.handleSubmitPayout, req) +} + +func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] { + payout, err := s.buildPayout(req) + if err != nil { + return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err) + } + + s.store.Save(payout) + s.emitEvent(payout, nm.NAPending) + go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason())) + + return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout}) +} + +func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, error) { + if req == nil { + return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) + } + + idempotencyKey := strings.TrimSpace(req.IdempotencyKey) + if idempotencyKey == "" { + return nil, newPayoutError("missing_idempotency_key", merrors.InvalidArgument("idempotency_key is required", "idempotency_key")) + } + + orgRef := strings.TrimSpace(req.OrganizationRef) + if orgRef == "" { + return nil, newPayoutError("missing_organization_ref", merrors.InvalidArgument("organization_ref is required", "organization_ref")) + } + + if err := validateAmount(req.Amount); err != nil { + return nil, err + } + + if err := validateDestination(req.Destination); err != nil { + return nil, err + } + + if reason := strings.TrimSpace(req.SimulatedFailureReason); reason != "" { + return nil, newPayoutError(normalizeReason(reason), merrors.InvalidArgument("simulated payout failure requested")) + } + + now := timestamppb.New(s.clock.Now()) + payout := &mntxv1.Payout{ + PayoutRef: newPayoutRef(), + IdempotencyKey: idempotencyKey, + OrganizationRef: orgRef, + Destination: req.Destination, + Amount: req.Amount, + Description: strings.TrimSpace(req.Description), + Metadata: req.Metadata, + Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, + CreatedAt: now, + UpdatedAt: now, + } + + return payout, nil +} + +func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) { + outcome := clonePayout(original) + if outcome == nil { + return + } + + // Simulate async processing delay for realism. + time.Sleep(150 * time.Millisecond) + + outcome.UpdatedAt = timestamppb.New(s.clock.Now()) + + if simulatedFailure != "" { + outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + outcome.FailureReason = simulatedFailure + observePayoutError(simulatedFailure, outcome.Amount) + s.store.Save(outcome) + s.emitEvent(outcome, nm.NAUpdated) + return + } + + outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED + observePayoutSuccess(outcome.Amount) + s.store.Save(outcome) + s.emitEvent(outcome, nm.NAUpdated) +} + +func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) { + if payout == nil || s.producer == nil { + return + } + + payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout}) + if err != nil { + s.logger.Warn("failed to marshal payout event", zapError(err)) + return + } + + env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action)) + if _, err := env.Wrap(payload); err != nil { + s.logger.Warn("failed to wrap payout event payload", zapError(err)) + return + } + + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("failed to publish payout event", zapError(err)) + } +} + +func zapError(err error) zap.Field { + return zap.Error(err) +} diff --git a/api/gateway/mntx/internal/service/gateway/payout_validation.go b/api/gateway/mntx/internal/service/gateway/payout_validation.go new file mode 100644 index 0000000..debb7df --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/payout_validation.go @@ -0,0 +1,106 @@ +package gateway + +import ( + "strconv" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func validateAmount(amount *moneyv1.Money) error { + if amount == nil { + return newPayoutError("missing_amount", merrors.InvalidArgument("amount is required", "amount")) + } + + if strings.TrimSpace(amount.Currency) == "" { + return newPayoutError("missing_currency", merrors.InvalidArgument("amount currency is required", "amount.currency")) + } + + val := strings.TrimSpace(amount.Amount) + if val == "" { + return newPayoutError("missing_amount_value", merrors.InvalidArgument("amount value is required", "amount.amount")) + } + dec, err := decimal.NewFromString(val) + if err != nil { + return newPayoutError("invalid_amount", merrors.InvalidArgument("amount must be a decimal value", "amount.amount")) + } + if dec.Sign() <= 0 { + return newPayoutError("non_positive_amount", merrors.InvalidArgument("amount must be positive", "amount.amount")) + } + return nil +} + +func validateDestination(dest *mntxv1.PayoutDestination) error { + if dest == nil { + return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination")) + } + + if bank := dest.GetBankAccount(); bank != nil { + return validateBankAccount(bank) + } + + if card := dest.GetCard(); card != nil { + return validateCardDestination(card) + } + + return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include bank_account or card", "destination")) +} + +func validateBankAccount(dest *mntxv1.BankAccount) error { + if dest == nil { + return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination")) + } + iban := strings.TrimSpace(dest.Iban) + holder := strings.TrimSpace(dest.AccountHolder) + + if iban == "" && holder == "" { + return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include iban or account_holder", "destination")) + } + return nil +} + +func validateCardDestination(card *mntxv1.CardDestination) error { + if card == nil { + return newPayoutError("missing_destination", merrors.InvalidArgument("destination.card is required", "destination.card")) + } + + pan := strings.TrimSpace(card.GetPan()) + token := strings.TrimSpace(card.GetToken()) + if pan == "" && token == "" { + return newPayoutError("invalid_card_destination", merrors.InvalidArgument("card destination must include pan or token", "destination.card")) + } + + if strings.TrimSpace(card.GetCardholderName()) == "" { + return newPayoutError("missing_cardholder_name", merrors.InvalidArgument("cardholder_name is required", "destination.card.cardholder_name")) + } + + month := strings.TrimSpace(card.GetExpMonth()) + year := strings.TrimSpace(card.GetExpYear()) + if pan != "" { + if err := validateExpiry(month, year); err != nil { + return err + } + } + + return nil +} + +func validateExpiry(month, year string) error { + if month == "" || year == "" { + return newPayoutError("missing_expiry", merrors.InvalidArgument("exp_month and exp_year are required for card payouts", "destination.card.expiry")) + } + + m, err := strconv.Atoi(month) + if err != nil || m < 1 || m > 12 { + return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("exp_month must be between 01 and 12", "destination.card.exp_month")) + } + + if _, err := strconv.Atoi(year); err != nil || len(year) < 2 { + return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("exp_year must be numeric", "destination.card.exp_year")) + } + + return nil +} diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go new file mode 100644 index 0000000..a03f816 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -0,0 +1,119 @@ +package gateway + +import ( + "context" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + clockpkg "github.com/tech/sendico/pkg/clock" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/grpc" +) + +type Service struct { + logger mlogger.Logger + clock clockpkg.Clock + producer msg.Producer + store *payoutStore + cardStore *cardPayoutStore + config monetix.Config + httpClient *http.Client + card *cardPayoutProcessor + + mntxv1.UnimplementedMntxGatewayServiceServer +} + +type payoutFailure interface { + error + Reason() string +} + +type reasonedError struct { + reason string + err error +} + +func (r reasonedError) Error() string { + return r.err.Error() +} + +func (r reasonedError) Unwrap() error { + return r.err +} + +func (r reasonedError) Reason() string { + return r.reason +} + +// NewService constructs the Monetix gateway service skeleton. +func NewService(logger mlogger.Logger, opts ...Option) *Service { + svc := &Service{ + logger: logger.Named("service"), + clock: clockpkg.NewSystem(), + store: newPayoutStore(), + cardStore: newCardPayoutStore(), + config: monetix.DefaultConfig(), + } + + initMetrics() + + for _, opt := range opts { + if opt != nil { + opt(svc) + } + } + + if svc.clock == nil { + svc.clock = clockpkg.NewSystem() + } + + if svc.httpClient == nil { + svc.httpClient = &http.Client{Timeout: svc.config.Timeout()} + } else if svc.httpClient.Timeout <= 0 { + svc.httpClient.Timeout = svc.config.Timeout() + } + + if svc.cardStore == nil { + svc.cardStore = newCardPayoutStore() + } + + svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer) + + return svc +} + +// Register wires the service onto the provided gRPC router. +func (s *Service) Register(router routers.GRPC) error { + return router.Register(func(reg grpc.ServiceRegistrar) { + mntxv1.RegisterMntxGatewayServiceServer(reg, s) + }) +} + +func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { + start := svc.clock.Now() + resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req) + observeRPC(method, err, svc.clock.Now().Sub(start)) + return resp, err +} + +func newPayoutRef() string { + return "pyt_" + strings.ReplaceAll(uuid.New().String(), "-", "") +} + +func normalizeReason(reason string) string { + return strings.ToLower(strings.TrimSpace(reason)) +} + +func newPayoutError(reason string, err error) error { + return reasonedError{ + reason: normalizeReason(reason), + err: err, + } +} diff --git a/api/gateway/mntx/internal/service/monetix/client.go b/api/gateway/mntx/internal/service/monetix/client.go new file mode 100644 index 0000000..4bf169d --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/client.go @@ -0,0 +1,66 @@ +package monetix + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type Client struct { + cfg Config + client *http.Client + logger mlogger.Logger +} + +func NewClient(cfg Config, httpClient *http.Client, logger mlogger.Logger) *Client { + client := httpClient + if client == nil { + client = &http.Client{Timeout: cfg.timeout()} + } + cl := logger + if cl == nil { + cl = zap.NewNop() + } + return &Client{ + cfg: cfg, + client: client, + logger: cl.Named("monetix_client"), + } +} + +func (c *Client) CreateCardPayout(ctx context.Context, req CardPayoutRequest) (*CardPayoutSendResult, error) { + return c.sendCardPayout(ctx, req) +} + +func (c *Client) CreateCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) { + return c.sendCardTokenPayout(ctx, req) +} + +func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) { + return c.sendTokenization(ctx, req) +} + +func signPayload(payload any, secret string) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + + h := hmac.New(sha256.New, []byte(secret)) + if _, err := h.Write(data); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// SignPayload exposes signature calculation for callback verification. +func SignPayload(payload any, secret string) (string, error) { + return signPayload(payload, secret) +} diff --git a/api/gateway/mntx/internal/service/monetix/config.go b/api/gateway/mntx/internal/service/monetix/config.go new file mode 100644 index 0000000..a46fe37 --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/config.go @@ -0,0 +1,78 @@ +package monetix + +import ( + "strings" + "time" +) + +const ( + DefaultRequestTimeout = 15 * time.Second + DefaultStatusSuccess = "success" + DefaultStatusProcessing = "processing" + + OutcomeSuccess = "success" + OutcomeProcessing = "processing" + OutcomeDecline = "decline" +) + +// Config holds resolved settings for communicating with Monetix. +type Config struct { + BaseURL string + ProjectID int64 + SecretKey string + AllowedCurrencies []string + RequireCustomerAddress bool + RequestTimeout time.Duration + StatusSuccess string + StatusProcessing string +} + +func DefaultConfig() Config { + return Config{ + RequestTimeout: DefaultRequestTimeout, + StatusSuccess: DefaultStatusSuccess, + StatusProcessing: DefaultStatusProcessing, + } +} + +func (c Config) timeout() time.Duration { + if c.RequestTimeout <= 0 { + return DefaultRequestTimeout + } + return c.RequestTimeout +} + +// Timeout exposes the configured HTTP timeout for external callers. +func (c Config) Timeout() time.Duration { + return c.timeout() +} + +func (c Config) CurrencyAllowed(code string) bool { + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + return false + } + if len(c.AllowedCurrencies) == 0 { + return true + } + for _, allowed := range c.AllowedCurrencies { + if strings.EqualFold(strings.TrimSpace(allowed), code) { + return true + } + } + return false +} + +func (c Config) SuccessStatus() string { + if strings.TrimSpace(c.StatusSuccess) == "" { + return DefaultStatusSuccess + } + return strings.ToLower(strings.TrimSpace(c.StatusSuccess)) +} + +func (c Config) ProcessingStatus() string { + if strings.TrimSpace(c.StatusProcessing) == "" { + return DefaultStatusProcessing + } + return strings.ToLower(strings.TrimSpace(c.StatusProcessing)) +} diff --git a/api/gateway/mntx/internal/service/monetix/mask.go b/api/gateway/mntx/internal/service/monetix/mask.go new file mode 100644 index 0000000..49cd6fe --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/mask.go @@ -0,0 +1,21 @@ +package monetix + +import "strings" + +// MaskPAN redacts a primary account number by keeping the first 6 and last 4 digits. +func MaskPAN(pan string) string { + p := strings.TrimSpace(pan) + if len(p) <= 4 { + return strings.Repeat("*", len(p)) + } + + if len(p) <= 10 { + return p[:2] + strings.Repeat("*", len(p)-4) + p[len(p)-2:] + } + + maskLen := len(p) - 10 + if maskLen < 0 { + maskLen = 0 + } + return p[:6] + strings.Repeat("*", maskLen) + p[len(p)-4:] +} diff --git a/api/gateway/mntx/internal/service/monetix/metrics.go b/api/gateway/mntx/internal/service/monetix/metrics.go new file mode 100644 index 0000000..9d0b3fb --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/metrics.go @@ -0,0 +1,71 @@ +package monetix + +import ( + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + metricsOnce sync.Once + + cardPayoutRequests *prometheus.CounterVec + cardPayoutCallbacks *prometheus.CounterVec + cardPayoutLatency *prometheus.HistogramVec +) + +func initMetrics() { + metricsOnce.Do(func() { + cardPayoutRequests = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "card_payout_requests_total", + Help: "Monetix card payout submissions grouped by outcome.", + }, []string{"outcome"}) + + cardPayoutCallbacks = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "card_payout_callbacks_total", + Help: "Monetix card payout callbacks grouped by provider status.", + }, []string{"status"}) + + cardPayoutLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sendico", + Subsystem: "mntx_gateway", + Name: "card_payout_request_latency_seconds", + Help: "Latency distribution for outbound Monetix card payout requests.", + Buckets: prometheus.DefBuckets, + }, []string{"outcome"}) + }) +} + +func observeRequest(outcome string, duration time.Duration) { + initMetrics() + outcome = strings.ToLower(strings.TrimSpace(outcome)) + if outcome == "" { + outcome = "unknown" + } + if cardPayoutLatency != nil { + cardPayoutLatency.WithLabelValues(outcome).Observe(duration.Seconds()) + } + if cardPayoutRequests != nil { + cardPayoutRequests.WithLabelValues(outcome).Inc() + } +} + +// ObserveCallback records callback status for Monetix card payouts. +func ObserveCallback(status string) { + initMetrics() + status = strings.TrimSpace(status) + if status == "" { + status = "unknown" + } + status = strings.ToLower(status) + if cardPayoutCallbacks != nil { + cardPayoutCallbacks.WithLabelValues(status).Inc() + } +} diff --git a/api/gateway/mntx/internal/service/monetix/payloads.go b/api/gateway/mntx/internal/service/monetix/payloads.go new file mode 100644 index 0000000..b44bc20 --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/payloads.go @@ -0,0 +1,96 @@ +package monetix + +type General struct { + ProjectID int64 `json:"project_id"` + PaymentID string `json:"payment_id"` + Signature string `json:"signature,omitempty"` +} + +type Customer struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + Middle string `json:"middle_name,omitempty"` + LastName string `json:"last_name"` + IP string `json:"ip_address"` + + Zip string `json:"zip,omitempty"` + Country string `json:"country,omitempty"` + State string `json:"state,omitempty"` + City string `json:"city,omitempty"` + Address string `json:"address,omitempty"` +} + +type Payment struct { + Amount int64 `json:"amount"` + Currency string `json:"currency"` +} + +type Card struct { + PAN string `json:"pan"` + Year int `json:"year,omitempty"` + Month int `json:"month,omitempty"` + CardHolder string `json:"card_holder"` +} + +type CardTokenize struct { + PAN string `json:"pan"` + Year int `json:"year,omitempty"` + Month int `json:"month,omitempty"` + CardHolder string `json:"card_holder"` + CVV string `json:"cvv,omitempty"` +} + +type Token struct { + CardToken string `json:"card_token"` + CardHolder string `json:"card_holder,omitempty"` + MaskedPAN string `json:"masked_pan,omitempty"` +} + +type CardPayoutRequest struct { + General General `json:"general"` + Customer Customer `json:"customer"` + Payment Payment `json:"payment"` + Card Card `json:"card"` +} + +type CardTokenPayoutRequest struct { + General General `json:"general"` + Customer Customer `json:"customer"` + Payment Payment `json:"payment"` + Token Token `json:"token"` +} + +type CardTokenizeRequest struct { + General General `json:"general"` + Customer Customer `json:"customer"` + Card CardTokenize `json:"card"` +} + +type CardPayoutSendResult struct { + Accepted bool + ProviderRequestID string + StatusCode int + ErrorCode string + ErrorMessage string +} + +type TokenizationResult struct { + CardPayoutSendResult + Token string + MaskedPAN string + ExpiryMonth string + ExpiryYear string + CardBrand string +} + +type APIResponse struct { + RequestID string `json:"request_id"` + Message string `json:"message"` + Code string `json:"code"` + Operation struct { + RequestID string `json:"request_id"` + Status string `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + } `json:"operation"` +} diff --git a/api/gateway/mntx/internal/service/monetix/sender.go b/api/gateway/mntx/internal/service/monetix/sender.go new file mode 100644 index 0000000..3757716 --- /dev/null +++ b/api/gateway/mntx/internal/service/monetix/sender.go @@ -0,0 +1,289 @@ +package monetix + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" +) + +const ( + outcomeAccepted = "accepted" + outcomeHTTPError = "http_error" + outcomeNetworkError = "network_error" +) + +// sendCardPayout dispatches a PAN-based payout. +func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*CardPayoutSendResult, error) { + maskedPAN := MaskPAN(req.Card.PAN) + return c.send(ctx, &req, "/v2/payment/card/payout", + func() { + c.logger.Info("dispatching Monetix card payout", + zap.String("payout_id", req.General.PaymentID), + zap.Int64("amount_minor", req.Payment.Amount), + zap.String("currency", req.Payment.Currency), + zap.String("pan", maskedPAN), + ) + }, + func(r *CardPayoutSendResult) { + c.logger.Info("Monetix payout response", + zap.String("payout_id", req.General.PaymentID), + zap.Bool("accepted", r.Accepted), + zap.Int("status_code", r.StatusCode), + zap.String("provider_request_id", r.ProviderRequestID), + zap.String("error_code", r.ErrorCode), + zap.String("error_message", r.ErrorMessage), + ) + }) +} + +// sendCardTokenPayout dispatches a token-based payout. +func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) { + return c.send(ctx, &req, "/v2/payment/card/payout/token", + func() { + c.logger.Info("dispatching Monetix card token payout", + zap.String("payout_id", req.General.PaymentID), + zap.Int64("amount_minor", req.Payment.Amount), + zap.String("currency", req.Payment.Currency), + zap.String("masked_pan", req.Token.MaskedPAN), + ) + }, + func(r *CardPayoutSendResult) { + c.logger.Info("Monetix token payout response", + zap.String("payout_id", req.General.PaymentID), + zap.Bool("accepted", r.Accepted), + zap.Int("status_code", r.StatusCode), + zap.String("provider_request_id", r.ProviderRequestID), + zap.String("error_code", r.ErrorCode), + zap.String("error_message", r.ErrorMessage), + ) + }) +} + +// sendTokenization sends a tokenization request. +func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) { + if ctx == nil { + ctx = context.Background() + } + if c == nil { + return nil, merrors.Internal("monetix client not initialised") + } + if strings.TrimSpace(c.cfg.SecretKey) == "" { + return nil, merrors.Internal("monetix secret key not configured") + } + if strings.TrimSpace(c.cfg.BaseURL) == "" { + return nil, merrors.Internal("monetix base url not configured") + } + + req.General.Signature = "" + signature, err := signPayload(req, c.cfg.SecretKey) + if err != nil { + return nil, merrors.Internal("failed to sign request: " + err.Error()) + } + req.General.Signature = signature + + payload, err := json.Marshal(req) + if err != nil { + return nil, merrors.Internal("failed to marshal request payload: " + err.Error()) + } + + url := strings.TrimRight(c.cfg.BaseURL, "/") + "/v1/tokenize" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, merrors.Internal("failed to build request: " + err.Error()) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + c.logger.Info("dispatching Monetix card tokenization", + zap.String("request_id", req.General.PaymentID), + zap.String("masked_pan", MaskPAN(req.Card.PAN)), + ) + + start := time.Now() + resp, err := c.client.Do(httpReq) + duration := time.Since(start) + if err != nil { + observeRequest(outcomeNetworkError, duration) + return nil, merrors.Internal("monetix tokenization request failed: " + err.Error()) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + outcome := outcomeAccepted + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + outcome = outcomeHTTPError + } + observeRequest(outcome, duration) + + result := &TokenizationResult{ + CardPayoutSendResult: CardPayoutSendResult{ + Accepted: resp.StatusCode >= 200 && resp.StatusCode < 300, + StatusCode: resp.StatusCode, + }, + } + + var apiResp APIResponse + if len(body) > 0 { + if err := json.Unmarshal(body, &apiResp); err != nil { + c.logger.Warn("failed to decode Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Int("status_code", resp.StatusCode), zap.Error(err)) + } else { + var tokenData struct { + Token string `json:"token"` + MaskedPAN string `json:"masked_pan"` + ExpiryMonth string `json:"expiry_month"` + ExpiryYear string `json:"expiry_year"` + CardBrand string `json:"card_brand"` + } + _ = json.Unmarshal(body, &tokenData) + result.Token = tokenData.Token + result.MaskedPAN = tokenData.MaskedPAN + result.ExpiryMonth = tokenData.ExpiryMonth + result.ExpiryYear = tokenData.ExpiryYear + result.CardBrand = tokenData.CardBrand + } + } + + if apiResp.Operation.RequestID != "" { + result.ProviderRequestID = apiResp.Operation.RequestID + } else if apiResp.RequestID != "" { + result.ProviderRequestID = apiResp.RequestID + } + + if !result.Accepted { + result.ErrorCode = apiResp.Code + if result.ErrorCode == "" { + result.ErrorCode = http.StatusText(resp.StatusCode) + } + result.ErrorMessage = apiResp.Message + if result.ErrorMessage == "" { + result.ErrorMessage = apiResp.Operation.Message + } + } + + c.logger.Info("Monetix tokenization response", + zap.String("request_id", req.General.PaymentID), + zap.Bool("accepted", result.Accepted), + zap.Int("status_code", resp.StatusCode), + zap.String("provider_request_id", result.ProviderRequestID), + zap.String("error_code", result.ErrorCode), + zap.String("error_message", result.ErrorMessage), + ) + + return result, nil +} + +func (c *Client) send(ctx context.Context, req any, path string, dispatchLog func(), responseLog func(*CardPayoutSendResult)) (*CardPayoutSendResult, error) { + if ctx == nil { + ctx = context.Background() + } + if c == nil { + return nil, merrors.Internal("monetix client not initialised") + } + if strings.TrimSpace(c.cfg.SecretKey) == "" { + return nil, merrors.Internal("monetix secret key not configured") + } + if strings.TrimSpace(c.cfg.BaseURL) == "" { + return nil, merrors.Internal("monetix base url not configured") + } + + setSignature, err := clearSignature(req) + if err != nil { + return nil, err + } + signature, err := signPayload(req, c.cfg.SecretKey) + if err != nil { + return nil, merrors.Internal("failed to sign request: " + err.Error()) + } + setSignature(signature) + + payload, err := json.Marshal(req) + if err != nil { + return nil, merrors.Internal("failed to marshal request payload: " + err.Error()) + } + + url := strings.TrimRight(c.cfg.BaseURL, "/") + path + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, merrors.Internal("failed to build request: " + err.Error()) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + if dispatchLog != nil { + dispatchLog() + } + + start := time.Now() + resp, err := c.client.Do(httpReq) + duration := time.Since(start) + if err != nil { + observeRequest(outcomeNetworkError, duration) + return nil, merrors.Internal("monetix request failed: " + err.Error()) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + outcome := outcomeAccepted + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + outcome = outcomeHTTPError + } + observeRequest(outcome, duration) + + result := &CardPayoutSendResult{ + Accepted: resp.StatusCode >= 200 && resp.StatusCode < 300, + StatusCode: resp.StatusCode, + } + + var apiResp APIResponse + if len(body) > 0 { + if err := json.Unmarshal(body, &apiResp); err != nil { + c.logger.Warn("failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err)) + } + } + + if apiResp.Operation.RequestID != "" { + result.ProviderRequestID = apiResp.Operation.RequestID + } else if apiResp.RequestID != "" { + result.ProviderRequestID = apiResp.RequestID + } + + if !result.Accepted { + result.ErrorCode = apiResp.Code + if result.ErrorCode == "" { + result.ErrorCode = http.StatusText(resp.StatusCode) + } + result.ErrorMessage = apiResp.Message + if result.ErrorMessage == "" { + result.ErrorMessage = apiResp.Operation.Message + } + } + + if responseLog != nil { + responseLog(result) + } + + return result, nil +} + +func clearSignature(req any) (func(string), error) { + switch r := req.(type) { + case *CardPayoutRequest: + r.General.Signature = "" + return func(sig string) { r.General.Signature = sig }, nil + case *CardTokenPayoutRequest: + r.General.Signature = "" + return func(sig string) { r.General.Signature = sig }, nil + case *CardTokenizeRequest: + r.General.Signature = "" + return func(sig string) { r.General.Signature = sig }, nil + default: + return nil, merrors.Internal("unsupported monetix payload type for signing") + } +} diff --git a/api/gateway/mntx/main.go b/api/gateway/mntx/main.go new file mode 100644 index 0000000..4c9b143 --- /dev/null +++ b/api/gateway/mntx/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/gateway/mntx/internal/appversion" + si "github.com/tech/sendico/gateway/mntx/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("mntx_gateway", appversion.Create(), factory) +} diff --git a/api/pkg/model/card.go b/api/pkg/model/card.go index 9019234..500c334 100644 --- a/api/pkg/model/card.go +++ b/api/pkg/model/card.go @@ -11,6 +11,10 @@ type CardPaymentData struct { Pan string `bson:"pan" json:"pan"` FirstName string `bson:"firstName" json:"firstName"` LastName string `bson:"lastName" json:"lastName"` + ExpMonth string `bson:"expMonth" json:"expMonth"` + ExpYear string `bson:"expYear" json:"expYear"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` } func (m *PaymentMethod) AsCard() (*CardPaymentData, error) { diff --git a/api/pkg/model/crypto_address.go b/api/pkg/model/cryptoaddress.go similarity index 100% rename from api/pkg/model/crypto_address.go rename to api/pkg/model/cryptoaddress.go diff --git a/api/pkg/model/ctoken.go b/api/pkg/model/ctoken.go new file mode 100644 index 0000000..89b0adb --- /dev/null +++ b/api/pkg/model/ctoken.go @@ -0,0 +1,32 @@ +package model + +import ( + "fmt" + + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/bson" +) + +// TokenPaymentData represents a network or gateway-issued card token. +type TokenPaymentData struct { + Token string `bson:"token" json:"token"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + Last4 string `bson:"last4,omitempty" json:"last4,omitempty"` + ExpMonth string `bson:"expMonth,omitempty" json:"expMonth,omitempty"` + ExpYear string `bson:"expYear,omitempty" json:"expYear,omitempty"` + CardholderName string `bson:"cardholderName,omitempty" json:"cardholderName,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` +} + +func (m *PaymentMethod) AsCardToken() (*TokenPaymentData, error) { + if m.Type != PaymentTypeCardToken { + return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cardToken", m.Type), "type") + } + + var d TokenPaymentData + if err := bson.Unmarshal(m.Data, &d); err != nil { + return nil, err + } + + return &d, nil +} diff --git a/api/pkg/model/payment.go b/api/pkg/model/payment.go index aa65c91..14b637d 100644 --- a/api/pkg/model/payment.go +++ b/api/pkg/model/payment.go @@ -14,6 +14,7 @@ type PaymentType int const ( PaymentTypeIban PaymentType = iota PaymentTypeCard + PaymentTypeCardToken PaymentTypeBankAccount PaymentTypeWallet PaymentTypeCryptoAddress @@ -22,6 +23,7 @@ const ( var paymentTypeToString = map[PaymentType]string{ PaymentTypeIban: "iban", PaymentTypeCard: "card", + PaymentTypeCardToken: "cardToken", PaymentTypeBankAccount: "bankAccount", PaymentTypeWallet: "wallet", PaymentTypeCryptoAddress: "cryptoAddress", @@ -30,6 +32,7 @@ var paymentTypeToString = map[PaymentType]string{ var paymentTypeFromString = map[string]PaymentType{ "iban": PaymentTypeIban, "card": PaymentTypeCard, + "cardToken": PaymentTypeCardToken, "bankAccount": PaymentTypeBankAccount, "wallet": PaymentTypeWallet, "cryptoAddress": PaymentTypeCryptoAddress, diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index da9f5aa..d5f8293 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -12,6 +12,7 @@ const ( Changes Type = "changes" // Tracks changes made to resources Clients Type = "clients" // Represents client information ChainGateway Type = "chain_gateway" // Represents chain gateway microservice + MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice FXOracle Type = "fx_oracle" // Represents FX oracle microservice FeePlans Type = "fee_plans" // Represents fee plans microservice FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources @@ -49,7 +50,7 @@ const ( func StringToSType(s string) (Type, error) { switch Type(s) { case Accounts, Confirmations, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, ChainWalletBalances, - ChainTransfers, ChainDeposits, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger, + ChainTransfers, ChainDeposits, MntxGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger, LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, Organizations, Payments, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, RefreshTokens, Roles, Storage, Tenants, Workflows: diff --git a/api/proto/gateway/mntx/v1/mntx.proto b/api/proto/gateway/mntx/v1/mntx.proto new file mode 100644 index 0000000..0fd4609 --- /dev/null +++ b/api/proto/gateway/mntx/v1/mntx.proto @@ -0,0 +1,238 @@ +syntax = "proto3"; + +package mntx.gateway.v1; + +option go_package = "github.com/tech/sendico/pkg/proto/gateway/mntx/v1;mntxv1"; + +import "google/protobuf/timestamp.proto"; +import "common/money/v1/money.proto"; + +// Status of a payout request handled by Monetix. +enum PayoutStatus { + PAYOUT_STATUS_UNSPECIFIED = 0; + PAYOUT_STATUS_PENDING = 1; + PAYOUT_STATUS_PROCESSED = 2; + PAYOUT_STATUS_FAILED = 3; +} + +// Basic destination data for the payout. +message BankAccount { + string iban = 1; + string bic = 2; + string account_holder = 3; + string country = 4; +} + +// Card destination for payouts (PAN-based or tokenized). +message CardDestination { + oneof card { + string pan = 1; // raw primary account number + string token = 2; // network or gateway-issued token + } + string cardholder_name = 3; + string exp_month = 4; + string exp_year = 5; + string country = 6; +} + +// Wrapper allowing multiple payout destination types. +message PayoutDestination { + oneof destination { + BankAccount bank_account = 1; + CardDestination card = 2; + } +} + +message Payout { + string payout_ref = 1; + string idempotency_key = 2; + string organization_ref = 3; + PayoutDestination destination = 4; + common.money.v1.Money amount = 5; + string description = 6; + map metadata = 7; + PayoutStatus status = 8; + string failure_reason = 9; + google.protobuf.Timestamp created_at = 10; + google.protobuf.Timestamp updated_at = 11; +} + +message SubmitPayoutRequest { + string idempotency_key = 1; + string organization_ref = 2; + PayoutDestination destination = 3; + common.money.v1.Money amount = 4; + string description = 5; + map metadata = 6; + string simulated_failure_reason = 7; // optional trigger to force a failed payout for testing +} + +message SubmitPayoutResponse { + Payout payout = 1; +} + +message GetPayoutRequest { + string payout_ref = 1; +} + +message GetPayoutResponse { + Payout payout = 1; +} + +// Event emitted over messaging when payout status changes. +message PayoutStatusChangedEvent { + Payout payout = 1; +} + +// Request to initiate a Monetix card payout. +message CardPayoutRequest { + string payout_id = 1; // internal payout id, mapped to Monetix payment_id + int64 project_id = 2; // optional override; defaults to configured project id + string customer_id = 3; + string customer_first_name = 4; + string customer_middle_name = 5; + string customer_last_name = 6; + string customer_ip = 7; + string customer_zip = 8; + string customer_country = 9; + string customer_state = 10; + string customer_city = 11; + string customer_address = 12; + int64 amount_minor = 13; // amount in minor units + string currency = 14; // ISO-4217 alpha-3 + string card_pan = 15; + uint32 card_exp_year = 16; + uint32 card_exp_month = 17; + string card_holder = 18; + map metadata = 30; +} + +// Persisted payout state for retrieval and status updates. +message CardPayoutState { + string payout_id = 1; + int64 project_id = 2; + string customer_id = 3; + int64 amount_minor = 4; + string currency = 5; + PayoutStatus status = 6; + string provider_code = 7; + string provider_message = 8; + string provider_payment_id = 9; + google.protobuf.Timestamp created_at = 10; + google.protobuf.Timestamp updated_at = 11; +} + +// Response returned immediately after submitting a payout to Monetix. +message CardPayoutResponse { + CardPayoutState payout = 1; + bool accepted = 2; + string provider_request_id = 3; + string error_code = 4; + string error_message = 5; +} + +message GetCardPayoutStatusRequest { + string payout_id = 1; +} + +message GetCardPayoutStatusResponse { + CardPayoutState payout = 1; +} + +// Event emitted when Monetix callback updates payout status. +message CardPayoutStatusChangedEvent { + CardPayoutState payout = 1; +} + +// Request to initiate a token-based card payout. +message CardTokenPayoutRequest { + string payout_id = 1; + int64 project_id = 2; + string customer_id = 3; + + string customer_first_name = 4; + string customer_middle_name = 5; + string customer_last_name = 6; + string customer_ip = 7; + + string customer_zip = 8; + string customer_country = 9; + string customer_state = 10; + string customer_city = 11; + string customer_address = 12; + + int64 amount_minor = 13; + string currency = 14; + + string card_token = 15; + string card_holder = 16; + string masked_pan = 17; + map metadata = 30; +} + +// Response returned immediately after submitting a token payout to Monetix. +message CardTokenPayoutResponse { + CardPayoutState payout = 1; + bool accepted = 2; + string provider_request_id = 3; + string error_code = 4; + string error_message = 5; +} + +// Raw card details used for tokenization. +message CardDetails { + string pan = 1; + uint32 exp_month = 2; + uint32 exp_year = 3; + string card_holder = 4; + string cvv = 5; +} + +// Request to tokenize a card with Monetix. +message CardTokenizeRequest { + string request_id = 1; + int64 project_id = 2; + string customer_id = 3; + + string customer_first_name = 4; + string customer_middle_name = 5; + string customer_last_name = 6; + string customer_ip = 7; + + string customer_zip = 8; + string customer_country = 9; + string customer_state = 10; + string customer_city = 11; + string customer_address = 12; + + string card_pan = 13; + uint32 card_exp_month = 14; + uint32 card_exp_year = 15; + string card_holder = 16; + string card_cvv = 17; + + // Preferred new card container for tokenization requests. + CardDetails card = 30; +} + +// Response from Monetix tokenization. +message CardTokenizeResponse { + string request_id = 1; + bool success = 2; + string token = 3; + string masked_pan = 4; + string expiry_month = 5; + string expiry_year = 6; + string card_brand = 7; + string error_code = 8; + string error_message = 9; +} + +service MntxGatewayService { + rpc SubmitPayout(SubmitPayoutRequest) returns (SubmitPayoutResponse); + rpc GetPayout(GetPayoutRequest) returns (GetPayoutResponse); + rpc CreateCardPayout(CardPayoutRequest) returns (CardPayoutResponse); + rpc GetCardPayoutStatus(GetCardPayoutStatusRequest) returns (GetCardPayoutStatusResponse); + rpc CreateCardTokenPayout(CardTokenPayoutRequest) returns (CardTokenPayoutResponse); + rpc CreateCardToken(CardTokenizeRequest) returns (CardTokenizeResponse); +} diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index 858686f..2a89dc1 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -61,6 +61,16 @@ FX_INGESTOR_COMPOSE_PROJECT=sendico-fx-ingestor FX_INGESTOR_SERVICE_NAME=sendico_fx_ingestor FX_INGESTOR_METRICS_PORT=9102 +# Monetix gateway +MNTX_GATEWAY_DIR=mntx_gateway +MNTX_GATEWAY_COMPOSE_PROJECT=sendico-mntx-gateway +MNTX_GATEWAY_SERVICE_NAME=sendico_mntx_gateway +MNTX_GATEWAY_GRPC_PORT=50075 +MNTX_GATEWAY_METRICS_PORT=9404 +MNTX_GATEWAY_HTTP_PORT=8080 +MONETIX_BASE_URL=https://api.txflux.com + + # FX oracle stack FX_ORACLE_DIR=fx_oracle FX_ORACLE_COMPOSE_PROJECT=sendico-fx-oracle diff --git a/ci/prod/compose/mntx_gateway.dockerfile b/ci/prod/compose/mntx_gateway.dockerfile new file mode 100644 index 0000000..c8ee7c8 --- /dev/null +++ b/ci/prod/compose/mntx_gateway.dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1.7 + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +FROM golang:alpine AS build +ARG APP_VERSION=dev +ARG GIT_REV=unknown +ARG BUILD_BRANCH=unknown +ARG BUILD_DATE=unknown +ARG BUILD_USER=ci +ENV GO111MODULE=on +ENV PATH="/go/bin:${PATH}" +WORKDIR /src +COPY . . +RUN apk add --no-cache bash git build-base protoc protobuf-dev \ + && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ + && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \ + && bash ci/scripts/proto/generate.sh +WORKDIR /src/api/gateway/mntx +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "\ + -s -w \ + -X github.com/tech/sendico/gateway/mntx/internal/appversion.Version=${APP_VERSION} \ + -X github.com/tech/sendico/gateway/mntx/internal/appversion.Revision=${GIT_REV} \ + -X github.com/tech/sendico/gateway/mntx/internal/appversion.Branch=${BUILD_BRANCH} \ + -X github.com/tech/sendico/gateway/mntx/internal/appversion.BuildUser=${BUILD_USER} \ + -X github.com/tech/sendico/gateway/mntx/internal/appversion.BuildDate=${BUILD_DATE}" \ + -o /out/mntx-gateway . + +FROM alpine:latest AS runtime +RUN apk add --no-cache ca-certificates tzdata wget +WORKDIR /app +COPY api/gateway/mntx/config.yml /app/config.yml +COPY api/gateway/mntx/entrypoint.sh /app/entrypoint.sh +COPY --from=build /out/mntx-gateway /app/mntx-gateway +RUN chmod +x /app/entrypoint.sh +EXPOSE 50075 9404 8080 +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["/app/mntx-gateway","--config.file","/app/config.yml"] diff --git a/ci/prod/compose/mntx_gateway.yml b/ci/prod/compose/mntx_gateway.yml new file mode 100644 index 0000000..ac1ffcb --- /dev/null +++ b/ci/prod/compose/mntx_gateway.yml @@ -0,0 +1,40 @@ +# Compose v2 - Monetix Gateway + +x-common-env: &common-env + env_file: + - ../env/.env.runtime + - ../env/.env.version + +networks: + sendico-net: + external: true + name: sendico-net + +services: + sendico_mntx_gateway: + <<: *common-env + container_name: sendico-mntx-gateway + restart: unless-stopped + image: ${REGISTRY_URL}/gateway/mntx:${APP_V} + pull_policy: always + environment: + NATS_URL: ${NATS_URL} + NATS_HOST: ${NATS_HOST} + NATS_PORT: ${NATS_PORT} + NATS_USER: ${NATS_USER} + NATS_PASSWORD: ${NATS_PASSWORD} + MONETIX_PROJECT_ID: ${MONETIX_PROJECT_ID} + MONETIX_SECRET_KEY: ${MONETIX_SECRET_KEY} + command: ["--config.file", "/app/config.yml"] + ports: + - "0.0.0.0:${MNTX_GATEWAY_GRPC_PORT:-50075}:50075" + - "0.0.0.0:${MNTX_GATEWAY_METRICS_PORT:-9404}:9404" + - "0.0.0.0:${MNTX_GATEWAY_HTTP_PORT:-8080}:8080" + healthcheck: + test: ["CMD-SHELL","wget -qO- http://localhost:9404/health | grep -q '\"status\":\"ok\"'"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - sendico-net diff --git a/ci/prod/scripts/deploy/mntx_gateway.sh b/ci/prod/scripts/deploy/mntx_gateway.sh new file mode 100644 index 0000000..792d826 --- /dev/null +++ b/ci/prod/scripts/deploy/mntx_gateway.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[deploy-mntx-gateway] error at line $LINENO" >&2' ERR + +: "${REMOTE_BASE:?missing REMOTE_BASE}" +: "${SSH_USER:?missing SSH_USER}" +: "${SSH_HOST:?missing SSH_HOST}" +: "${MNTX_GATEWAY_DIR:?missing MNTX_GATEWAY_DIR}" +: "${MNTX_GATEWAY_COMPOSE_PROJECT:?missing MNTX_GATEWAY_COMPOSE_PROJECT}" +: "${MNTX_GATEWAY_SERVICE_NAME:?missing MNTX_GATEWAY_SERVICE_NAME}" + +REMOTE_DIR="${REMOTE_BASE%/}/${MNTX_GATEWAY_DIR}" +REMOTE_TARGET="${SSH_USER}@${SSH_HOST}" +COMPOSE_FILE="mntx_gateway.yml" +SERVICE_NAMES="${MNTX_GATEWAY_SERVICE_NAME}" + +REQUIRED_SECRETS=( + NATS_USER + NATS_PASSWORD + NATS_URL + MONETIX_PROJECT_ID + MONETIX_SECRET_KEY +) + +for var in "${REQUIRED_SECRETS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "missing required secret env: ${var}" >&2 + exit 65 + fi +done + +if [[ ! -s .env.version ]]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +b64enc() { + printf '%s' "$1" | base64 | tr -d '\n' +} + +NATS_USER_B64="$(b64enc "${NATS_USER}")" +NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")" +NATS_URL_B64="$(b64enc "${NATS_URL}")" +MONETIX_PROJECT_ID_B64="$(b64enc "${MONETIX_PROJECT_ID}")" +MONETIX_SECRET_KEY_B64="$(b64enc "${MONETIX_SECRET_KEY}")" + +SSH_OPTS=( + -i /root/.ssh/id_rsa + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -q +) +if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then + SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv) +fi + +RSYNC_FLAGS=(-az --delete) +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete) + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/compose ${REMOTE_DIR}/env" + +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version" + +SERVICES_LINE="${SERVICE_NAMES}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ + REMOTE_DIR="$REMOTE_DIR" \ + COMPOSE_FILE="$COMPOSE_FILE" \ + COMPOSE_PROJECT="$MNTX_GATEWAY_COMPOSE_PROJECT" \ + SERVICES_LINE="$SERVICES_LINE" \ + NATS_USER_B64="$NATS_USER_B64" \ + NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \ + NATS_URL_B64="$NATS_URL_B64" \ + MONETIX_PROJECT_ID_B64="$MONETIX_PROJECT_ID_B64" \ + MONETIX_SECRET_KEY_B64="$MONETIX_SECRET_KEY_B64" \ + bash -s <<'EOSSH' +set -euo pipefail +cd "${REMOTE_DIR}/compose" +set -a +. ../env/.env.runtime +load_kv_file() { + local file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + local key="${line%%=*}" + local value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + fi + done <"$file" +} +load_kv_file ../env/.env.version +set +a + +if base64 -d >/dev/null 2>&1 <<<'AA=='; then + BASE64_DECODE_FLAG='-d' +else + BASE64_DECODE_FLAG='--decode' +fi + +decode_b64() { + val="$1" + if [[ -z "$val" ]]; then + printf '' + return + fi + printf '%s' "$val" | base64 "${BASE64_DECODE_FLAG}" +} + +NATS_USER="$(decode_b64 "$NATS_USER_B64")" +NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")" +NATS_URL="$(decode_b64 "$NATS_URL_B64")" +MONETIX_PROJECT_ID="$(decode_b64 "$MONETIX_PROJECT_ID_B64")" +MONETIX_SECRET_KEY="$(decode_b64 "$MONETIX_SECRET_KEY_B64")" + +export NATS_USER NATS_PASSWORD NATS_URL +export MONETIX_PROJECT_ID MONETIX_SECRET_KEY + +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" +export COMPOSE_PROJECT_NAME +read -r -a SERVICES <<<"${SERVICES_LINE}" + +pull_cmd=(docker compose -f "$COMPOSE_FILE" pull) +up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans) +ps_cmd=(docker compose -f "$COMPOSE_FILE" ps) +if [[ "${#SERVICES[@]}" -gt 0 ]]; then + pull_cmd+=("${SERVICES[@]}") + up_cmd+=("${SERVICES[@]}") + ps_cmd+=("${SERVICES[@]}") +fi + +"${pull_cmd[@]}" +"${up_cmd[@]}" +"${ps_cmd[@]}" + +date -Is > .last_deploy +logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}" +EOSSH diff --git a/ci/scripts/mntx/build-image.sh b/ci/scripts/mntx/build-image.sh new file mode 100644 index 0000000..cacf96b --- /dev/null +++ b/ci/scripts/mntx/build-image.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +MNTX_GATEWAY_ENV_NAME="${MNTX_GATEWAY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${MNTX_GATEWAY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[mntx-gateway-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}" +APP_V="${APP_V:?missing APP_V}" +MNTX_GATEWAY_DOCKERFILE="${MNTX_GATEWAY_DOCKERFILE:?missing MNTX_GATEWAY_DOCKERFILE}" +MNTX_GATEWAY_IMAGE_PATH="${MNTX_GATEWAY_IMAGE_PATH:?missing MNTX_GATEWAY_IMAGE_PATH}" + +REGISTRY_HOST="${REGISTRY_URL#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_USER="$(cat secrets/REGISTRY_USER)" +REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)" +: "${REGISTRY_USER:?missing registry user}" +: "${REGISTRY_PASSWORD:?missing registry password}" + +mkdir -p /kaniko/.docker +AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" +cat </kaniko/.docker/config.json +{ + "auths": { + "https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" } + } +} +EOF + +BUILD_CONTEXT="${MNTX_GATEWAY_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + +/kaniko/executor \ + --context "${BUILD_CONTEXT}" \ + --dockerfile "${MNTX_GATEWAY_DOCKERFILE}" \ + --destination "${REGISTRY_URL}/${MNTX_GATEWAY_IMAGE_PATH}:${APP_V}" \ + --build-arg APP_VERSION="${APP_V}" \ + --build-arg GIT_REV="${GIT_REV}" \ + --build-arg BUILD_BRANCH="${BUILD_BRANCH}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg BUILD_USER="${BUILD_USER}" \ + --single-snapshot diff --git a/ci/scripts/mntx/deploy.sh b/ci/scripts/mntx/deploy.sh new file mode 100644 index 0000000..68d1c0d --- /dev/null +++ b/ci/scripts/mntx/deploy.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[mntx-gateway-deploy] error at line $LINENO" >&2' ERR + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + fi + done <"$file" +} + +MNTX_GATEWAY_ENV_NAME="${MNTX_GATEWAY_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${MNTX_GATEWAY_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[mntx-gateway-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi +if [ ! -f ./.env.version ]; then + echo "[mntx-gateway-deploy] .env.version is missing; run version step first" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +MNTX_GATEWAY_MONETIX_SECRET_PATH="${MNTX_GATEWAY_MONETIX_SECRET_PATH:-sendico/gateway/monetix}" +MNTX_GATEWAY_NATS_SECRET_PATH="${MNTX_GATEWAY_NATS_SECRET_PATH:-sendico/nats}" +: "${NATS_HOST:?missing NATS_HOST}" +: "${NATS_PORT:?missing NATS_PORT}" + +export MONETIX_PROJECT_ID="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_MONETIX_SECRET_PATH}" project_id)" +export MONETIX_SECRET_KEY="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_MONETIX_SECRET_PATH}" secret_key)" + +export NATS_USER="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_NATS_SECRET_PATH}" user)" +export NATS_PASSWORD="$(./ci/vlt kv_get kv "${MNTX_GATEWAY_NATS_SECRET_PATH}" password)" +export NATS_URL="nats://${NATS_USER}:${NATS_PASSWORD}@${NATS_HOST}:${NATS_PORT}" + +bash ci/prod/scripts/bootstrap/network.sh +bash ci/prod/scripts/deploy/mntx_gateway.sh diff --git a/ci/scripts/proto/generate.sh b/ci/scripts/proto/generate.sh index e40ceae..7a3e19a 100755 --- a/ci/scripts/proto/generate.sh +++ b/ci/scripts/proto/generate.sh @@ -110,6 +110,12 @@ if [ -f "${PROTO_DIR}/gateway/chain/v1/chain.proto" ]; then generate_go_with_grpc "${PROTO_DIR}/gateway/chain/v1/chain.proto" fi +if [ -f "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" ]; then + info "Compiling Monetix gateway protos" + clean_pb_files "./pkg/proto/gateway/mntx" + generate_go_with_grpc "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" +fi + if [ -f "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" ]; then info "Compiling payments orchestrator protos" clean_pb_files "./pkg/proto/payments/orchestrator" diff --git a/frontend/pweb/caddy/Caddyfile b/frontend/pweb/caddy/Caddyfile index 1df6dbd..36b4ecf 100644 --- a/frontend/pweb/caddy/Caddyfile +++ b/frontend/pweb/caddy/Caddyfile @@ -35,6 +35,37 @@ header Expires "0" } + # Monetix payout callbacks -> mntx gateway (IP allow-listed) + @monetixSuccess { + path /gateway/m/success* + method POST + remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244 + } + handle @monetixSuccess { + rewrite * /monetix/callback + reverse_proxy sendico_mntx_gateway:8080 + header Cache-Control "no-cache, no-store, must-revalidate" + } + + @monetixFail { + path /gateway/m/fail* + method POST + remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244 + } + handle @monetixFail { + rewrite * /monetix/callback + reverse_proxy sendico_mntx_gateway:8080 + header Cache-Control "no-cache, no-store, must-revalidate" + } + + @monetixCallbackPath { + path /gateway/m/success* /gateway/m/fail* + method POST + } + handle @monetixCallbackPath { + respond "forbidden" 403 + } + ######################################## # Static assets with tailored caching ######################################## diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index 1f71ac0..fd9e4e1 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 3.2.936+14 +version: 2.1.0+14 environment: sdk: ^3.8.1 diff --git a/version b/version index b621109..50aea0e 100644 --- a/version +++ b/version @@ -1 +1 @@ -2.0.857 \ No newline at end of file +2.1.0 \ No newline at end of file From 43c4866ad7dd67e48c805b7ca9b1d52fa6ad649b Mon Sep 17 00:00:00 2001 From: Stephan D Date: Thu, 4 Dec 2025 21:50:49 +0100 Subject: [PATCH 2/2] +logging --- .../service/gateway/card_processor.go | 41 +++++++++++++++++++ .../mntx/internal/service/monetix/sender.go | 1 + 2 files changed, 42 insertions(+) diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index 7cc10db..8640ede 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -46,11 +46,17 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout return nil, merrors.Internal("card payout processor not initialised") } if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { + p.logger.Warn("monetix configuration is incomplete for payout submission") return nil, merrors.Internal("monetix configuration is incomplete") } req = sanitizeCardPayoutRequest(req) if err := validateCardPayoutRequest(req, p.config); err != nil { + p.logger.Warn("card payout validation failed", + zap.String("payout_id", req.GetPayoutId()), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) return nil, err } @@ -59,6 +65,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout projectID = p.config.ProjectID } if projectID == 0 { + p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId())) return nil, merrors.Internal("monetix project_id is not configured") } @@ -88,6 +95,11 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout state.ProviderMessage = err.Error() state.UpdatedAt = timestamppb.New(p.clock.Now()) p.store.Save(state) + p.logger.Warn("monetix payout submission failed", + zap.String("payout_id", req.GetPayoutId()), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) return nil, err } @@ -118,11 +130,17 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT return nil, merrors.Internal("card payout processor not initialised") } if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { + p.logger.Warn("monetix configuration is incomplete for token payout submission") return nil, merrors.Internal("monetix configuration is incomplete") } req = sanitizeCardTokenPayoutRequest(req) if err := validateCardTokenPayoutRequest(req, p.config); err != nil { + p.logger.Warn("card token payout validation failed", + zap.String("payout_id", req.GetPayoutId()), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) return nil, err } @@ -131,6 +149,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT projectID = p.config.ProjectID } if projectID == 0 { + p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId())) return nil, merrors.Internal("monetix project_id is not configured") } @@ -160,6 +179,11 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT state.ProviderMessage = err.Error() state.UpdatedAt = timestamppb.New(p.clock.Now()) p.store.Save(state) + p.logger.Warn("monetix token payout submission failed", + zap.String("payout_id", req.GetPayoutId()), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) return nil, err } @@ -191,6 +215,11 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke } cardInput, err := validateCardTokenizeRequest(req, p.config) if err != nil { + p.logger.Warn("card tokenization validation failed", + zap.String("request_id", req.GetRequestId()), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) return nil, err } @@ -199,6 +228,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke projectID = p.config.ProjectID } if projectID == 0 { + p.logger.Warn("monetix project_id is not configured", zap.String("request_id", req.GetRequestId())) return nil, merrors.Internal("monetix project_id is not configured") } @@ -208,6 +238,11 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke apiReq := buildCardTokenizeRequest(projectID, req, cardInput) result, err := client.CreateCardTokenization(ctx, apiReq) if err != nil { + p.logger.Warn("monetix tokenization request failed", + zap.String("request_id", req.GetRequestId()), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) return nil, err } @@ -233,11 +268,13 @@ func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv id := strings.TrimSpace(payoutID) if id == "" { + p.logger.Warn("payout status requested with empty payout_id") return nil, merrors.InvalidArgument("payout_id is required", "payout_id") } state, ok := p.store.Get(id) if !ok || state == nil { + p.logger.Warn("payout status not found", zap.String("payout_id", id)) return nil, merrors.NoData("payout not found") } return state, nil @@ -248,18 +285,22 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") } if len(payload) == 0 { + p.logger.Warn("received empty Monetix callback payload") return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty") } if strings.TrimSpace(p.config.SecretKey) == "" { + p.logger.Warn("monetix secret key is not configured; cannot verify callback") return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured") } var cb monetixCallback if err := json.Unmarshal(payload, &cb); err != nil { + p.logger.Warn("failed to unmarshal Monetix callback", zap.Error(err)) return http.StatusBadRequest, err } if strings.TrimSpace(cb.Signature) == "" { + p.logger.Warn("Monetix callback signature is missing", zap.String("payout_id", cb.Payment.ID)) return http.StatusBadRequest, merrors.InvalidArgument("signature is missing") } if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil { diff --git a/api/gateway/mntx/internal/service/monetix/sender.go b/api/gateway/mntx/internal/service/monetix/sender.go index 3757716..e9ad47c 100644 --- a/api/gateway/mntx/internal/service/monetix/sender.go +++ b/api/gateway/mntx/internal/service/monetix/sender.go @@ -111,6 +111,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest) duration := time.Since(start) if err != nil { observeRequest(outcomeNetworkError, duration) + c.logger.Warn("monetix tokenization request failed", zap.Error(err)) return nil, merrors.Internal("monetix tokenization request failed: " + err.Error()) } defer resp.Body.Close()