monetix gateway
This commit is contained in:
74
.woodpecker/mntx_gateway.yml
Normal file
74
.woodpecker/mntx_gateway.yml
Normal file
@@ -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
|
||||||
1
api/gateway/mntx/.gitignore
vendored
Normal file
1
api/gateway/mntx/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/mntx-gateway
|
||||||
46
api/gateway/mntx/README.md
Normal file
46
api/gateway/mntx/README.md
Normal file
@@ -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": <int>, "payment_id": "<payout_id>", "signature": "<hmac>" },
|
||||||
|
"customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? },
|
||||||
|
"payment": { amount: <minor_units>, currency: "<ISO-4217>" },
|
||||||
|
"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.
|
||||||
40
api/gateway/mntx/config.yml
Normal file
40
api/gateway/mntx/config.yml
Normal file
@@ -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
|
||||||
4
api/gateway/mntx/entrypoint.sh
Executable file
4
api/gateway/mntx/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
exec /app/mntx-gateway "$@"
|
||||||
54
api/gateway/mntx/go.mod
Normal file
54
api/gateway/mntx/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
227
api/gateway/mntx/go.sum
Normal file
227
api/gateway/mntx/go.sum
Normal file
@@ -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=
|
||||||
27
api/gateway/mntx/internal/appversion/version.go
Normal file
27
api/gateway/mntx/internal/appversion/version.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
345
api/gateway/mntx/internal/server/internal/serverimp.go
Normal file
345
api/gateway/mntx/internal/server/internal/serverimp.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
12
api/gateway/mntx/internal/server/server.go
Normal file
12
api/gateway/mntx/internal/server/server.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
134
api/gateway/mntx/internal/service/gateway/callback.go
Normal file
134
api/gateway/mntx/internal/service/gateway/callback.go
Normal file
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
311
api/gateway/mntx/internal/service/gateway/card_processor.go
Normal file
311
api/gateway/mntx/internal/service/gateway/card_processor.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
174
api/gateway/mntx/internal/service/gateway/metrics.go
Normal file
174
api/gateway/mntx/internal/service/gateway/metrics.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
44
api/gateway/mntx/internal/service/gateway/options.go
Normal file
44
api/gateway/mntx/internal/service/gateway/options.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
api/gateway/mntx/internal/service/gateway/payout_get.go
Normal file
30
api/gateway/mntx/internal/service/gateway/payout_get.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
46
api/gateway/mntx/internal/service/gateway/payout_store.go
Normal file
46
api/gateway/mntx/internal/service/gateway/payout_store.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
131
api/gateway/mntx/internal/service/gateway/payout_submit.go
Normal file
131
api/gateway/mntx/internal/service/gateway/payout_submit.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
106
api/gateway/mntx/internal/service/gateway/payout_validation.go
Normal file
106
api/gateway/mntx/internal/service/gateway/payout_validation.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
119
api/gateway/mntx/internal/service/gateway/service.go
Normal file
119
api/gateway/mntx/internal/service/gateway/service.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
66
api/gateway/mntx/internal/service/monetix/client.go
Normal file
66
api/gateway/mntx/internal/service/monetix/client.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
78
api/gateway/mntx/internal/service/monetix/config.go
Normal file
78
api/gateway/mntx/internal/service/monetix/config.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
21
api/gateway/mntx/internal/service/monetix/mask.go
Normal file
21
api/gateway/mntx/internal/service/monetix/mask.go
Normal file
@@ -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:]
|
||||||
|
}
|
||||||
71
api/gateway/mntx/internal/service/monetix/metrics.go
Normal file
71
api/gateway/mntx/internal/service/monetix/metrics.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
96
api/gateway/mntx/internal/service/monetix/payloads.go
Normal file
96
api/gateway/mntx/internal/service/monetix/payloads.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
289
api/gateway/mntx/internal/service/monetix/sender.go
Normal file
289
api/gateway/mntx/internal/service/monetix/sender.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
17
api/gateway/mntx/main.go
Normal file
17
api/gateway/mntx/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ type CardPaymentData struct {
|
|||||||
Pan string `bson:"pan" json:"pan"`
|
Pan string `bson:"pan" json:"pan"`
|
||||||
FirstName string `bson:"firstName" json:"firstName"`
|
FirstName string `bson:"firstName" json:"firstName"`
|
||||||
LastName string `bson:"lastName" json:"lastName"`
|
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) {
|
func (m *PaymentMethod) AsCard() (*CardPaymentData, error) {
|
||||||
|
|||||||
32
api/pkg/model/ctoken.go
Normal file
32
api/pkg/model/ctoken.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type PaymentType int
|
|||||||
const (
|
const (
|
||||||
PaymentTypeIban PaymentType = iota
|
PaymentTypeIban PaymentType = iota
|
||||||
PaymentTypeCard
|
PaymentTypeCard
|
||||||
|
PaymentTypeCardToken
|
||||||
PaymentTypeBankAccount
|
PaymentTypeBankAccount
|
||||||
PaymentTypeWallet
|
PaymentTypeWallet
|
||||||
PaymentTypeCryptoAddress
|
PaymentTypeCryptoAddress
|
||||||
@@ -22,6 +23,7 @@ const (
|
|||||||
var paymentTypeToString = map[PaymentType]string{
|
var paymentTypeToString = map[PaymentType]string{
|
||||||
PaymentTypeIban: "iban",
|
PaymentTypeIban: "iban",
|
||||||
PaymentTypeCard: "card",
|
PaymentTypeCard: "card",
|
||||||
|
PaymentTypeCardToken: "cardToken",
|
||||||
PaymentTypeBankAccount: "bankAccount",
|
PaymentTypeBankAccount: "bankAccount",
|
||||||
PaymentTypeWallet: "wallet",
|
PaymentTypeWallet: "wallet",
|
||||||
PaymentTypeCryptoAddress: "cryptoAddress",
|
PaymentTypeCryptoAddress: "cryptoAddress",
|
||||||
@@ -30,6 +32,7 @@ var paymentTypeToString = map[PaymentType]string{
|
|||||||
var paymentTypeFromString = map[string]PaymentType{
|
var paymentTypeFromString = map[string]PaymentType{
|
||||||
"iban": PaymentTypeIban,
|
"iban": PaymentTypeIban,
|
||||||
"card": PaymentTypeCard,
|
"card": PaymentTypeCard,
|
||||||
|
"cardToken": PaymentTypeCardToken,
|
||||||
"bankAccount": PaymentTypeBankAccount,
|
"bankAccount": PaymentTypeBankAccount,
|
||||||
"wallet": PaymentTypeWallet,
|
"wallet": PaymentTypeWallet,
|
||||||
"cryptoAddress": PaymentTypeCryptoAddress,
|
"cryptoAddress": PaymentTypeCryptoAddress,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
Changes Type = "changes" // Tracks changes made to resources
|
Changes Type = "changes" // Tracks changes made to resources
|
||||||
Clients Type = "clients" // Represents client information
|
Clients Type = "clients" // Represents client information
|
||||||
ChainGateway Type = "chain_gateway" // Represents chain gateway microservice
|
ChainGateway Type = "chain_gateway" // Represents chain gateway microservice
|
||||||
|
MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice
|
||||||
FXOracle Type = "fx_oracle" // Represents FX oracle microservice
|
FXOracle Type = "fx_oracle" // Represents FX oracle microservice
|
||||||
FeePlans Type = "fee_plans" // Represents fee plans microservice
|
FeePlans Type = "fee_plans" // Represents fee plans microservice
|
||||||
FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources
|
FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources
|
||||||
@@ -49,7 +50,7 @@ const (
|
|||||||
func StringToSType(s string) (Type, error) {
|
func StringToSType(s string) (Type, error) {
|
||||||
switch Type(s) {
|
switch Type(s) {
|
||||||
case Accounts, Confirmations, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, ChainWalletBalances,
|
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,
|
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
|
||||||
Organizations, Payments, PaymentOrchestrator, Permissions, Policies, PolicyAssignements,
|
Organizations, Payments, PaymentOrchestrator, Permissions, Policies, PolicyAssignements,
|
||||||
RefreshTokens, Roles, Storage, Tenants, Workflows:
|
RefreshTokens, Roles, Storage, Tenants, Workflows:
|
||||||
|
|||||||
238
api/proto/gateway/mntx/v1/mntx.proto
Normal file
238
api/proto/gateway/mntx/v1/mntx.proto
Normal file
@@ -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<string, string> 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<string, string> 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<string, string> 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<string, string> 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);
|
||||||
|
}
|
||||||
@@ -61,6 +61,16 @@ FX_INGESTOR_COMPOSE_PROJECT=sendico-fx-ingestor
|
|||||||
FX_INGESTOR_SERVICE_NAME=sendico_fx_ingestor
|
FX_INGESTOR_SERVICE_NAME=sendico_fx_ingestor
|
||||||
FX_INGESTOR_METRICS_PORT=9102
|
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 stack
|
||||||
FX_ORACLE_DIR=fx_oracle
|
FX_ORACLE_DIR=fx_oracle
|
||||||
FX_ORACLE_COMPOSE_PROJECT=sendico-fx-oracle
|
FX_ORACLE_COMPOSE_PROJECT=sendico-fx-oracle
|
||||||
|
|||||||
42
ci/prod/compose/mntx_gateway.dockerfile
Normal file
42
ci/prod/compose/mntx_gateway.dockerfile
Normal file
@@ -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"]
|
||||||
40
ci/prod/compose/mntx_gateway.yml
Normal file
40
ci/prod/compose/mntx_gateway.yml
Normal file
@@ -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
|
||||||
149
ci/prod/scripts/deploy/mntx_gateway.sh
Normal file
149
ci/prod/scripts/deploy/mntx_gateway.sh
Normal file
@@ -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
|
||||||
85
ci/scripts/mntx/build-image.sh
Normal file
85
ci/scripts/mntx/build-image.sh
Normal file
@@ -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 <<EOF >/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
|
||||||
64
ci/scripts/mntx/deploy.sh
Normal file
64
ci/scripts/mntx/deploy.sh
Normal file
@@ -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
|
||||||
@@ -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"
|
generate_go_with_grpc "${PROTO_DIR}/gateway/chain/v1/chain.proto"
|
||||||
fi
|
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
|
if [ -f "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" ]; then
|
||||||
info "Compiling payments orchestrator protos"
|
info "Compiling payments orchestrator protos"
|
||||||
clean_pb_files "./pkg/proto/payments/orchestrator"
|
clean_pb_files "./pkg/proto/payments/orchestrator"
|
||||||
|
|||||||
@@ -35,6 +35,37 @@
|
|||||||
header Expires "0"
|
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
|
# Static assets with tailored caching
|
||||||
########################################
|
########################################
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user