Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aceb2f441 | ||
|
|
281b3834d3 | ||
|
|
2b0ada1541 | ||
|
|
b67d199427 | ||
|
|
97b16542c2 | ||
|
|
39c04beb21 | ||
|
|
d6a3a0cc5b |
@@ -1,18 +1,2 @@
|
|||||||
ci/dev/mongo.key*
|
ci/dev/mongo.key*
|
||||||
|
|
||||||
# VCS / editor files
|
|
||||||
.git
|
|
||||||
.vscode
|
|
||||||
.DS_Store
|
|
||||||
**/.DS_Store
|
|
||||||
|
|
||||||
# Local caches and temporary artifacts
|
|
||||||
.cache
|
|
||||||
.gocache
|
|
||||||
**/.gocache
|
|
||||||
**/tmp/
|
|
||||||
**/tmp/**
|
|
||||||
|
|
||||||
# Frontend local build artifacts (rebuilt in Docker)
|
|
||||||
frontend/**/.dart_tool
|
|
||||||
frontend/**/build
|
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -20,7 +20,7 @@ BACKEND_SERVICES := \
|
|||||||
dev-tron-gateway-vault-agent \
|
dev-tron-gateway-vault-agent \
|
||||||
dev-tron-gateway \
|
dev-tron-gateway \
|
||||||
dev-aurora-gateway \
|
dev-aurora-gateway \
|
||||||
dev-chsettle-gateway \
|
dev-tgsettle-gateway \
|
||||||
dev-notification \
|
dev-notification \
|
||||||
dev-callbacks-vault-agent \
|
dev-callbacks-vault-agent \
|
||||||
dev-callbacks \
|
dev-callbacks \
|
||||||
@@ -61,7 +61,7 @@ help:
|
|||||||
@echo " make build-core Build core services (discovery, ledger, fees, documents)"
|
@echo " make build-core Build core services (discovery, ledger, fees, documents)"
|
||||||
@echo " make build-fx Build FX services (oracle, ingestor)"
|
@echo " make build-fx Build FX services (oracle, ingestor)"
|
||||||
@echo " make build-payments Build payment orchestrator"
|
@echo " make build-payments Build payment orchestrator"
|
||||||
@echo " make build-gateways Build gateway services (chain, tron, aurora, chsettle)"
|
@echo " make build-gateways Build gateway services (chain, tron, aurora, tgsettle)"
|
||||||
@echo " make build-api Build API services (notification, callbacks, bff)"
|
@echo " make build-api Build API services (notification, callbacks, bff)"
|
||||||
@echo " make build-frontend Build Flutter web frontend"
|
@echo " make build-frontend Build Flutter web frontend"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -247,7 +247,7 @@ services-up:
|
|||||||
dev-chain-gateway \
|
dev-chain-gateway \
|
||||||
dev-tron-gateway \
|
dev-tron-gateway \
|
||||||
dev-aurora-gateway \
|
dev-aurora-gateway \
|
||||||
dev-chsettle-gateway \
|
dev-tgsettle-gateway \
|
||||||
dev-notification \
|
dev-notification \
|
||||||
dev-callbacks \
|
dev-callbacks \
|
||||||
dev-bff \
|
dev-bff \
|
||||||
@@ -292,7 +292,7 @@ list-services:
|
|||||||
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
|
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
|
||||||
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
|
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
|
||||||
@echo " - dev-aurora-gateway :50075, :9405, :8084 (Card Payouts Simulator)"
|
@echo " - dev-aurora-gateway :50075, :9405, :8084 (Card Payouts Simulator)"
|
||||||
@echo " - dev-chsettle-gateway :50080, :9406 (Chimera Settlements Simulator)"
|
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
|
||||||
@echo " - dev-notification :8081 (Notifications)"
|
@echo " - dev-notification :8081 (Notifications)"
|
||||||
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
|
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
|
||||||
@echo " - dev-bff :8080 (Backend for Frontend)"
|
@echo " - dev-bff :8080 (Backend for Frontend)"
|
||||||
@@ -322,7 +322,7 @@ build-payments:
|
|||||||
|
|
||||||
build-gateways:
|
build-gateways:
|
||||||
@echo "$(GREEN)Building gateway services...$(NC)"
|
@echo "$(GREEN)Building gateway services...$(NC)"
|
||||||
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-chsettle-gateway
|
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-tgsettle-gateway
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
@echo "$(GREEN)Building API services...$(NC)"
|
@echo "$(GREEN)Building API services...$(NC)"
|
||||||
|
|||||||
@@ -25,15 +25,14 @@ Financial services platform providing payment orchestration, ledger accounting,
|
|||||||
| Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway |
|
| Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway |
|
||||||
| Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway |
|
| Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway |
|
||||||
| Gateway Aurora | `api/gateway/aurora/` | Card payouts simulator |
|
| Gateway Aurora | `api/gateway/aurora/` | Card payouts simulator |
|
||||||
| Gateway ChimeraSettle | `api/gateway/chsettle/` | Dummy settlement simulator (fast/slow/success/fail/stuck) |
|
|
||||||
| Gateway MNTX | `api/gateway/mntx/` | Card payouts |
|
| Gateway MNTX | `api/gateway/mntx/` | Card payouts |
|
||||||
| Gateway TGSettle (legacy) | `api/gateway/tgsettle/` | Legacy Telegram settlement gateway (not used in dev compose) |
|
| Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
|
||||||
| Notification | `api/notification/` | Notifications |
|
| Notification | `api/notification/` | Notifications |
|
||||||
| BFF | `api/edge/bff/` | Backend for frontend |
|
| BFF | `api/edge/bff/` | Backend for frontend |
|
||||||
| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery |
|
| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery |
|
||||||
| Frontend | `frontend/pweb/` | Flutter web UI |
|
| Frontend | `frontend/pweb/` | Flutter web UI |
|
||||||
|
|
||||||
Gateway note: current dev compose workflows (`make services-up`, `make build-gateways`) use (`chain`, `tron`, `aurora`, `chsettle`). ChimeraSettle is the settlement simulator for test flows; it supports deterministic behavior routing via explicit scenario override and amount buckets. TGSettle remains in-repo as legacy code and is not started by default in dev compose.
|
Gateway note: current dev compose workflows (`make services-up`, `make build-gateways`) use Aurora for card-payout flows (`chain`, `tron`, `aurora`, `tgsettle`). The MNTX gateway codebase is retained separately for Monetix-specific integration.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ make list-services # Show service names, ports, and descriptions
|
|||||||
make build-core # discovery, ledger, fees, documents
|
make build-core # discovery, ledger, fees, documents
|
||||||
make build-fx # oracle, ingestor
|
make build-fx # oracle, ingestor
|
||||||
make build-payments # orchestrator, quotation, methods
|
make build-payments # orchestrator, quotation, methods
|
||||||
make build-gateways # chain, tron, aurora, chsettle
|
make build-gateways # chain, tron, aurora, tgsettle
|
||||||
make build-api # notification, callbacks, bff
|
make build-api # notification, callbacks, bff
|
||||||
make build-frontend # Flutter web UI
|
make build-frontend # Flutter web UI
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ database:
|
|||||||
|
|
||||||
documents:
|
documents:
|
||||||
issuer:
|
issuer:
|
||||||
|
legal_name: "Sendico Ltd"
|
||||||
|
legal_address: "12 Market Street, London, UK"
|
||||||
logo_path: "assets/logo.png"
|
logo_path: "assets/logo.png"
|
||||||
templates:
|
templates:
|
||||||
acceptance_path: "templates/acceptance.tpl"
|
acceptance_path: "templates/acceptance.tpl"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ database:
|
|||||||
|
|
||||||
documents:
|
documents:
|
||||||
issuer:
|
issuer:
|
||||||
|
legal_name: "Sendico Ltd"
|
||||||
|
legal_address: "12 Market Street, London, UK"
|
||||||
logo_path: "/app/assets/logo.png"
|
logo_path: "/app/assets/logo.png"
|
||||||
templates:
|
templates:
|
||||||
acceptance_path: "/app/templates/acceptance.tpl"
|
acceptance_path: "/app/templates/acceptance.tpl"
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
|
||||||
github.com/jung-kurt/gofpdf v1.16.2
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJ
|
|||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
||||||
@@ -30,8 +30,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7su
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||||
@@ -260,8 +260,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package content
|
|
||||||
|
|
||||||
// Issuer details are intentionally centralized to avoid document text drift.
|
|
||||||
const (
|
|
||||||
IssuerLegalName = "SMX Operations Limited"
|
|
||||||
IssuerLegalAddress = "Room 607, 12/F., Block C, Hong Kong Industrial Centre, 489-491 Castle Peak Road, Lai Chi Kok, Hong Kong"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
PDFTitleActOfAcceptance = "Act of Acceptance"
|
|
||||||
DocumentIntegrityHashPrefix = "Document integrity hash: "
|
|
||||||
)
|
|
||||||
|
|
||||||
// AcceptanceTemplateContent contains all static copy used by the acceptance act template.
|
|
||||||
type AcceptanceTemplateContent struct {
|
|
||||||
Title string
|
|
||||||
Subtitle string
|
|
||||||
MetaDateLabel string
|
|
||||||
MetaActNumberLabel string
|
|
||||||
SectionParties string
|
|
||||||
PartiesIntro string
|
|
||||||
PartyExecutorLabel string
|
|
||||||
PartyStatusLabel string
|
|
||||||
PartyStatusValue string
|
|
||||||
SectionBasis string
|
|
||||||
BasisLine1 string
|
|
||||||
BasisLine2 string
|
|
||||||
SectionServicesRendered string
|
|
||||||
ServicesRenderedLine1 string
|
|
||||||
ServicesRenderedLine2 string
|
|
||||||
SectionRemuneration string
|
|
||||||
RemunerationHeaderDesc string
|
|
||||||
RemunerationHeaderAmount string
|
|
||||||
RemunerationServicesDesc string
|
|
||||||
SectionConfirmation string
|
|
||||||
ConfirmationLine1 string
|
|
||||||
ConfirmationLine2 string
|
|
||||||
ConfirmationPaymentLine1 string
|
|
||||||
ConfirmationPaymentLine2 string
|
|
||||||
SectionSignatures string
|
|
||||||
SignatureCustomerLine string
|
|
||||||
SignatureExecutorLine string
|
|
||||||
}
|
|
||||||
|
|
||||||
var AcceptanceTemplate = AcceptanceTemplateContent{
|
|
||||||
Title: "ACT OF ACCEPTANCE OF SERVICES",
|
|
||||||
Subtitle: "under the Public Offer Agreement",
|
|
||||||
MetaDateLabel: "Date",
|
|
||||||
MetaActNumberLabel: "Act No",
|
|
||||||
SectionParties: "PARTIES",
|
|
||||||
PartiesIntro: "This Act is made between the following Parties.",
|
|
||||||
PartyExecutorLabel: "Executor",
|
|
||||||
PartyStatusLabel: "Status",
|
|
||||||
PartyStatusValue: "Individual",
|
|
||||||
SectionBasis: "BASIS",
|
|
||||||
BasisLine1: "This Act is issued pursuant to the Public Offer Agreement",
|
|
||||||
BasisLine2: "accepted by the Executor by joining the offer.",
|
|
||||||
SectionServicesRendered: "SERVICES RENDERED",
|
|
||||||
ServicesRenderedLine1: "The Executor has rendered services to the Customer",
|
|
||||||
ServicesRenderedLine2: "in accordance with the terms of the Public Offer Agreement.",
|
|
||||||
SectionRemuneration: "REMUNERATION",
|
|
||||||
RemunerationHeaderDesc: "Description",
|
|
||||||
RemunerationHeaderAmount: "Amount",
|
|
||||||
RemunerationServicesDesc: "Services rendered under the Public Offer Agreement",
|
|
||||||
SectionConfirmation: "CONFIRMATION",
|
|
||||||
ConfirmationLine1: "The Customer confirms that the services were rendered properly",
|
|
||||||
ConfirmationLine2: "and accepted without any claims.",
|
|
||||||
ConfirmationPaymentLine1: "The remuneration for the services was paid to the Executor",
|
|
||||||
ConfirmationPaymentLine2: "using the bank card details provided by the Executor.",
|
|
||||||
SectionSignatures: "SIGNATURES",
|
|
||||||
SignatureCustomerLine: "Customer ___________________________",
|
|
||||||
SignatureExecutorLine: "Executor ___________________________",
|
|
||||||
}
|
|
||||||
|
|
||||||
// OperationDocumentContent contains all static copy for operation documents.
|
|
||||||
type OperationDocumentContent struct {
|
|
||||||
Title string
|
|
||||||
Subtitle string
|
|
||||||
MetaDocumentType string
|
|
||||||
SectionOperation string
|
|
||||||
SectionFailure string
|
|
||||||
RowOrganization string
|
|
||||||
RowGatewayService string
|
|
||||||
RowOperationRef string
|
|
||||||
RowPaymentRef string
|
|
||||||
RowCode string
|
|
||||||
RowState string
|
|
||||||
RowLabel string
|
|
||||||
RowStartedAtUTC string
|
|
||||||
RowCompletedAtUTC string
|
|
||||||
RowAmount string
|
|
||||||
RowFailureCode string
|
|
||||||
RowFailureReason string
|
|
||||||
MissingValuePlaceholder string
|
|
||||||
}
|
|
||||||
|
|
||||||
var OperationDocument = OperationDocumentContent{
|
|
||||||
Title: "OPERATION BILLING DOCUMENT",
|
|
||||||
Subtitle: "Gateway operation statement",
|
|
||||||
MetaDocumentType: "Document Type: Operation",
|
|
||||||
SectionOperation: "OPERATION DETAILS",
|
|
||||||
SectionFailure: "FAILURE DETAILS",
|
|
||||||
RowOrganization: "Organization",
|
|
||||||
RowGatewayService: "Gateway Service",
|
|
||||||
RowOperationRef: "Operation Ref",
|
|
||||||
RowPaymentRef: "Payment Ref",
|
|
||||||
RowCode: "Code",
|
|
||||||
RowState: "State",
|
|
||||||
RowLabel: "Label",
|
|
||||||
RowStartedAtUTC: "Started At (UTC)",
|
|
||||||
RowCompletedAtUTC: "Completed At (UTC)",
|
|
||||||
RowAmount: "Amount",
|
|
||||||
RowFailureCode: "Failure Code",
|
|
||||||
RowFailureReason: "Failure Reason",
|
|
||||||
MissingValuePlaceholder: "n/a",
|
|
||||||
}
|
|
||||||
@@ -3,24 +3,18 @@ package documents
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/documents/internal/content"
|
|
||||||
"github.com/tech/sendico/billing/documents/internal/docstore"
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
||||||
"github.com/tech/sendico/billing/documents/renderer"
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds document service settings loaded from YAML.
|
// Config holds document service settings loaded from YAML.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Issuer IssuerConfig `yaml:"issuer"`
|
Issuer renderer.Issuer `yaml:"issuer"`
|
||||||
Templates TemplateConfig `yaml:"templates"`
|
Templates TemplateConfig `yaml:"templates"`
|
||||||
Protection ProtectionConfig `yaml:"protection"`
|
Protection ProtectionConfig `yaml:"protection"`
|
||||||
Storage docstore.Config `yaml:"storage"`
|
Storage docstore.Config `yaml:"storage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssuerConfig defines issuer settings that are environment-specific.
|
|
||||||
type IssuerConfig struct {
|
|
||||||
LogoPath string `yaml:"logo_path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TemplateConfig defines document template locations.
|
// TemplateConfig defines document template locations.
|
||||||
type TemplateConfig struct {
|
type TemplateConfig struct {
|
||||||
AcceptancePath string `yaml:"acceptance_path"`
|
AcceptancePath string `yaml:"acceptance_path"`
|
||||||
@@ -31,14 +25,6 @@ type ProtectionConfig struct {
|
|||||||
OwnerPassword string `yaml:"owner_password"`
|
OwnerPassword string `yaml:"owner_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) IssuerDetails() renderer.Issuer {
|
|
||||||
return renderer.Issuer{
|
|
||||||
LegalName: content.IssuerLegalName,
|
|
||||||
LegalAddress: content.IssuerLegalAddress,
|
|
||||||
LogoPath: c.Issuer.LogoPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Config) AcceptanceTemplatePath() string {
|
func (c Config) AcceptanceTemplatePath() string {
|
||||||
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
||||||
return "templates/acceptance.tpl"
|
return "templates/acceptance.tpl"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
@@ -16,6 +17,7 @@ var (
|
|||||||
|
|
||||||
requestsTotal *prometheus.CounterVec
|
requestsTotal *prometheus.CounterVec
|
||||||
requestLatency *prometheus.HistogramVec
|
requestLatency *prometheus.HistogramVec
|
||||||
|
batchSize prometheus.Histogram
|
||||||
documentBytes *prometheus.HistogramVec
|
documentBytes *prometheus.HistogramVec
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +44,16 @@ func initMetrics() {
|
|||||||
[]string{"call", "status", "doc_type"},
|
[]string{"call", "status", "doc_type"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
batchSize = promauto.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "batch_size",
|
||||||
|
Help: "Number of payment references in batch resolution requests.",
|
||||||
|
Buckets: []float64{0, 1, 2, 5, 10, 20, 50, 100, 250, 500},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
documentBytes = promauto.NewHistogramVec(
|
documentBytes = promauto.NewHistogramVec(
|
||||||
prometheus.HistogramOpts{
|
prometheus.HistogramOpts{
|
||||||
Namespace: "billing",
|
Namespace: "billing",
|
||||||
@@ -55,14 +67,18 @@ func initMetrics() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func observeRequest(call string, documentKind, statusLabel string, took time.Duration) {
|
func observeRequest(call string, docType documentsv1.DocumentType, statusLabel string, took time.Duration) {
|
||||||
kind := docKindLabel(documentKind)
|
typeLabel := docTypeLabel(docType)
|
||||||
requestsTotal.WithLabelValues(call, statusLabel, kind).Inc()
|
requestsTotal.WithLabelValues(call, statusLabel, typeLabel).Inc()
|
||||||
requestLatency.WithLabelValues(call, statusLabel, kind).Observe(took.Seconds())
|
requestLatency.WithLabelValues(call, statusLabel, typeLabel).Observe(took.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
func observeDocumentBytes(documentKind string, size int) {
|
func observeBatchSize(size int) {
|
||||||
documentBytes.WithLabelValues(docKindLabel(documentKind)).Observe(float64(size))
|
batchSize.Observe(float64(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeDocumentBytes(docType documentsv1.DocumentType, size int) {
|
||||||
|
documentBytes.WithLabelValues(docTypeLabel(docType)).Observe(float64(size))
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusFromError(err error) string {
|
func statusFromError(err error) string {
|
||||||
@@ -84,10 +100,10 @@ func statusFromError(err error) string {
|
|||||||
return strings.ToLower(code.String())
|
return strings.ToLower(code.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func docKindLabel(documentKind string) string {
|
func docTypeLabel(docType documentsv1.DocumentType) string {
|
||||||
label := strings.TrimSpace(documentKind)
|
label := docType.String()
|
||||||
if label == "" {
|
if label == "" {
|
||||||
return "operation"
|
return "DOCUMENT_TYPE_UNSPECIFIED"
|
||||||
}
|
}
|
||||||
|
|
||||||
return label
|
return label
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/documents/internal/appversion"
|
"github.com/tech/sendico/billing/documents/internal/appversion"
|
||||||
"github.com/tech/sendico/billing/documents/internal/content"
|
|
||||||
"github.com/tech/sendico/billing/documents/internal/docstore"
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
||||||
"github.com/tech/sendico/billing/documents/renderer"
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
"github.com/tech/sendico/billing/documents/storage"
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
@@ -145,6 +145,94 @@ func (s *Service) Shutdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
paymentRefs := 0
|
||||||
|
if req != nil {
|
||||||
|
paymentRefs = len(req.GetPaymentRefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := s.logger.With(zap.Int("payment_refs", paymentRefs))
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||||
|
observeBatchSize(paymentRefs)
|
||||||
|
|
||||||
|
itemsCount := 0
|
||||||
|
if resp != nil {
|
||||||
|
itemsCount = len(resp.GetItems())
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("status", statusLabel),
|
||||||
|
zap.Duration("duration", time.Since(start)),
|
||||||
|
zap.Int("items", itemsCount),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("BatchResolveDocuments finished", fields...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = ctx
|
||||||
|
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||||
|
paymentRef := ""
|
||||||
|
if req != nil {
|
||||||
|
docType = req.GetType()
|
||||||
|
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := s.logger.With(
|
||||||
|
zap.String("payment_ref", paymentRef),
|
||||||
|
zap.String("document_type", docTypeLabel(docType)),
|
||||||
|
)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
contentBytes := 0
|
||||||
|
if resp != nil {
|
||||||
|
contentBytes = len(resp.GetContent())
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("status", statusLabel),
|
||||||
|
zap.Duration("duration", time.Since(start)),
|
||||||
|
zap.Int("content_bytes", contentBytes),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("GetDocument failed", append(fields, zap.Error(err))...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("GetDocument finished", fields...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = ctx
|
||||||
|
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
organizationRef := ""
|
organizationRef := ""
|
||||||
@@ -165,10 +253,11 @@ func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOp
|
|||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
observeRequest("get_operation_document", "operation", statusLabel, time.Since(start))
|
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||||
|
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
|
||||||
|
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
observeDocumentBytes("operation", len(resp.GetContent()))
|
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||||
}
|
}
|
||||||
|
|
||||||
contentBytes := 0
|
contentBytes := 0
|
||||||
@@ -253,6 +342,12 @@ func (e serviceError) Error() string {
|
|||||||
return string(e)
|
return string(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errStorageUnavailable = serviceError("documents: storage not initialised")
|
||||||
|
errDocStoreUnavailable = serviceError("documents: document store not initialised")
|
||||||
|
errTemplateUnavailable = serviceError("documents: template renderer not initialised")
|
||||||
|
)
|
||||||
|
|
||||||
func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, error) {
|
func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, error) {
|
||||||
blocks, err := s.template.Render(snapshot)
|
blocks, err := s.template.Render(snapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,7 +363,7 @@ func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, stri
|
|||||||
|
|
||||||
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
|
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
|
||||||
generated := renderer.Renderer{
|
generated := renderer.Renderer{
|
||||||
Issuer: s.config.IssuerDetails(),
|
Issuer: s.config.Issuer,
|
||||||
OwnerPassword: s.config.Protection.OwnerPassword,
|
OwnerPassword: s.config.Protection.OwnerPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,41 +427,39 @@ func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
|
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
|
||||||
documentCopy := content.OperationDocument
|
|
||||||
|
|
||||||
rows := [][]string{
|
rows := [][]string{
|
||||||
{documentCopy.RowOrganization, snapshot.OrganizationRef},
|
{"Organization", snapshot.OrganizationRef},
|
||||||
{documentCopy.RowGatewayService, snapshot.GatewayService},
|
{"Gateway Service", snapshot.GatewayService},
|
||||||
{documentCopy.RowOperationRef, snapshot.OperationRef},
|
{"Operation Ref", snapshot.OperationRef},
|
||||||
{documentCopy.RowPaymentRef, safeValue(snapshot.PaymentRef)},
|
{"Payment Ref", safeValue(snapshot.PaymentRef)},
|
||||||
{documentCopy.RowCode, safeValue(snapshot.OperationCode)},
|
{"Code", safeValue(snapshot.OperationCode)},
|
||||||
{documentCopy.RowState, safeValue(snapshot.OperationState)},
|
{"State", safeValue(snapshot.OperationState)},
|
||||||
{documentCopy.RowLabel, safeValue(snapshot.OperationLabel)},
|
{"Label", safeValue(snapshot.OperationLabel)},
|
||||||
{documentCopy.RowStartedAtUTC, formatSnapshotTime(snapshot.StartedAt)},
|
{"Started At (UTC)", formatSnapshotTime(snapshot.StartedAt)},
|
||||||
{documentCopy.RowCompletedAtUTC, formatSnapshotTime(snapshot.CompletedAt)},
|
{"Completed At (UTC)", formatSnapshotTime(snapshot.CompletedAt)},
|
||||||
}
|
}
|
||||||
if snapshot.Amount != "" || snapshot.Currency != "" {
|
if snapshot.Amount != "" || snapshot.Currency != "" {
|
||||||
rows = append(rows, []string{documentCopy.RowAmount, strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
|
rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks := []renderer.Block{
|
blocks := []renderer.Block{
|
||||||
{
|
{
|
||||||
Tag: renderer.TagTitle,
|
Tag: renderer.TagTitle,
|
||||||
Lines: []string{documentCopy.Title},
|
Lines: []string{"OPERATION BILLING DOCUMENT"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Tag: renderer.TagSubtitle,
|
Tag: renderer.TagSubtitle,
|
||||||
Lines: []string{documentCopy.Subtitle},
|
Lines: []string{"Gateway operation statement"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Tag: renderer.TagMeta,
|
Tag: renderer.TagMeta,
|
||||||
Lines: []string{
|
Lines: []string{
|
||||||
documentCopy.MetaDocumentType,
|
"Document Type: Operation",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Tag: renderer.TagSection,
|
Tag: renderer.TagSection,
|
||||||
Lines: []string{documentCopy.SectionOperation},
|
Lines: []string{"OPERATION DETAILS"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Tag: renderer.TagKV,
|
Tag: renderer.TagKV,
|
||||||
@@ -376,12 +469,12 @@ func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
|
|||||||
|
|
||||||
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
|
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
|
||||||
blocks = append(blocks,
|
blocks = append(blocks,
|
||||||
renderer.Block{Tag: renderer.TagSection, Lines: []string{documentCopy.SectionFailure}},
|
renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}},
|
||||||
renderer.Block{
|
renderer.Block{
|
||||||
Tag: renderer.TagKV,
|
Tag: renderer.TagKV,
|
||||||
Rows: [][]string{
|
Rows: [][]string{
|
||||||
{documentCopy.RowFailureCode, safeValue(snapshot.FailureCode)},
|
{"Failure Code", safeValue(snapshot.FailureCode)},
|
||||||
{documentCopy.RowFailureReason, safeValue(snapshot.FailureReason)},
|
{"Failure Reason", safeValue(snapshot.FailureReason)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -392,7 +485,7 @@ func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
|
|||||||
|
|
||||||
func formatSnapshotTime(value time.Time) string {
|
func formatSnapshotTime(value time.Time) string {
|
||||||
if value.IsZero() {
|
if value.IsZero() {
|
||||||
return content.OperationDocument.MissingValuePlaceholder
|
return "n/a"
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.UTC().Format(time.RFC3339)
|
return value.UTC().Format(time.RFC3339)
|
||||||
@@ -401,7 +494,7 @@ func formatSnapshotTime(value time.Time) string {
|
|||||||
func safeValue(value string) string {
|
func safeValue(value string) string {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return content.OperationDocument.MissingValuePlaceholder
|
return "n/a"
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed
|
return trimmed
|
||||||
@@ -442,3 +535,50 @@ func sanitizeFilenameComponent(value string) string {
|
|||||||
|
|
||||||
return strings.Trim(b.String(), "_")
|
return strings.Trim(b.String(), "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
||||||
|
if len(types) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]documentsv1.DocumentType, 0, len(types))
|
||||||
|
for _, t := range types {
|
||||||
|
result = append(result, t.Proto())
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
|
||||||
|
suffix := "document.pdf"
|
||||||
|
|
||||||
|
switch docType {
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||||
|
suffix = "act.pdf"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_INVOICE:
|
||||||
|
suffix = "invoice.pdf"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||||
|
suffix = "receipt.pdf"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||||
|
// default suffix used
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
|
||||||
|
name := "document"
|
||||||
|
|
||||||
|
switch docType {
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||||
|
name = "act"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_INVOICE:
|
||||||
|
name = "invoice"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||||
|
name = "receipt"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||||
|
// default name used
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/billing/documents/internal/content"
|
|
||||||
"github.com/tech/sendico/billing/documents/renderer"
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
"github.com/tech/sendico/billing/documents/storage"
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
"github.com/tech/sendico/billing/documents/storage/model"
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
@@ -60,6 +59,10 @@ type memDocStore struct {
|
|||||||
loadCount int
|
loadCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newMemDocStore() *memDocStore {
|
||||||
|
return &memDocStore{data: map[string][]byte{}}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
|
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
|
||||||
m.saveCount++
|
m.saveCount++
|
||||||
copyData := make([]byte, len(data))
|
copyData := make([]byte, len(data))
|
||||||
@@ -109,7 +112,15 @@ func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Issuer: renderer.Issuer{
|
||||||
|
LegalName: "Sendico Ltd",
|
||||||
|
LegalAddress: "12 Market Street, London, UK",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
svc := NewService(zap.NewNop(), nil, nil,
|
svc := NewService(zap.NewNop(), nil, nil,
|
||||||
|
WithConfig(cfg),
|
||||||
WithTemplateRenderer(tmpl),
|
WithTemplateRenderer(tmpl),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,7 +164,7 @@ func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractFooterHash(pdf []byte) string {
|
func extractFooterHash(pdf []byte) string {
|
||||||
prefix := []byte(content.DocumentIntegrityHashPrefix)
|
prefix := []byte("Document integrity hash: ")
|
||||||
idx := bytes.Index(pdf, prefix)
|
idx := bytes.Index(pdf, prefix)
|
||||||
|
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
@@ -180,7 +191,11 @@ func isHexDigit(b byte) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
|
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
|
||||||
svc := NewService(zap.NewNop(), nil, nil)
|
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
|
||||||
|
Issuer: renderer.Issuer{
|
||||||
|
LegalName: "Sendico Ltd",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
||||||
OrganizationRef: "org-1",
|
OrganizationRef: "org-1",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/billing/documents/internal/content"
|
|
||||||
"github.com/tech/sendico/billing/documents/renderer"
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
"github.com/tech/sendico/billing/documents/storage/model"
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
)
|
)
|
||||||
@@ -18,11 +17,6 @@ type templateRenderer struct {
|
|||||||
tpl *template.Template
|
tpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
type acceptanceTemplateData struct {
|
|
||||||
model.ActSnapshot
|
|
||||||
Content content.AcceptanceTemplateContent
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTemplateRenderer(path string) (*templateRenderer, error) {
|
func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,12 +38,7 @@ func newTemplateRenderer(path string) (*templateRenderer, error) {
|
|||||||
|
|
||||||
func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
data := acceptanceTemplateData{
|
if err := r.tpl.Execute(&buf, snapshot); err != nil {
|
||||||
ActSnapshot: snapshot,
|
|
||||||
Content: content.AcceptanceTemplate,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.tpl.Execute(&buf, data); err != nil {
|
|
||||||
return nil, fmt.Errorf("execute template: %w", err)
|
return nil, fmt.Errorf("execute template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/billing/documents/internal/content"
|
|
||||||
"github.com/tech/sendico/billing/documents/renderer"
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
"github.com/tech/sendico/billing/documents/storage/model"
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
)
|
)
|
||||||
@@ -43,7 +42,7 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
|||||||
t.Fatalf("expected title block")
|
t.Fatalf("expected title block")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(title.Lines, content.AcceptanceTemplate.Title) {
|
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
|
||||||
t.Fatalf("expected title content not found")
|
t.Fatalf("expected title content not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
|||||||
foundExecutor := false
|
foundExecutor := false
|
||||||
|
|
||||||
for _, row := range kv.Rows {
|
for _, row := range kv.Rows {
|
||||||
if len(row) >= 2 && row[0] == content.AcceptanceTemplate.PartyExecutorLabel && row[1] == snapshot.ExecutorFullName {
|
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
|
||||||
foundExecutor = true
|
foundExecutor = true
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jung-kurt/gofpdf"
|
"github.com/jung-kurt/gofpdf"
|
||||||
"github.com/tech/sendico/billing/documents/internal/content"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,7 +28,7 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
|||||||
pdf.SetAutoPageBreak(true, pageMarginBottom)
|
pdf.SetAutoPageBreak(true, pageMarginBottom)
|
||||||
pdf.SetCompression(false)
|
pdf.SetCompression(false)
|
||||||
pdf.SetAuthor(r.Issuer.LegalName, false)
|
pdf.SetAuthor(r.Issuer.LegalName, false)
|
||||||
pdf.SetTitle(content.PDFTitleActOfAcceptance, false)
|
pdf.SetTitle("Act of Acceptance", false)
|
||||||
|
|
||||||
owner := strings.TrimSpace(r.OwnerPassword)
|
owner := strings.TrimSpace(r.OwnerPassword)
|
||||||
if owner != "" {
|
if owner != "" {
|
||||||
@@ -40,7 +39,7 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
|||||||
pdf.SetY(-15)
|
pdf.SetY(-15)
|
||||||
pdf.SetFont("Helvetica", "", 8)
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
|
|
||||||
footer := content.DocumentIntegrityHashPrefix + footerHash
|
footer := "Document integrity hash: " + footerHash
|
||||||
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/documents/internal/content"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderer_RenderContainsText(t *testing.T) {
|
func TestRenderer_RenderContainsText(t *testing.T) {
|
||||||
@@ -33,7 +31,7 @@ func TestRenderer_RenderContainsText(t *testing.T) {
|
|||||||
t.Fatalf("expected PDF bytes")
|
t.Fatalf("expected PDF bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", strings.TrimSpace(strings.TrimSuffix(content.DocumentIntegrityHashPrefix, ": "))}
|
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
|
||||||
|
|
||||||
for _, token := range checks {
|
for _, token := range checks {
|
||||||
if !containsPDFText(pdfBytes, token) {
|
if !containsPDFText(pdfBytes, token) {
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import (
|
|||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DocumentRecordsCollection = "document_records"
|
DocumentRecordsCollection = "document_records"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DocumentType represents document kinds cached in storage.
|
// DocumentType mirrors the protobuf enum but stores string names for Mongo compatibility.
|
||||||
type DocumentType string
|
type DocumentType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,6 +23,24 @@ const (
|
|||||||
DocumentTypeReceipt DocumentType = "DOCUMENT_TYPE_RECEIPT"
|
DocumentTypeReceipt DocumentType = "DOCUMENT_TYPE_RECEIPT"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DocumentTypeFromProto converts a protobuf enum to the storage representation.
|
||||||
|
func DocumentTypeFromProto(t documentsv1.DocumentType) DocumentType {
|
||||||
|
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
|
||||||
|
return DocumentType(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DocumentTypeUnspecified
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proto converts the storage representation to a protobuf enum.
|
||||||
|
func (t DocumentType) Proto() documentsv1.DocumentType {
|
||||||
|
if value, ok := documentsv1.DocumentType_value[string(t)]; ok {
|
||||||
|
return documentsv1.DocumentType(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
|
||||||
// ActSnapshot captures the immutable data needed to generate an acceptance act.
|
// ActSnapshot captures the immutable data needed to generate an acceptance act.
|
||||||
type ActSnapshot struct {
|
type ActSnapshot struct {
|
||||||
PaymentID string `bson:"paymentId" json:"paymentId"`
|
PaymentID string `bson:"paymentId" json:"paymentId"`
|
||||||
|
|||||||
@@ -2,66 +2,66 @@
|
|||||||
|
|
||||||
|
|
||||||
#title
|
#title
|
||||||
{{ .Content.Title }}
|
ACT OF ACCEPTANCE OF SERVICES
|
||||||
|
|
||||||
#subtitle
|
#subtitle
|
||||||
{{ .Content.Subtitle }}
|
under the Public Offer Agreement
|
||||||
|
|
||||||
#meta
|
#meta
|
||||||
{{ .Content.MetaDateLabel }}: {{ date .Date }}
|
Date: {{ date .Date }}
|
||||||
{{ .Content.MetaActNumberLabel }}: {{ .PaymentID }}
|
Act No: {{ .PaymentID }}
|
||||||
|
|
||||||
|
|
||||||
#section
|
#section
|
||||||
{{ .Content.SectionParties }}
|
PARTIES
|
||||||
|
|
||||||
#text
|
#text
|
||||||
{{ .Content.PartiesIntro }}
|
This Act is made between the following Parties.
|
||||||
|
|
||||||
#kv
|
#kv
|
||||||
{{ .Content.PartyExecutorLabel }} | {{ .ExecutorFullName }}
|
Executor | {{ .ExecutorFullName }}
|
||||||
{{ .Content.PartyStatusLabel }} | {{ .Content.PartyStatusValue }}
|
Status | Individual
|
||||||
|
|
||||||
|
|
||||||
#section
|
#section
|
||||||
{{ .Content.SectionBasis }}
|
BASIS
|
||||||
|
|
||||||
#text
|
#text
|
||||||
{{ .Content.BasisLine1 }}
|
This Act is issued pursuant to the Public Offer Agreement
|
||||||
{{ .Content.BasisLine2 }}
|
accepted by the Executor by joining the offer.
|
||||||
|
|
||||||
|
|
||||||
#section
|
#section
|
||||||
{{ .Content.SectionServicesRendered }}
|
SERVICES RENDERED
|
||||||
|
|
||||||
#text
|
#text
|
||||||
{{ .Content.ServicesRenderedLine1 }}
|
The Executor has rendered services to the Customer
|
||||||
{{ .Content.ServicesRenderedLine2 }}
|
in accordance with the terms of the Public Offer Agreement.
|
||||||
|
|
||||||
|
|
||||||
#section
|
#section
|
||||||
{{ .Content.SectionRemuneration }}
|
REMUNERATION
|
||||||
|
|
||||||
#table
|
#table
|
||||||
{{ .Content.RemunerationHeaderDesc }} | {{ .Content.RemunerationHeaderAmount }}
|
Description | Amount
|
||||||
{{ .Content.RemunerationServicesDesc }} | {{ money .Amount .Currency }}
|
Services rendered under the Public Offer Agreement | {{ money .Amount .Currency }}
|
||||||
|
|
||||||
|
|
||||||
#section
|
#section
|
||||||
{{ .Content.SectionConfirmation }}
|
CONFIRMATION
|
||||||
|
|
||||||
#text
|
#text
|
||||||
{{ .Content.ConfirmationLine1 }}
|
The Customer confirms that the services were rendered properly
|
||||||
{{ .Content.ConfirmationLine2 }}
|
and accepted without any claims.
|
||||||
|
|
||||||
{{ .Content.ConfirmationPaymentLine1 }}
|
The remuneration for the services was paid to the Executor
|
||||||
{{ .Content.ConfirmationPaymentLine2 }}
|
using the bank card details provided by the Executor.
|
||||||
|
|
||||||
|
|
||||||
#section
|
#section
|
||||||
{{ .Content.SectionSignatures }}
|
SIGNATURES
|
||||||
|
|
||||||
#sign
|
#sign
|
||||||
{{ .Content.SignatureCustomerLine }}
|
Customer ___________________________
|
||||||
|
|
||||||
{{ .Content.SignatureExecutorLine }}
|
Executor ___________________________
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ require (
|
|||||||
github.com/tech/sendico/fx/oracle v0.0.0
|
github.com/tech/sendico/fx/oracle v0.0.0
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -44,6 +44,6 @@ require (
|
|||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
google.golang.org/grpc v1.79.2 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||||
@@ -38,7 +38,7 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/net v0.51.0
|
golang.org/x/net v0.51.0
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
moul.io/chizap v1.0.3
|
moul.io/chizap v1.0.3
|
||||||
@@ -59,7 +59,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJ
|
|||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
||||||
@@ -32,8 +32,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7su
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||||
@@ -403,8 +403,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:
|
|||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -59,6 +59,6 @@ require (
|
|||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
google.golang.org/grpc v1.79.2 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -245,8 +245,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -48,6 +48,6 @@ require (
|
|||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
google.golang.org/grpc v1.79.2 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -210,8 +210,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -492,9 +492,6 @@ func transferFromReceipt(req *chainv1.SubmitTransferRequest, receipt *connectorv
|
|||||||
transfer.Destination = req.GetDestination()
|
transfer.Destination = req.GetDestination()
|
||||||
transfer.RequestedAmount = req.GetAmount()
|
transfer.RequestedAmount = req.GetAmount()
|
||||||
transfer.NetAmount = req.GetAmount()
|
transfer.NetAmount = req.GetAmount()
|
||||||
transfer.IntentRef = strings.TrimSpace(req.GetIntentRef())
|
|
||||||
transfer.OperationRef = strings.TrimSpace(req.GetOperationRef())
|
|
||||||
transfer.PaymentRef = strings.TrimSpace(req.GetPaymentRef())
|
|
||||||
}
|
}
|
||||||
if receipt != nil {
|
if receipt != nil {
|
||||||
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())
|
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -14,11 +13,6 @@ import (
|
|||||||
|
|
||||||
type stubConnectorClient struct {
|
type stubConnectorClient struct {
|
||||||
listReq *connectorv1.ListAccountsRequest
|
listReq *connectorv1.ListAccountsRequest
|
||||||
getReq *connectorv1.GetOperationRequest
|
|
||||||
|
|
||||||
submitReq *connectorv1.SubmitOperationRequest
|
|
||||||
submitResp *connectorv1.SubmitOperationResponse
|
|
||||||
getResp *connectorv1.GetOperationResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubConnectorClient) GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error) {
|
func (s *stubConnectorClient) GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||||
@@ -43,18 +37,10 @@ func (s *stubConnectorClient) GetBalance(ctx context.Context, in *connectorv1.Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubConnectorClient) SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) {
|
func (s *stubConnectorClient) SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) {
|
||||||
s.submitReq = in
|
|
||||||
if s.submitResp != nil {
|
|
||||||
return s.submitResp, nil
|
|
||||||
}
|
|
||||||
return &connectorv1.SubmitOperationResponse{}, nil
|
return &connectorv1.SubmitOperationResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubConnectorClient) GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) {
|
func (s *stubConnectorClient) GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) {
|
||||||
s.getReq = in
|
|
||||||
if s.getResp != nil {
|
|
||||||
return s.getResp, nil
|
|
||||||
}
|
|
||||||
return &connectorv1.GetOperationResponse{}, nil
|
return &connectorv1.GetOperationResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,84 +66,3 @@ func TestListManagedWallets_ForwardsOrganizationRef(t *testing.T) {
|
|||||||
require.Equal(t, "owner-1", stub.listReq.GetOwnerRefFilter().GetValue())
|
require.Equal(t, "owner-1", stub.listReq.GetOwnerRefFilter().GetValue())
|
||||||
require.Equal(t, connectorv1.AccountKind_CHAIN_MANAGED_WALLET, stub.listReq.GetKind())
|
require.Equal(t, connectorv1.AccountKind_CHAIN_MANAGED_WALLET, stub.listReq.GetKind())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTransferFromReceipt_PreservesRequestReferences(t *testing.T) {
|
|
||||||
req := &chainv1.SubmitTransferRequest{
|
|
||||||
IdempotencyKey: "idem-1",
|
|
||||||
OrganizationRef: "org-1",
|
|
||||||
SourceWalletRef: "wallet-src",
|
|
||||||
Amount: &moneyv1.Money{Amount: "12.34", Currency: "USDT"},
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
OperationRef: "payment-1:hop_2_settlement_fx_convert",
|
|
||||||
PaymentRef: "payment-1",
|
|
||||||
}
|
|
||||||
receipt := &connectorv1.OperationReceipt{
|
|
||||||
OperationId: "payment-1:hop_2_settlement_fx_convert",
|
|
||||||
ProviderRef: "provider-1",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_WAITING,
|
|
||||||
}
|
|
||||||
|
|
||||||
transfer := transferFromReceipt(req, receipt)
|
|
||||||
require.NotNil(t, transfer)
|
|
||||||
require.Equal(t, "payment-1:hop_2_settlement_fx_convert", transfer.GetTransferRef())
|
|
||||||
require.Equal(t, "intent-1", transfer.GetIntentRef())
|
|
||||||
require.Equal(t, "payment-1:hop_2_settlement_fx_convert", transfer.GetOperationRef())
|
|
||||||
require.Equal(t, "payment-1", transfer.GetPaymentRef())
|
|
||||||
require.Equal(t, chainv1.TransferStatus_TRANSFER_WAITING, transfer.GetStatus())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmitTransfer_ForwardsOperationAndIntentReferences(t *testing.T) {
|
|
||||||
stub := &stubConnectorClient{
|
|
||||||
submitResp: &connectorv1.SubmitOperationResponse{
|
|
||||||
Receipt: &connectorv1.OperationReceipt{
|
|
||||||
OperationId: "payment-1:hop_2_settlement_fx_convert",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_WAITING,
|
|
||||||
ProviderRef: "provider-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := NewWithClient(Config{}, stub)
|
|
||||||
|
|
||||||
resp, err := client.SubmitTransfer(context.Background(), &chainv1.SubmitTransferRequest{
|
|
||||||
IdempotencyKey: "idem-1",
|
|
||||||
OrganizationRef: "org-1",
|
|
||||||
SourceWalletRef: "wallet-src",
|
|
||||||
Destination: &chainv1.TransferDestination{
|
|
||||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{
|
|
||||||
ManagedWalletRef: "wallet-dst",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Amount: &moneyv1.Money{Amount: "12.34", Currency: "USDT"},
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
OperationRef: "payment-1:hop_2_settlement_fx_convert",
|
|
||||||
PaymentRef: "payment-1",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, stub.submitReq)
|
|
||||||
require.NotNil(t, stub.submitReq.GetOperation())
|
|
||||||
require.Equal(t, "intent-1", stub.submitReq.GetOperation().GetIntentRef())
|
|
||||||
require.Equal(t, "payment-1:hop_2_settlement_fx_convert", stub.submitReq.GetOperation().GetOperationRef())
|
|
||||||
require.NotNil(t, resp.GetTransfer())
|
|
||||||
require.Equal(t, "payment-1:hop_2_settlement_fx_convert", resp.GetTransfer().GetTransferRef())
|
|
||||||
require.Equal(t, "payment-1:hop_2_settlement_fx_convert", resp.GetTransfer().GetOperationRef())
|
|
||||||
require.Equal(t, "intent-1", resp.GetTransfer().GetIntentRef())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetTransfer_UsesTransferRefAsOperationID(t *testing.T) {
|
|
||||||
stub := &stubConnectorClient{
|
|
||||||
getResp: &connectorv1.GetOperationResponse{
|
|
||||||
Operation: &connectorv1.Operation{
|
|
||||||
OperationId: "transfer-1",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_PROCESSING,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := NewWithClient(Config{}, stub)
|
|
||||||
|
|
||||||
resp, err := client.GetTransfer(context.Background(), &chainv1.GetTransferRequest{TransferRef: "transfer-1"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, stub.getReq)
|
|
||||||
require.Equal(t, "transfer-1", stub.getReq.GetOperationId())
|
|
||||||
require.NotNil(t, resp.GetTransfer())
|
|
||||||
require.Equal(t, "transfer-1", resp.GetTransfer().GetTransferRef())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -357,8 +357,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
root = "."
|
|
||||||
testdata_dir = "testdata"
|
|
||||||
tmp_dir = "tmp"
|
|
||||||
|
|
||||||
[build]
|
|
||||||
args_bin = []
|
|
||||||
entrypoint = "./tmp/main"
|
|
||||||
cmd = "go build -o ./tmp/main ."
|
|
||||||
delay = 1000
|
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
|
||||||
exclude_file = []
|
|
||||||
exclude_regex = ["_test.go", "_templ.go"]
|
|
||||||
exclude_unchanged = false
|
|
||||||
follow_symlink = false
|
|
||||||
full_bin = ""
|
|
||||||
include_dir = []
|
|
||||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
|
||||||
include_file = []
|
|
||||||
kill_delay = "0s"
|
|
||||||
log = "build-errors.log"
|
|
||||||
poll = false
|
|
||||||
poll_interval = 0
|
|
||||||
post_cmd = []
|
|
||||||
pre_cmd = []
|
|
||||||
rerun = false
|
|
||||||
rerun_delay = 500
|
|
||||||
send_interrupt = false
|
|
||||||
stop_on_error = false
|
|
||||||
|
|
||||||
[color]
|
|
||||||
app = ""
|
|
||||||
build = "yellow"
|
|
||||||
main = "magenta"
|
|
||||||
runner = "green"
|
|
||||||
watcher = "cyan"
|
|
||||||
|
|
||||||
[log]
|
|
||||||
main_only = false
|
|
||||||
time = false
|
|
||||||
|
|
||||||
[misc]
|
|
||||||
clean_on_exit = false
|
|
||||||
|
|
||||||
[screen]
|
|
||||||
clear_on_rebuild = false
|
|
||||||
keep_scroll = true
|
|
||||||
4
api/gateway/chsettle/.gitignore
vendored
4
api/gateway/chsettle/.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
internal/generated
|
|
||||||
.gocache
|
|
||||||
app
|
|
||||||
tmp
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# ChimeraSettle Gateway (`chsettle`)
|
|
||||||
|
|
||||||
ChimeraSettle is a dummy settlement gateway for dev/test flows.
|
|
||||||
It simulates settlement outcomes deterministically, without real fund movement.
|
|
||||||
|
|
||||||
## What It Simulates
|
|
||||||
|
|
||||||
- Fast success
|
|
||||||
- Slow success
|
|
||||||
- Immediate failure
|
|
||||||
- Timeout-like failure
|
|
||||||
- Stuck pending/processing
|
|
||||||
- Retry-like progression before success
|
|
||||||
- Deterministic chaos bucket
|
|
||||||
|
|
||||||
## How Behavior Is Chosen
|
|
||||||
|
|
||||||
Selection order:
|
|
||||||
|
|
||||||
1. Explicit scenario override from request metadata:
|
|
||||||
- `chsettle_scenario`
|
|
||||||
- `scenario` (alias)
|
|
||||||
|
|
||||||
2. Amount bucket routing based on:
|
|
||||||
- `amount_minor % 1000`
|
|
||||||
|
|
||||||
3. Hash fallback using idempotency key (if amount cannot be parsed)
|
|
||||||
|
|
||||||
## Scenario Overrides
|
|
||||||
|
|
||||||
Accepted override values (aliases supported in code):
|
|
||||||
|
|
||||||
- `fast_success`
|
|
||||||
- `slow_success`
|
|
||||||
- `fail_immediate`
|
|
||||||
- `fail_timeout`
|
|
||||||
- `stuck_pending` (also `stuck`)
|
|
||||||
- `retry_then_success`
|
|
||||||
- `webhook_delayed_success`
|
|
||||||
- `slow_then_fail`
|
|
||||||
- `partial_progress_stuck`
|
|
||||||
- `chaos`
|
|
||||||
|
|
||||||
## Amount Bucket Map (`%1000`)
|
|
||||||
|
|
||||||
| Slot range | Scenario |
|
|
||||||
|---|---|
|
|
||||||
| `000-099` | `fast_success` |
|
|
||||||
| `100-199` | `slow_success` |
|
|
||||||
| `200-299` | `fail_immediate` |
|
|
||||||
| `300-399` | `fail_timeout` |
|
|
||||||
| `400-499` | `stuck_pending` |
|
|
||||||
| `500-599` | `retry_then_success` |
|
|
||||||
| `600-699` | `webhook_delayed_success` |
|
|
||||||
| `700-799` | `slow_then_fail` |
|
|
||||||
| `800-899` | `partial_progress_stuck` |
|
|
||||||
| `900-999` | `chaos` (deterministic by idempotency key) |
|
|
||||||
|
|
||||||
## Request Usage
|
|
||||||
|
|
||||||
### Through Connector `SubmitOperation`
|
|
||||||
|
|
||||||
Use operation param:
|
|
||||||
|
|
||||||
- `scenario` (optional)
|
|
||||||
|
|
||||||
or put it inside `metadata` as:
|
|
||||||
|
|
||||||
- `chsettle_scenario`
|
|
||||||
|
|
||||||
### Through Gateway `SubmitTransfer`
|
|
||||||
|
|
||||||
Set metadata key directly:
|
|
||||||
|
|
||||||
- `chsettle_scenario`
|
|
||||||
|
|
||||||
## Dev Environment
|
|
||||||
|
|
||||||
Compose service:
|
|
||||||
|
|
||||||
- `dev-chsettle-gateway`
|
|
||||||
|
|
||||||
Ports:
|
|
||||||
|
|
||||||
- gRPC: `50080`
|
|
||||||
- metrics: `9406`
|
|
||||||
|
|
||||||
Start only this gateway:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml --env-file .env.dev up -d dev-chsettle-gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
ChimeraSettle emits:
|
|
||||||
|
|
||||||
- `info` logs for scenario decisions and transition milestones
|
|
||||||
- `warn` logs for recoverable/non-fatal errors
|
|
||||||
- `error` logs for startup-fatal failures
|
|
||||||
- detailed `debug` logs for request execution tracing
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
runtime:
|
|
||||||
shutdown_timeout_seconds: 15
|
|
||||||
|
|
||||||
grpc:
|
|
||||||
network: tcp
|
|
||||||
address: ":50080"
|
|
||||||
advertise_host: "dev-chsettle-gateway"
|
|
||||||
enable_reflection: true
|
|
||||||
enable_health: true
|
|
||||||
|
|
||||||
metrics:
|
|
||||||
address: ":9406"
|
|
||||||
|
|
||||||
database:
|
|
||||||
driver: mongodb
|
|
||||||
settings:
|
|
||||||
host_env: CHSETTLE_GATEWAY_MONGO_HOST
|
|
||||||
port_env: CHSETTLE_GATEWAY_MONGO_PORT
|
|
||||||
database_env: CHSETTLE_GATEWAY_MONGO_DATABASE
|
|
||||||
user_env: CHSETTLE_GATEWAY_MONGO_USER
|
|
||||||
password_env: CHSETTLE_GATEWAY_MONGO_PASSWORD
|
|
||||||
auth_source_env: CHSETTLE_GATEWAY_MONGO_AUTH_SOURCE
|
|
||||||
replica_set_env: CHSETTLE_GATEWAY_MONGO_REPLICA_SET
|
|
||||||
|
|
||||||
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: ChimeraSettle Gateway Service
|
|
||||||
max_reconnects: 10
|
|
||||||
reconnect_wait: 5
|
|
||||||
buffer_size: 1024
|
|
||||||
|
|
||||||
gateway:
|
|
||||||
rail: "SETTLEMENT"
|
|
||||||
target_chat_id_env: CHSETTLE_GATEWAY_CHAT_ID
|
|
||||||
timeout_seconds: 345600
|
|
||||||
accepted_user_ids: []
|
|
||||||
success_reaction: "\U0001FAE1"
|
|
||||||
|
|
||||||
treasury:
|
|
||||||
execution_delay: 60s
|
|
||||||
poll_interval: 60s
|
|
||||||
ledger:
|
|
||||||
timeout: 5s
|
|
||||||
limits:
|
|
||||||
max_amount_per_operation: "1000000"
|
|
||||||
max_daily_amount: "5000000"
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
runtime:
|
|
||||||
shutdown_timeout_seconds: 15
|
|
||||||
|
|
||||||
grpc:
|
|
||||||
network: tcp
|
|
||||||
address: ":50080"
|
|
||||||
advertise_host: "sendico_chsettle_gateway"
|
|
||||||
enable_reflection: true
|
|
||||||
enable_health: true
|
|
||||||
|
|
||||||
metrics:
|
|
||||||
address: ":9406"
|
|
||||||
|
|
||||||
database:
|
|
||||||
driver: mongodb
|
|
||||||
settings:
|
|
||||||
host_env: CHSETTLE_GATEWAY_MONGO_HOST
|
|
||||||
port_env: CHSETTLE_GATEWAY_MONGO_PORT
|
|
||||||
database_env: CHSETTLE_GATEWAY_MONGO_DATABASE
|
|
||||||
user_env: CHSETTLE_GATEWAY_MONGO_USER
|
|
||||||
password_env: CHSETTLE_GATEWAY_MONGO_PASSWORD
|
|
||||||
auth_source_env: CHSETTLE_GATEWAY_MONGO_AUTH_SOURCE
|
|
||||||
replica_set_env: CHSETTLE_GATEWAY_MONGO_REPLICA_SET
|
|
||||||
|
|
||||||
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: ChimeraSettle Gateway Service
|
|
||||||
max_reconnects: 10
|
|
||||||
reconnect_wait: 5
|
|
||||||
buffer_size: 1024
|
|
||||||
|
|
||||||
gateway:
|
|
||||||
rail: "SETTLEMENT"
|
|
||||||
target_chat_id_env: CHSETTLE_GATEWAY_CHAT_ID
|
|
||||||
timeout_seconds: 345600
|
|
||||||
accepted_user_ids: []
|
|
||||||
success_reaction: "\U0001FAE1"
|
|
||||||
|
|
||||||
treasury:
|
|
||||||
execution_delay: 60s
|
|
||||||
poll_interval: 60s
|
|
||||||
ledger:
|
|
||||||
timeout: 5s
|
|
||||||
limits:
|
|
||||||
max_amount_per_operation: ""
|
|
||||||
max_daily_amount: ""
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
module github.com/tech/sendico/gateway/chsettle
|
|
||||||
|
|
||||||
go 1.25.7
|
|
||||||
|
|
||||||
replace github.com/tech/sendico/pkg => ../../pkg
|
|
||||||
|
|
||||||
replace github.com/tech/sendico/gateway/common => ../common
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/tech/sendico/gateway/common v0.1.0
|
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
|
||||||
go.uber.org/zap v1.27.1
|
|
||||||
google.golang.org/grpc v1.79.2
|
|
||||||
google.golang.org/protobuf v1.36.11
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
|
||||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
|
||||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
|
||||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
|
||||||
golang.org/x/net v0.51.0 // indirect
|
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
|
||||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
|
||||||
github.com/casbin/casbin/v2 v2.135.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/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ=
|
|
||||||
github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE=
|
|
||||||
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.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
|
||||||
github.com/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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
|
||||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
|
||||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
|
||||||
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
|
||||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
|
||||||
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/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.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
|
||||||
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/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
|
||||||
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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
|
||||||
go.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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
|
||||||
golang.org/x/sync v0.19.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
golang.org/x/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-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
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=
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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 Payment Gateway Service",
|
|
||||||
Revision: Revision,
|
|
||||||
Branch: Branch,
|
|
||||||
BuildUser: BuildUser,
|
|
||||||
BuildDate: BuildDate,
|
|
||||||
Version: Version,
|
|
||||||
}
|
|
||||||
return vf.Create(&info)
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
package serverimp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/internal/service/gateway"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
gatewaymongo "github.com/tech/sendico/gateway/chsettle/storage/mongo"
|
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
|
||||||
"github.com/tech/sendico/pkg/db"
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
|
||||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
|
||||||
"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[storage.Repository]
|
|
||||||
service *gateway.Service
|
|
||||||
|
|
||||||
discoveryWatcher *discovery.RegistryWatcher
|
|
||||||
discoveryReg *discovery.Registry
|
|
||||||
}
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
*grpcapp.Config `yaml:",inline"`
|
|
||||||
Gateway gatewayConfig `yaml:"gateway"`
|
|
||||||
Treasury treasuryConfig `yaml:"treasury"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type gatewayConfig struct {
|
|
||||||
Rail string `yaml:"rail"`
|
|
||||||
TargetChatIDEnv string `yaml:"target_chat_id_env"`
|
|
||||||
TimeoutSeconds int32 `yaml:"timeout_seconds"`
|
|
||||||
AcceptedUserIDs []string `yaml:"accepted_user_ids"`
|
|
||||||
SuccessReaction string `yaml:"success_reaction"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type treasuryConfig struct {
|
|
||||||
ExecutionDelay time.Duration `yaml:"execution_delay"`
|
|
||||||
PollInterval time.Duration `yaml:"poll_interval"`
|
|
||||||
Ledger ledgerConfig `yaml:"ledger"`
|
|
||||||
Limits treasuryLimitsConfig `yaml:"limits"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type treasuryLimitsConfig struct {
|
|
||||||
MaxAmountPerOperation string `yaml:"max_amount_per_operation"`
|
|
||||||
MaxDailyAmount string `yaml:"max_daily_amount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ledgerConfig struct {
|
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
if i.service != nil {
|
|
||||||
i.service.Shutdown()
|
|
||||||
}
|
|
||||||
if i.discoveryWatcher != nil {
|
|
||||||
i.discoveryWatcher.Stop()
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
i.app.Shutdown(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Imp) Start() error {
|
|
||||||
cfg, err := i.loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Error("Service startup aborted: invalid configuration", zap.Error(err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
i.config = cfg
|
|
||||||
|
|
||||||
var broker mb.Broker
|
|
||||||
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
|
|
||||||
broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging)
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Warn("Failed to create messaging broker", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if broker != nil {
|
|
||||||
registry := discovery.NewRegistry()
|
|
||||||
watcher, watcherErr := discovery.NewRegistryWatcher(i.logger, broker, registry)
|
|
||||||
if watcherErr != nil {
|
|
||||||
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(watcherErr))
|
|
||||||
} else if startErr := watcher.Start(); startErr != nil {
|
|
||||||
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(startErr))
|
|
||||||
} else {
|
|
||||||
i.discoveryWatcher = watcher
|
|
||||||
i.discoveryReg = registry
|
|
||||||
i.logger.Info("Discovery registry watcher started")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
|
||||||
return gatewaymongo.New(logger, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
|
||||||
invokeURI := ""
|
|
||||||
if cfg.GRPC != nil {
|
|
||||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
|
||||||
}
|
|
||||||
msgSettings := map[string]any(nil)
|
|
||||||
if cfg.Messaging != nil {
|
|
||||||
msgSettings = cfg.Messaging.Settings
|
|
||||||
}
|
|
||||||
gwCfg := gateway.Config{
|
|
||||||
Rail: cfg.Gateway.Rail,
|
|
||||||
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
|
|
||||||
TimeoutSeconds: cfg.Gateway.TimeoutSeconds,
|
|
||||||
AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs,
|
|
||||||
SuccessReaction: cfg.Gateway.SuccessReaction,
|
|
||||||
InvokeURI: invokeURI,
|
|
||||||
MessagingSettings: msgSettings,
|
|
||||||
DiscoveryRegistry: i.discoveryReg,
|
|
||||||
Treasury: gateway.TreasuryConfig{
|
|
||||||
ExecutionDelay: cfg.Treasury.ExecutionDelay,
|
|
||||||
PollInterval: cfg.Treasury.PollInterval,
|
|
||||||
Ledger: gateway.LedgerConfig{
|
|
||||||
Timeout: cfg.Treasury.Ledger.Timeout,
|
|
||||||
},
|
|
||||||
Limits: gateway.TreasuryLimitsConfig{
|
|
||||||
MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation,
|
|
||||||
MaxDailyAmount: cfg.Treasury.Limits.MaxDailyAmount,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
svc := gateway.NewService(logger, repo, producer, broker, gwCfg)
|
|
||||||
i.service = svc
|
|
||||||
return svc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "chsettle_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
|
|
||||||
if err != nil {
|
|
||||||
i.logger.Error("Service startup aborted: failed to construct app", zap.Error(err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
i.app = app
|
|
||||||
if err := i.app.Start(); err != nil {
|
|
||||||
i.logger.Error("Service startup aborted: app start failed", zap.Error(err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
grpcAddress := ""
|
|
||||||
metricsAddress := ""
|
|
||||||
if cfg.GRPC != nil {
|
|
||||||
grpcAddress = strings.TrimSpace(cfg.GRPC.Address)
|
|
||||||
}
|
|
||||||
if cfg.Metrics != nil {
|
|
||||||
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
|
|
||||||
}
|
|
||||||
i.logger.Info("ChimeraSettle gateway started",
|
|
||||||
zap.String("grpc_address", grpcAddress),
|
|
||||||
zap.String("metrics_address", metricsAddress))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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: ":50080",
|
|
||||||
EnableReflection: true,
|
|
||||||
EnableHealth: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.Metrics == nil {
|
|
||||||
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"}
|
|
||||||
}
|
|
||||||
if cfg.Treasury.ExecutionDelay <= 0 {
|
|
||||||
cfg.Treasury.ExecutionDelay = 30 * time.Second
|
|
||||||
}
|
|
||||||
if cfg.Treasury.PollInterval <= 0 {
|
|
||||||
cfg.Treasury.PollInterval = 30 * time.Second
|
|
||||||
}
|
|
||||||
if cfg.Treasury.Ledger.Timeout <= 0 {
|
|
||||||
cfg.Treasury.Ledger.Timeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail)
|
|
||||||
if cfg.Gateway.Rail == "" {
|
|
||||||
i.logger.Error("Invalid configuration: gateway rail is required")
|
|
||||||
return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail")
|
|
||||||
}
|
|
||||||
if !discovery.IsKnownRail(cfg.Gateway.Rail) {
|
|
||||||
i.logger.Error("Invalid configuration: gateway rail is unknown", zap.String("gateway_rail", cfg.Gateway.Rail))
|
|
||||||
return nil, merrors.InvalidArgument("gateway rail must be a known token", "gateway.rail")
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
serverimp "github.com/tech/sendico/gateway/chsettle/internal/server/internal"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
|
||||||
return serverimp.Create(logger, file, debug)
|
|
||||||
}
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
|
|
||||||
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
|
|
||||||
var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`)
|
|
||||||
|
|
||||||
func (s *Service) startConfirmationTimeoutWatcher() {
|
|
||||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.timeoutCancel != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
s.timeoutCtx = ctx
|
|
||||||
s.timeoutCancel = cancel
|
|
||||||
s.timeoutWG.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer s.timeoutWG.Done()
|
|
||||||
ticker := time.NewTicker(defaultConfirmationSweepInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
s.sweepExpiredConfirmations(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) sweepExpiredConfirmations(ctx context.Context) {
|
|
||||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expired, err := s.repo.PendingConfirmations().ListExpired(ctx, time.Now(), 100)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := range expired {
|
|
||||||
pending := &expired[i]
|
|
||||||
if strings.TrimSpace(pending.RequestID) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: pending.RequestID,
|
|
||||||
Status: model.ConfirmationStatusTimeout,
|
|
||||||
}
|
|
||||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
|
||||||
s.logger.Warn("Failed to publish timeout confirmation result", zap.Error(err), zap.String("request_id", pending.RequestID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
|
||||||
s.logger.Warn("Failed to remove expired pending confirmation", zap.Error(err), zap.String("request_id", pending.RequestID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) persistPendingConfirmation(ctx context.Context, request *model.ConfirmationRequest) error {
|
|
||||||
if request == nil {
|
|
||||||
return merrors.InvalidArgument("confirmation request is nil", "request")
|
|
||||||
}
|
|
||||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
|
||||||
return merrors.Internal("pending confirmations store unavailable")
|
|
||||||
}
|
|
||||||
timeout := request.TimeoutSeconds
|
|
||||||
if timeout <= 0 {
|
|
||||||
timeout = int32(defaultConfirmationTimeoutSeconds)
|
|
||||||
}
|
|
||||||
pending := &storagemodel.PendingConfirmation{
|
|
||||||
RequestID: strings.TrimSpace(request.RequestID),
|
|
||||||
TargetChatID: strings.TrimSpace(request.TargetChatID),
|
|
||||||
AcceptedUserIDs: normalizeStringList(request.AcceptedUserIDs),
|
|
||||||
RequestedMoney: request.RequestedMoney,
|
|
||||||
SourceService: strings.TrimSpace(request.SourceService),
|
|
||||||
Rail: strings.TrimSpace(request.Rail),
|
|
||||||
ExpiresAt: time.Now().Add(time.Duration(timeout) * time.Second),
|
|
||||||
}
|
|
||||||
return s.repo.PendingConfirmations().Upsert(ctx, pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) clearPendingConfirmation(ctx context.Context, requestID string) error {
|
|
||||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.repo.PendingConfirmations().DeleteByRequestID(ctx, requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) onConfirmationDispatch(ctx context.Context, dispatch *model.ConfirmationRequestDispatch) error {
|
|
||||||
if dispatch == nil {
|
|
||||||
return merrors.InvalidArgument("confirmation dispatch is nil", "dispatch")
|
|
||||||
}
|
|
||||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
|
||||||
return merrors.Internal("pending confirmations store unavailable")
|
|
||||||
}
|
|
||||||
requestID := strings.TrimSpace(dispatch.RequestID)
|
|
||||||
messageID := strings.TrimSpace(dispatch.MessageID)
|
|
||||||
if requestID == "" {
|
|
||||||
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
if messageID == "" {
|
|
||||||
return merrors.InvalidArgument("confirmation message_id is required", "message_id")
|
|
||||||
}
|
|
||||||
if err := s.repo.PendingConfirmations().AttachMessage(ctx, requestID, messageID); err != nil {
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
s.logger.Info("Confirmation dispatch ignored: pending request not found",
|
|
||||||
zap.String("request_id", requestID),
|
|
||||||
zap.String("message_id", messageID))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.logger.Warn("Failed to attach confirmation message id", zap.Error(err), zap.String("request_id", requestID), zap.String("message_id", messageID))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.logger.Info("Pending confirmation message attached", zap.String("request_id", requestID), zap.String("message_id", messageID))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) error {
|
|
||||||
if update == nil || update.Message == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
|
||||||
return merrors.Internal("pending confirmations store unavailable")
|
|
||||||
}
|
|
||||||
message := update.Message
|
|
||||||
replyToID := strings.TrimSpace(message.ReplyToMessageID)
|
|
||||||
if replyToID == "" {
|
|
||||||
s.handleTreasuryTelegramUpdate(ctx, update)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
replyFields := telegramReplyLogFields(update)
|
|
||||||
pending, err := s.repo.PendingConfirmations().FindByMessageID(ctx, replyToID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if pending == nil {
|
|
||||||
if s.handleTreasuryTelegramUpdate(ctx, update) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.logger.Warn("Telegram confirmation reply dropped",
|
|
||||||
append(replyFields,
|
|
||||||
zap.String("outcome", "dropped"),
|
|
||||||
zap.String("reason", "no_pending_confirmation"),
|
|
||||||
)...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
replyFields = append(replyFields,
|
|
||||||
zap.String("request_id", strings.TrimSpace(pending.RequestID)),
|
|
||||||
zap.String("target_chat_id", strings.TrimSpace(pending.TargetChatID)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) {
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: pending.RequestID,
|
|
||||||
Status: model.ConfirmationStatusTimeout,
|
|
||||||
}
|
|
||||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.logger.Info("Telegram confirmation reply processed",
|
|
||||||
append(replyFields,
|
|
||||||
zap.String("outcome", "processed"),
|
|
||||||
zap.String("result_status", string(result.Status)),
|
|
||||||
zap.String("reason", "expired_confirmation"),
|
|
||||||
)...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(message.ChatID) != strings.TrimSpace(pending.TargetChatID) {
|
|
||||||
s.logger.Warn("Telegram confirmation reply dropped",
|
|
||||||
append(replyFields,
|
|
||||||
zap.String("outcome", "dropped"),
|
|
||||||
zap.String("reason", "chat_mismatch"),
|
|
||||||
zap.String("expected_chat_id", strings.TrimSpace(pending.TargetChatID)),
|
|
||||||
)...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isUserAllowed(message.FromUserID, pending.AcceptedUserIDs) {
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: pending.RequestID,
|
|
||||||
Status: model.ConfirmationStatusRejected,
|
|
||||||
ParseError: "unauthorized_user",
|
|
||||||
RawReply: message,
|
|
||||||
}
|
|
||||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{
|
|
||||||
RequestID: pending.RequestID,
|
|
||||||
ChatID: pending.TargetChatID,
|
|
||||||
ReplyToMessageID: message.MessageID,
|
|
||||||
Text: "Only approved users can confirm this payment.",
|
|
||||||
}); e != nil {
|
|
||||||
s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...)
|
|
||||||
}
|
|
||||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.logger.Info("Telegram confirmation reply processed",
|
|
||||||
append(replyFields,
|
|
||||||
zap.String("outcome", "processed"),
|
|
||||||
zap.String("result_status", string(result.Status)),
|
|
||||||
zap.String("reason", "unauthorized_user"),
|
|
||||||
)...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
money, reason, err := parseConfirmationReply(message.Text)
|
|
||||||
if err != nil {
|
|
||||||
if markErr := s.repo.PendingConfirmations().MarkClarified(ctx, pending.RequestID); markErr != nil {
|
|
||||||
s.logger.Warn("Failed to mark confirmation as clarified", zap.Error(markErr), zap.String("request_id", pending.RequestID))
|
|
||||||
}
|
|
||||||
if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{
|
|
||||||
RequestID: pending.RequestID,
|
|
||||||
ChatID: pending.TargetChatID,
|
|
||||||
ReplyToMessageID: message.MessageID,
|
|
||||||
Text: clarificationMessage(reason),
|
|
||||||
}); e != nil {
|
|
||||||
s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...)
|
|
||||||
}
|
|
||||||
s.logger.Warn("Telegram confirmation reply dropped",
|
|
||||||
append(replyFields,
|
|
||||||
zap.String("outcome", "dropped"),
|
|
||||||
zap.String("reason", "invalid_reply_format"),
|
|
||||||
zap.String("parse_reason", reason),
|
|
||||||
)...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
status := model.ConfirmationStatusConfirmed
|
|
||||||
if pending.Clarified {
|
|
||||||
status = model.ConfirmationStatusClarified
|
|
||||||
}
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: pending.RequestID,
|
|
||||||
Money: money,
|
|
||||||
RawReply: message,
|
|
||||||
Status: status,
|
|
||||||
}
|
|
||||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.logger.Info("Telegram confirmation reply processed",
|
|
||||||
append(replyFields,
|
|
||||||
zap.String("outcome", "processed"),
|
|
||||||
zap.String("result_status", string(result.Status)),
|
|
||||||
)...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleTreasuryTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
|
||||||
if s == nil || s.treasury == nil || update == nil || update.Message == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return s.treasury.HandleUpdate(ctx, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
func telegramReplyLogFields(update *model.TelegramWebhookUpdate) []zap.Field {
|
|
||||||
if update == nil || update.Message == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
message := update.Message
|
|
||||||
return []zap.Field{
|
|
||||||
zap.Int64("update_id", update.UpdateID),
|
|
||||||
zap.String("message_id", strings.TrimSpace(message.MessageID)),
|
|
||||||
zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)),
|
|
||||||
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
|
|
||||||
zap.String("from_user_id", strings.TrimSpace(message.FromUserID)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) publishPendingConfirmationResult(pending *storagemodel.PendingConfirmation, result *model.ConfirmationResult) error {
|
|
||||||
if pending == nil || result == nil {
|
|
||||||
return merrors.InvalidArgument("pending confirmation context is required")
|
|
||||||
}
|
|
||||||
if s == nil || s.producer == nil {
|
|
||||||
return merrors.Internal("messaging producer is not configured")
|
|
||||||
}
|
|
||||||
sourceService := strings.TrimSpace(pending.SourceService)
|
|
||||||
if sourceService == "" {
|
|
||||||
sourceService = string(mservice.PaymentGateway)
|
|
||||||
}
|
|
||||||
rail := strings.TrimSpace(pending.Rail)
|
|
||||||
if rail == "" {
|
|
||||||
rail = s.rail
|
|
||||||
}
|
|
||||||
env := confirmations.ConfirmationResult(string(mservice.PaymentGateway), result, sourceService, rail)
|
|
||||||
if err := s.producer.SendMessage(env); err != nil {
|
|
||||||
s.logger.Warn("Failed to publish confirmation result", zap.Error(err),
|
|
||||||
zap.String("request_id", strings.TrimSpace(result.RequestID)),
|
|
||||||
zap.String("status", string(result.Status)),
|
|
||||||
zap.String("source_service", sourceService),
|
|
||||||
zap.String("rail", rail))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) sendTelegramText(_ context.Context, request *model.TelegramTextRequest) error {
|
|
||||||
if request == nil {
|
|
||||||
return merrors.InvalidArgument("telegram text request is nil", "request")
|
|
||||||
}
|
|
||||||
if s == nil || s.producer == nil {
|
|
||||||
return merrors.Internal("messaging producer is not configured")
|
|
||||||
}
|
|
||||||
request.ChatID = strings.TrimSpace(request.ChatID)
|
|
||||||
request.Text = strings.TrimSpace(request.Text)
|
|
||||||
request.ReplyToMessageID = strings.TrimSpace(request.ReplyToMessageID)
|
|
||||||
if request.ChatID == "" || request.Text == "" {
|
|
||||||
return merrors.InvalidArgument("telegram chat_id and text are required", "chat_id", "text")
|
|
||||||
}
|
|
||||||
env := tnotifications.TelegramText(string(mservice.PaymentGateway), request)
|
|
||||||
if err := s.producer.SendMessage(env); err != nil {
|
|
||||||
s.logger.Warn("Failed to publish telegram text request", zap.Error(err),
|
|
||||||
zap.String("request_id", request.RequestID),
|
|
||||||
zap.String("chat_id", request.ChatID),
|
|
||||||
zap.String("reply_to_message_id", request.ReplyToMessageID))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFinalConfirmationStatus(status model.ConfirmationStatus) bool {
|
|
||||||
switch status {
|
|
||||||
case model.ConfirmationStatusConfirmed, model.ConfirmationStatusRejected, model.ConfirmationStatusTimeout, model.ConfirmationStatusClarified:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isUserAllowed(userID string, allowed []string) bool {
|
|
||||||
allowed = normalizeStringList(allowed)
|
|
||||||
if len(allowed) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
userID = strings.TrimSpace(userID)
|
|
||||||
if userID == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, id := range allowed {
|
|
||||||
if id == userID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) {
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if text == "" {
|
|
||||||
return nil, "empty", merrors.InvalidArgument("empty reply")
|
|
||||||
}
|
|
||||||
parts := strings.Fields(text)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
|
|
||||||
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
|
|
||||||
}
|
|
||||||
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
|
|
||||||
}
|
|
||||||
if len(parts) > 2 {
|
|
||||||
return nil, "format", merrors.InvalidArgument("reply format is invalid")
|
|
||||||
}
|
|
||||||
amount := parts[0]
|
|
||||||
currency := parts[1]
|
|
||||||
if !amountPattern.MatchString(amount) {
|
|
||||||
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
|
|
||||||
}
|
|
||||||
if !currencyPattern.MatchString(currency) {
|
|
||||||
return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
|
|
||||||
}
|
|
||||||
return &paymenttypes.Money{
|
|
||||||
Amount: amount,
|
|
||||||
Currency: strings.ToUpper(currency),
|
|
||||||
}, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func clarificationMessage(reason string) string {
|
|
||||||
switch reason {
|
|
||||||
case "missing_currency":
|
|
||||||
return "Currency code is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
||||||
case "missing_amount":
|
|
||||||
return "Amount is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
||||||
case "invalid_amount":
|
|
||||||
return "Amount must be a decimal number. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
||||||
case "invalid_currency":
|
|
||||||
return "Currency must be a code like USD or EUR. Reply with \"<amount> <currency>\"."
|
|
||||||
default:
|
|
||||||
return "Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeStringList(values []string) []string {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]string, 0, len(values))
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, value := range values {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[value]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[value] = struct{}{}
|
|
||||||
result = append(result, value)
|
|
||||||
}
|
|
||||||
if len(result) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/connector/params"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
chsettleConnectorID = "chsettle"
|
|
||||||
connectorScenarioParam = "scenario"
|
|
||||||
connectorScenarioMetaKey = "chsettle_scenario"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
|
|
||||||
return &connectorv1.GetCapabilitiesResponse{
|
|
||||||
Capabilities: &connectorv1.ConnectorCapabilities{
|
|
||||||
ConnectorType: chsettleConnectorID,
|
|
||||||
Version: "",
|
|
||||||
SupportedAccountKinds: nil,
|
|
||||||
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER},
|
|
||||||
OperationParams: chsettleOperationParams(),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
|
|
||||||
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
|
|
||||||
return nil, merrors.NotImplemented("get_account: unsupported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
|
||||||
return nil, merrors.NotImplemented("list_accounts: unsupported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
|
|
||||||
return nil, merrors.NotImplemented("get_balance: unsupported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
|
|
||||||
if req == nil || req.GetOperation() == nil {
|
|
||||||
s.logger.Warn("Submit operation rejected", zap.String("reason", "operation is required"))
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
|
|
||||||
}
|
|
||||||
op := req.GetOperation()
|
|
||||||
s.logger.Debug("Submit operation request received",
|
|
||||||
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
|
|
||||||
zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())),
|
|
||||||
zap.String("operation_ref", strings.TrimSpace(op.GetOperationRef())))
|
|
||||||
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
|
|
||||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "idempotency_key is required"))...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
|
|
||||||
}
|
|
||||||
if op.GetType() != connectorv1.OperationType_TRANSFER {
|
|
||||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "unsupported operation type"))...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
|
|
||||||
}
|
|
||||||
reader := params.New(op.GetParams())
|
|
||||||
metadata := reader.StringMap("metadata")
|
|
||||||
if metadata == nil {
|
|
||||||
metadata = map[string]string{}
|
|
||||||
}
|
|
||||||
paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id"))
|
|
||||||
if paymentIntentID == "" {
|
|
||||||
paymentIntentID = strings.TrimSpace(reader.String("payment_ref"))
|
|
||||||
}
|
|
||||||
if paymentIntentID == "" {
|
|
||||||
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
|
|
||||||
}
|
|
||||||
if paymentIntentID == "" {
|
|
||||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "payment_intent_id is required"))...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: payment_intent_id is required", op, "")}}, nil
|
|
||||||
}
|
|
||||||
source := operationAccountID(op.GetFrom())
|
|
||||||
if source == "" {
|
|
||||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "from.account is required"))...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account is required", op, "")}}, nil
|
|
||||||
}
|
|
||||||
dest, err := transferDestinationFromOperation(op)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.Error(err))...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
|
||||||
}
|
|
||||||
amount := op.GetMoney()
|
|
||||||
if amount == nil {
|
|
||||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "money is required"))...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata[metadataPaymentIntentID] = paymentIntentID
|
|
||||||
quoteRef := strings.TrimSpace(reader.String("quote_ref"))
|
|
||||||
if quoteRef != "" {
|
|
||||||
metadata[metadataQuoteRef] = quoteRef
|
|
||||||
}
|
|
||||||
targetChatID := strings.TrimSpace(reader.String("target_chat_id"))
|
|
||||||
if targetChatID != "" {
|
|
||||||
metadata[metadataTargetChatID] = targetChatID
|
|
||||||
}
|
|
||||||
outgoingLeg := normalizeRail(reader.String("outgoing_leg"))
|
|
||||||
if outgoingLeg != "" {
|
|
||||||
metadata[metadataOutgoingLeg] = outgoingLeg
|
|
||||||
}
|
|
||||||
if scenario := strings.TrimSpace(reader.String(connectorScenarioParam)); scenario != "" {
|
|
||||||
metadata[connectorScenarioMetaKey] = scenario
|
|
||||||
}
|
|
||||||
s.logger.Debug("Submit operation parsed transfer metadata",
|
|
||||||
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
|
|
||||||
zap.String("payment_intent_id", paymentIntentID),
|
|
||||||
zap.String("quote_ref", quoteRef),
|
|
||||||
zap.String("target_chat_id", targetChatID),
|
|
||||||
zap.String("outgoing_leg", outgoingLeg),
|
|
||||||
zap.String("scenario_override", strings.TrimSpace(metadata[connectorScenarioMetaKey])))
|
|
||||||
|
|
||||||
normalizedAmount := normalizeMoneyForTransfer(amount)
|
|
||||||
logFields := append(operationLogFields(op),
|
|
||||||
zap.String("payment_intent_id", paymentIntentID),
|
|
||||||
zap.String("organization_ref", strings.TrimSpace(reader.String("organization_ref"))),
|
|
||||||
zap.String("source_wallet_ref", source),
|
|
||||||
zap.String("amount", strings.TrimSpace(normalizedAmount.GetAmount())),
|
|
||||||
zap.String("currency", strings.TrimSpace(normalizedAmount.GetCurrency())),
|
|
||||||
zap.String("quote_ref", quoteRef),
|
|
||||||
zap.String("operation_ref", req.Operation.GetOperationRef()),
|
|
||||||
zap.String("intent_ref", op.GetIntentRef()),
|
|
||||||
zap.String("outgoing_leg", outgoingLeg),
|
|
||||||
)
|
|
||||||
logFields = append(logFields, transferDestinationLogFields(dest)...)
|
|
||||||
s.logger.Debug("Submit operation forwarding to transfer handler", logFields...)
|
|
||||||
|
|
||||||
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
|
||||||
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
|
||||||
OrganizationRef: strings.TrimSpace(reader.String("organization_ref")),
|
|
||||||
SourceWalletRef: source,
|
|
||||||
Destination: dest,
|
|
||||||
Amount: normalizedAmount,
|
|
||||||
Metadata: metadata,
|
|
||||||
PaymentRef: paymentIntentID,
|
|
||||||
IntentRef: strings.TrimSpace(op.GetIntentRef()),
|
|
||||||
OperationRef: strings.TrimSpace(op.GetOperationRef()),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Submit operation transfer failed", append(logFields, zap.Error(err))...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
|
||||||
}
|
|
||||||
transfer := resp.GetTransfer()
|
|
||||||
operationID := strings.TrimSpace(transfer.GetOperationRef())
|
|
||||||
if operationID == "" {
|
|
||||||
s.logger.Warn("Submit operation transfer response missing operation_ref", append(logFields,
|
|
||||||
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
|
|
||||||
)...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{
|
|
||||||
Error: connectorError(connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE, "submit_operation: operation_ref is missing in transfer response", op, ""),
|
|
||||||
}}, nil
|
|
||||||
}
|
|
||||||
s.logger.Info("Submit operation transfer submitted", append(logFields,
|
|
||||||
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
|
|
||||||
zap.String("status", transfer.GetStatus().String()),
|
|
||||||
)...)
|
|
||||||
return &connectorv1.SubmitOperationResponse{
|
|
||||||
Receipt: &connectorv1.OperationReceipt{
|
|
||||||
OperationId: operationID,
|
|
||||||
Status: transferStatusToOperation(transfer.GetStatus()),
|
|
||||||
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
|
|
||||||
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
|
||||||
s.logger.Warn("Get operation rejected", zap.String("reason", "operation_id is required"))
|
|
||||||
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
|
||||||
}
|
|
||||||
operationID := strings.TrimSpace(req.GetOperationId())
|
|
||||||
s.logger.Debug("Get operation request received", zap.String("operation_id", operationID))
|
|
||||||
|
|
||||||
if s.repo == nil || s.repo.Payments() == nil {
|
|
||||||
s.logger.Warn("Get operation storage unavailable", zap.String("operation_id", operationID))
|
|
||||||
return nil, merrors.Internal("get_operation: storage is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := s.repo.Payments().FindByOperationRef(ctx, operationID)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if record == nil {
|
|
||||||
s.logger.Info("Get operation not found", zap.String("operation_id", operationID))
|
|
||||||
return nil, status.Error(codes.NotFound, "operation not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &connectorv1.GetOperationResponse{Operation: transferToOperation(transferFromPayment(record, nil))}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
|
||||||
return nil, merrors.NotImplemented("list_operations: unsupported")
|
|
||||||
}
|
|
||||||
|
|
||||||
func chsettleOperationParams() []*connectorv1.OperationParamSpec {
|
|
||||||
return []*connectorv1.OperationParamSpec{
|
|
||||||
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
|
|
||||||
{Key: "payment_intent_id", Type: connectorv1.ParamType_STRING, Required: true},
|
|
||||||
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false},
|
|
||||||
{Key: "quote_ref", Type: connectorv1.ParamType_STRING, Required: false},
|
|
||||||
{Key: "target_chat_id", Type: connectorv1.ParamType_STRING, Required: false},
|
|
||||||
{Key: "outgoing_leg", Type: connectorv1.ParamType_STRING, Required: false},
|
|
||||||
{Key: connectorScenarioParam, Type: connectorv1.ParamType_STRING, Required: false},
|
|
||||||
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
|
|
||||||
if op == nil {
|
|
||||||
return nil, merrors.InvalidArgument("transfer: operation is required")
|
|
||||||
}
|
|
||||||
if to := op.GetTo(); to != nil {
|
|
||||||
if account := to.GetAccount(); account != nil {
|
|
||||||
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
|
|
||||||
}
|
|
||||||
if ext := to.GetExternal(); ext != nil {
|
|
||||||
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money {
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
currency := strings.TrimSpace(m.GetCurrency())
|
|
||||||
if idx := strings.Index(currency, "-"); idx > 0 {
|
|
||||||
currency = currency[:idx]
|
|
||||||
}
|
|
||||||
return &moneyv1.Money{
|
|
||||||
Amount: strings.TrimSpace(m.GetAmount()),
|
|
||||||
Currency: currency,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
|
|
||||||
if transfer == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
op := &connectorv1.Operation{
|
|
||||||
OperationId: strings.TrimSpace(transfer.GetOperationRef()),
|
|
||||||
Type: connectorv1.OperationType_TRANSFER,
|
|
||||||
Status: transferStatusToOperation(transfer.GetStatus()),
|
|
||||||
Money: transfer.GetRequestedAmount(),
|
|
||||||
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
|
|
||||||
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
|
|
||||||
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
|
|
||||||
CreatedAt: transfer.GetCreatedAt(),
|
|
||||||
UpdatedAt: transfer.GetUpdatedAt(),
|
|
||||||
}
|
|
||||||
params := map[string]interface{}{}
|
|
||||||
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
|
|
||||||
params["payment_ref"] = paymentRef
|
|
||||||
}
|
|
||||||
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
|
|
||||||
params["organization_ref"] = organizationRef
|
|
||||||
}
|
|
||||||
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
|
|
||||||
params["failure_reason"] = failureReason
|
|
||||||
}
|
|
||||||
if len(params) > 0 {
|
|
||||||
op.Params = structFromMap(params)
|
|
||||||
}
|
|
||||||
if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
|
|
||||||
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
|
||||||
ConnectorId: chsettleConnectorID,
|
|
||||||
AccountId: source,
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
if dest := transfer.GetDestination(); dest != nil {
|
|
||||||
switch d := dest.GetDestination().(type) {
|
|
||||||
case *chainv1.TransferDestination_ManagedWalletRef:
|
|
||||||
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
|
||||||
ConnectorId: chsettleConnectorID,
|
|
||||||
AccountId: strings.TrimSpace(d.ManagedWalletRef),
|
|
||||||
}}}
|
|
||||||
case *chainv1.TransferDestination_ExternalAddress:
|
|
||||||
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
|
|
||||||
ExternalRef: strings.TrimSpace(d.ExternalAddress),
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return op
|
|
||||||
}
|
|
||||||
|
|
||||||
func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
|
|
||||||
switch status {
|
|
||||||
|
|
||||||
case chainv1.TransferStatus_TRANSFER_CREATED:
|
|
||||||
return connectorv1.OperationStatus_OPERATION_CREATED
|
|
||||||
|
|
||||||
case chainv1.TransferStatus_TRANSFER_PROCESSING:
|
|
||||||
return connectorv1.OperationStatus_OPERATION_PROCESSING
|
|
||||||
|
|
||||||
case chainv1.TransferStatus_TRANSFER_WAITING:
|
|
||||||
return connectorv1.OperationStatus_OPERATION_WAITING
|
|
||||||
|
|
||||||
case chainv1.TransferStatus_TRANSFER_SUCCESS:
|
|
||||||
return connectorv1.OperationStatus_OPERATION_SUCCESS
|
|
||||||
|
|
||||||
case chainv1.TransferStatus_TRANSFER_FAILED:
|
|
||||||
return connectorv1.OperationStatus_OPERATION_FAILED
|
|
||||||
|
|
||||||
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
|
||||||
return connectorv1.OperationStatus_OPERATION_CANCELLED
|
|
||||||
|
|
||||||
case chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED:
|
|
||||||
fallthrough
|
|
||||||
default:
|
|
||||||
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationAccountID(party *connectorv1.OperationParty) string {
|
|
||||||
if party == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if account := party.GetAccount(); account != nil {
|
|
||||||
return strings.TrimSpace(account.GetAccountId())
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func structFromMap(values map[string]interface{}) *structpb.Struct {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result, err := structpb.NewStruct(values)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func operationLogFields(op *connectorv1.Operation) []zap.Field {
|
|
||||||
if op == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []zap.Field{
|
|
||||||
zap.String("operation_id", strings.TrimSpace(op.GetOperationId())),
|
|
||||||
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
|
|
||||||
zap.String("correlation_id", strings.TrimSpace(op.GetCorrelationId())),
|
|
||||||
zap.String("parent_intent_id", strings.TrimSpace(op.GetParentIntentId())),
|
|
||||||
zap.String("operation_type", op.GetType().String()),
|
|
||||||
zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func transferDestinationLogFields(dest *chainv1.TransferDestination) []zap.Field {
|
|
||||||
if dest == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch d := dest.GetDestination().(type) {
|
|
||||||
case *chainv1.TransferDestination_ManagedWalletRef:
|
|
||||||
return []zap.Field{
|
|
||||||
zap.String("destination_type", "managed_wallet"),
|
|
||||||
zap.String("destination_ref", strings.TrimSpace(d.ManagedWalletRef)),
|
|
||||||
}
|
|
||||||
case *chainv1.TransferDestination_ExternalAddress:
|
|
||||||
return []zap.Field{
|
|
||||||
zap.String("destination_type", "external_address"),
|
|
||||||
zap.String("destination_ref", strings.TrimSpace(d.ExternalAddress)),
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return []zap.Field{zap.String("destination_type", "unknown")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
|
||||||
err := &connectorv1.ConnectorError{
|
|
||||||
Code: code,
|
|
||||||
Message: strings.TrimSpace(message),
|
|
||||||
AccountId: strings.TrimSpace(accountID),
|
|
||||||
}
|
|
||||||
if op != nil {
|
|
||||||
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
|
|
||||||
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
|
|
||||||
err.OperationId = strings.TrimSpace(op.GetOperationId())
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapErrorCode(err error) connectorv1.ErrorCode {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, merrors.ErrInvalidArg):
|
|
||||||
return connectorv1.ErrorCode_INVALID_PARAMS
|
|
||||||
case errors.Is(err, merrors.ErrNoData):
|
|
||||||
return connectorv1.ErrorCode_NOT_FOUND
|
|
||||||
case errors.Is(err, merrors.ErrNotImplemented):
|
|
||||||
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
|
|
||||||
case errors.Is(err, merrors.ErrInternal):
|
|
||||||
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
|
|
||||||
default:
|
|
||||||
return connectorv1.ErrorCode_PROVIDER_ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSubmitOperation_UsesOperationRefAsOperationID(t *testing.T) {
|
|
||||||
svc, _, _ := newTestService(t)
|
|
||||||
svc.chatID = "1"
|
|
||||||
|
|
||||||
req := &connectorv1.SubmitOperationRequest{
|
|
||||||
Operation: &connectorv1.Operation{
|
|
||||||
Type: connectorv1.OperationType_TRANSFER,
|
|
||||||
IdempotencyKey: "idem-settlement-1",
|
|
||||||
OperationRef: "payment-1:hop_2_settlement_fx_convert",
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
Money: &moneyv1.Money{Amount: "1.00", Currency: "USDT"},
|
|
||||||
From: &connectorv1.OperationParty{
|
|
||||||
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
|
||||||
ConnectorId: chsettleConnectorID,
|
|
||||||
AccountId: "wallet-src",
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
To: &connectorv1.OperationParty{
|
|
||||||
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
|
||||||
ConnectorId: chsettleConnectorID,
|
|
||||||
AccountId: "wallet-dst",
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
Params: structFromMap(map[string]interface{}{
|
|
||||||
"payment_ref": "payment-1",
|
|
||||||
"organization_ref": "org-1",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := svc.SubmitOperation(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SubmitOperation returned error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.GetReceipt() == nil {
|
|
||||||
t.Fatal("expected receipt")
|
|
||||||
}
|
|
||||||
if got := resp.GetReceipt().GetError(); got != nil {
|
|
||||||
t.Fatalf("expected no connector error, got: %v", got)
|
|
||||||
}
|
|
||||||
if got, want := resp.GetReceipt().GetOperationId(), "payment-1:hop_2_settlement_fx_convert"; got != want {
|
|
||||||
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := resp.GetReceipt().GetProviderRef(), "idem-settlement-1"; got != want {
|
|
||||||
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetOperation_UsesOperationRefIdentity(t *testing.T) {
|
|
||||||
svc, repo, _ := newTestService(t)
|
|
||||||
|
|
||||||
record := &storagemodel.PaymentRecord{
|
|
||||||
IdempotencyKey: "idem-settlement-2",
|
|
||||||
OperationRef: "payment-2:hop_2_settlement_fx_convert",
|
|
||||||
PaymentIntentID: "pi-2",
|
|
||||||
PaymentRef: "payment-2",
|
|
||||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
|
|
||||||
Status: storagemodel.PaymentStatusSuccess,
|
|
||||||
}
|
|
||||||
if err := repo.payments.Upsert(context.Background(), record); err != nil {
|
|
||||||
t.Fatalf("failed to seed payment record: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
|
|
||||||
OperationId: "payment-2:hop_2_settlement_fx_convert",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetOperation returned error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.GetOperation() == nil {
|
|
||||||
t.Fatal("expected operation")
|
|
||||||
}
|
|
||||||
if got, want := resp.GetOperation().GetOperationId(), "payment-2:hop_2_settlement_fx_convert"; got != want {
|
|
||||||
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := resp.GetOperation().GetProviderRef(), "idem-settlement-2"; got != want {
|
|
||||||
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetOperation_DoesNotResolveByIdempotencyKey(t *testing.T) {
|
|
||||||
svc, repo, _ := newTestService(t)
|
|
||||||
|
|
||||||
record := &storagemodel.PaymentRecord{
|
|
||||||
IdempotencyKey: "idem-settlement-3",
|
|
||||||
OperationRef: "payment-3:hop_2_settlement_fx_convert",
|
|
||||||
PaymentIntentID: "pi-3",
|
|
||||||
PaymentRef: "payment-3",
|
|
||||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
|
|
||||||
Status: storagemodel.PaymentStatusSuccess,
|
|
||||||
}
|
|
||||||
if err := repo.payments.Upsert(context.Background(), record); err != nil {
|
|
||||||
t.Fatalf("failed to seed payment record: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
|
|
||||||
OperationId: "idem-settlement-3",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected not found error")
|
|
||||||
}
|
|
||||||
if status.Code(err) != codes.NotFound {
|
|
||||||
t.Fatalf("unexpected error code: got=%s want=%s", status.Code(err), codes.NotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
|
|
||||||
"github.com/tech/sendico/pkg/db/transaction"
|
|
||||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
|
||||||
)
|
|
||||||
|
|
||||||
type tgOutboxProvider interface {
|
|
||||||
Outbox() gatewayoutbox.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
type tgTransactionProvider interface {
|
|
||||||
TransactionFactory() transaction.Factory
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) outboxStore() gatewayoutbox.Store {
|
|
||||||
provider, ok := s.repo.(tgOutboxProvider)
|
|
||||||
if !ok || provider == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return provider.Outbox()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) startOutboxReliableProducer() error {
|
|
||||||
if s == nil || s.repo == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error {
|
|
||||||
if err := s.startOutboxReliableProducer(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.outbox.Send(ctx, env)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
|
|
||||||
provider, ok := s.repo.(tgTransactionProvider)
|
|
||||||
if !ok || provider == nil || provider.TransactionFactory() == nil {
|
|
||||||
return cb(ctx)
|
|
||||||
}
|
|
||||||
return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb)
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"hash/fnv"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
scenarioMetadataKey = "chsettle_scenario"
|
|
||||||
scenarioMetadataAliasKey = "scenario"
|
|
||||||
)
|
|
||||||
|
|
||||||
type settlementScenario struct {
|
|
||||||
Name string
|
|
||||||
InitialStatus storagemodel.PaymentStatus
|
|
||||||
FinalStatus storagemodel.PaymentStatus
|
|
||||||
FinalDelay time.Duration
|
|
||||||
FailureReason string
|
|
||||||
}
|
|
||||||
|
|
||||||
type settlementScenarioTrace struct {
|
|
||||||
Source string
|
|
||||||
OverrideRaw string
|
|
||||||
OverrideNormalized string
|
|
||||||
AmountRaw string
|
|
||||||
AmountCurrency string
|
|
||||||
BucketSlot int
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioFastSuccess = settlementScenario{
|
|
||||||
Name: "fast_success",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusSuccess,
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioSlowSuccess = settlementScenario{
|
|
||||||
Name: "slow_success",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
|
||||||
FinalStatus: storagemodel.PaymentStatusSuccess,
|
|
||||||
FinalDelay: 30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioFailImmediate = settlementScenario{
|
|
||||||
Name: "fail_immediate",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusFailed,
|
|
||||||
FailureReason: "simulated_fail_immediate",
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioFailTimeout = settlementScenario{
|
|
||||||
Name: "fail_timeout",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
|
||||||
FinalStatus: storagemodel.PaymentStatusFailed,
|
|
||||||
FinalDelay: 45 * time.Second,
|
|
||||||
FailureReason: "simulated_fail_timeout",
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioStuckPending = settlementScenario{
|
|
||||||
Name: "stuck_pending",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioRetryThenSuccess = settlementScenario{
|
|
||||||
Name: "retry_then_success",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusProcessing,
|
|
||||||
FinalStatus: storagemodel.PaymentStatusSuccess,
|
|
||||||
FinalDelay: 25 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioWebhookDelayedSuccess = settlementScenario{
|
|
||||||
Name: "webhook_delayed_success",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
|
||||||
FinalStatus: storagemodel.PaymentStatusSuccess,
|
|
||||||
FinalDelay: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioSlowThenFail = settlementScenario{
|
|
||||||
Name: "slow_then_fail",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusProcessing,
|
|
||||||
FinalStatus: storagemodel.PaymentStatusFailed,
|
|
||||||
FinalDelay: 75 * time.Second,
|
|
||||||
FailureReason: "simulated_slow_then_fail",
|
|
||||||
}
|
|
||||||
|
|
||||||
var scenarioPartialProgressStuck = settlementScenario{
|
|
||||||
Name: "partial_progress_stuck",
|
|
||||||
InitialStatus: storagemodel.PaymentStatusProcessing,
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSettlementScenario(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) settlementScenario {
|
|
||||||
scenario, _ := resolveSettlementScenarioWithTrace(idempotencyKey, amount, metadata)
|
|
||||||
return scenario
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSettlementScenarioWithTrace(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) (settlementScenario, settlementScenarioTrace) {
|
|
||||||
trace := settlementScenarioTrace{
|
|
||||||
BucketSlot: -1,
|
|
||||||
}
|
|
||||||
if amount != nil {
|
|
||||||
trace.AmountRaw = strings.TrimSpace(amount.Amount)
|
|
||||||
trace.AmountCurrency = strings.TrimSpace(amount.Currency)
|
|
||||||
}
|
|
||||||
overrideScenario, overrideRaw, overrideNormalized, overrideApplied := parseScenarioOverride(metadata)
|
|
||||||
if overrideRaw != "" {
|
|
||||||
trace.OverrideRaw = overrideRaw
|
|
||||||
trace.OverrideNormalized = overrideNormalized
|
|
||||||
}
|
|
||||||
if overrideApplied {
|
|
||||||
trace.Source = "explicit_override"
|
|
||||||
return overrideScenario, trace
|
|
||||||
}
|
|
||||||
slot, ok := amountModuloSlot(amount)
|
|
||||||
if ok {
|
|
||||||
if trace.OverrideRaw != "" {
|
|
||||||
trace.Source = "invalid_override_amount_bucket"
|
|
||||||
} else {
|
|
||||||
trace.Source = "amount_bucket"
|
|
||||||
}
|
|
||||||
trace.BucketSlot = slot
|
|
||||||
return scenarioBySlot(slot, idempotencyKey), trace
|
|
||||||
}
|
|
||||||
slot = hashModulo(idempotencyKey, 1000)
|
|
||||||
if trace.OverrideRaw != "" {
|
|
||||||
trace.Source = "invalid_override_idempotency_hash_bucket"
|
|
||||||
} else {
|
|
||||||
trace.Source = "idempotency_hash_bucket"
|
|
||||||
}
|
|
||||||
trace.BucketSlot = slot
|
|
||||||
return scenarioBySlot(slot, idempotencyKey), trace
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseScenarioOverride(metadata map[string]string) (settlementScenario, string, string, bool) {
|
|
||||||
if len(metadata) == 0 {
|
|
||||||
return settlementScenario{}, "", "", false
|
|
||||||
}
|
|
||||||
overrideRaw := strings.TrimSpace(metadata[scenarioMetadataKey])
|
|
||||||
if overrideRaw == "" {
|
|
||||||
overrideRaw = strings.TrimSpace(metadata[scenarioMetadataAliasKey])
|
|
||||||
}
|
|
||||||
if overrideRaw == "" {
|
|
||||||
return settlementScenario{}, "", "", false
|
|
||||||
}
|
|
||||||
scenario, normalized, ok := scenarioByName(overrideRaw)
|
|
||||||
return scenario, overrideRaw, normalized, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func scenarioByName(value string) (settlementScenario, string, bool) {
|
|
||||||
key := normalizeScenarioName(value)
|
|
||||||
switch key {
|
|
||||||
case "fast_success", "success_fast", "instant_success":
|
|
||||||
return scenarioFastSuccess, key, true
|
|
||||||
case "slow_success", "success_slow":
|
|
||||||
return scenarioSlowSuccess, key, true
|
|
||||||
case "fail_immediate", "immediate_fail", "failed":
|
|
||||||
return scenarioFailImmediate, key, true
|
|
||||||
case "fail_timeout", "timeout_fail":
|
|
||||||
return scenarioFailTimeout, key, true
|
|
||||||
case "stuck", "stuck_pending", "pending_stuck":
|
|
||||||
return scenarioStuckPending, key, true
|
|
||||||
case "retry_then_success":
|
|
||||||
return scenarioRetryThenSuccess, key, true
|
|
||||||
case "webhook_delayed_success":
|
|
||||||
return scenarioWebhookDelayedSuccess, key, true
|
|
||||||
case "slow_then_fail":
|
|
||||||
return scenarioSlowThenFail, key, true
|
|
||||||
case "partial_progress_stuck":
|
|
||||||
return scenarioPartialProgressStuck, key, true
|
|
||||||
case "chaos", "chaos_random_seeded":
|
|
||||||
return scenarioBySlot(950, ""), key, true
|
|
||||||
default:
|
|
||||||
return settlementScenario{}, key, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeScenarioName(value string) string {
|
|
||||||
key := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
key = strings.ReplaceAll(key, "-", "_")
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
func scenarioBySlot(slot int, seed string) settlementScenario {
|
|
||||||
switch {
|
|
||||||
case slot < 100:
|
|
||||||
return scenarioFastSuccess
|
|
||||||
case slot < 200:
|
|
||||||
return scenarioSlowSuccess
|
|
||||||
case slot < 300:
|
|
||||||
return scenarioFailImmediate
|
|
||||||
case slot < 400:
|
|
||||||
return scenarioFailTimeout
|
|
||||||
case slot < 500:
|
|
||||||
return scenarioStuckPending
|
|
||||||
case slot < 600:
|
|
||||||
return scenarioRetryThenSuccess
|
|
||||||
case slot < 700:
|
|
||||||
return scenarioWebhookDelayedSuccess
|
|
||||||
case slot < 800:
|
|
||||||
return scenarioSlowThenFail
|
|
||||||
case slot < 900:
|
|
||||||
return scenarioPartialProgressStuck
|
|
||||||
default:
|
|
||||||
return chaosScenario(seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func chaosScenario(seed string) settlementScenario {
|
|
||||||
choices := []settlementScenario{
|
|
||||||
scenarioFastSuccess,
|
|
||||||
scenarioSlowSuccess,
|
|
||||||
scenarioFailImmediate,
|
|
||||||
scenarioFailTimeout,
|
|
||||||
scenarioStuckPending,
|
|
||||||
scenarioSlowThenFail,
|
|
||||||
}
|
|
||||||
idx := hashModulo(seed, len(choices))
|
|
||||||
return choices[idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
func amountModuloSlot(amount *paymenttypes.Money) (int, bool) {
|
|
||||||
if amount == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
raw := strings.TrimSpace(amount.Amount)
|
|
||||||
if raw == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
sign := 1
|
|
||||||
if strings.HasPrefix(raw, "+") {
|
|
||||||
raw = strings.TrimPrefix(raw, "+")
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(raw, "-") {
|
|
||||||
sign = -1
|
|
||||||
raw = strings.TrimPrefix(raw, "-")
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(raw, ".", 3)
|
|
||||||
if len(parts) == 0 || len(parts) > 2 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
whole := parts[0]
|
|
||||||
if whole == "" || !digitsOnly(whole) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
frac := "00"
|
|
||||||
if len(parts) == 2 {
|
|
||||||
f := parts[1]
|
|
||||||
if f == "" || !digitsOnly(f) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if len(f) >= 2 {
|
|
||||||
frac = f[:2]
|
|
||||||
} else {
|
|
||||||
frac = f + "0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wholeMod := digitsMod(whole, 10)
|
|
||||||
fracVal, _ := strconv.Atoi(frac)
|
|
||||||
slot := (wholeMod*100 + fracVal) % 1000
|
|
||||||
if sign < 0 {
|
|
||||||
slot = (-slot + 1000) % 1000
|
|
||||||
}
|
|
||||||
return slot, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func digitsOnly(value string) bool {
|
|
||||||
if value == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := 0; i < len(value); i++ {
|
|
||||||
if value[i] < '0' || value[i] > '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func digitsMod(value string, mod int) int {
|
|
||||||
if mod <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
result := 0
|
|
||||||
for i := 0; i < len(value); i++ {
|
|
||||||
digit := int(value[i] - '0')
|
|
||||||
result = (result*10 + digit) % mod
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashModulo(input string, mod int) int {
|
|
||||||
if mod <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
h := fnv.New32a()
|
|
||||||
_, _ = h.Write([]byte(strings.TrimSpace(input)))
|
|
||||||
return int(h.Sum32() % uint32(mod))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s settlementScenario) delayedTransitionEnabled() bool {
|
|
||||||
return s.FinalStatus != "" && s.FinalDelay > 0
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveSettlementScenario_AmountBuckets(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
amount string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "bucket_000_fast_success", amount: "10.00", want: scenarioFastSuccess.Name},
|
|
||||||
{name: "bucket_100_slow_success", amount: "11.00", want: scenarioSlowSuccess.Name},
|
|
||||||
{name: "bucket_200_fail_immediate", amount: "12.00", want: scenarioFailImmediate.Name},
|
|
||||||
{name: "bucket_300_fail_timeout", amount: "13.00", want: scenarioFailTimeout.Name},
|
|
||||||
{name: "bucket_400_stuck_pending", amount: "14.00", want: scenarioStuckPending.Name},
|
|
||||||
{name: "bucket_500_retry_then_success", amount: "15.00", want: scenarioRetryThenSuccess.Name},
|
|
||||||
{name: "bucket_600_webhook_delayed_success", amount: "16.00", want: scenarioWebhookDelayedSuccess.Name},
|
|
||||||
{name: "bucket_700_slow_then_fail", amount: "17.00", want: scenarioSlowThenFail.Name},
|
|
||||||
{name: "bucket_800_partial_progress_stuck", amount: "18.00", want: scenarioPartialProgressStuck.Name},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := resolveSettlementScenario("idem-"+tc.name, &paymenttypes.Money{Amount: tc.amount, Currency: "USD"}, nil)
|
|
||||||
if got.Name != tc.want {
|
|
||||||
t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveSettlementScenario_ExplicitOverride(t *testing.T) {
|
|
||||||
got := resolveSettlementScenario("idem-override", &paymenttypes.Money{Amount: "10.00", Currency: "USD"}, map[string]string{
|
|
||||||
scenarioMetadataKey: "stuck",
|
|
||||||
})
|
|
||||||
if got.Name != scenarioStuckPending.Name {
|
|
||||||
t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, scenarioStuckPending.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmitTransfer_UsesImmediateFailureScenario(t *testing.T) {
|
|
||||||
svc, repo, _ := newTestService(t)
|
|
||||||
|
|
||||||
resp, err := svc.SubmitTransfer(context.Background(), &chainv1.SubmitTransferRequest{
|
|
||||||
IdempotencyKey: "idem-immediate-fail",
|
|
||||||
IntentRef: "intent-immediate-fail",
|
|
||||||
OperationRef: "op-immediate-fail",
|
|
||||||
PaymentRef: "payment-immediate-fail",
|
|
||||||
Amount: &moneyv1.Money{Amount: "9.99", Currency: "USD"},
|
|
||||||
Metadata: map[string]string{
|
|
||||||
scenarioMetadataAliasKey: "fail_immediate",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("submit transfer failed: %v", err)
|
|
||||||
}
|
|
||||||
if got, want := resp.GetTransfer().GetStatus(), chainv1.TransferStatus_TRANSFER_FAILED; got != want {
|
|
||||||
t.Fatalf("transfer status mismatch: got=%v want=%v", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
record := repo.payments.records["idem-immediate-fail"]
|
|
||||||
if record == nil {
|
|
||||||
t.Fatalf("expected payment record")
|
|
||||||
}
|
|
||||||
if got, want := record.Status, storagemodel.PaymentStatusFailed; got != want {
|
|
||||||
t.Fatalf("record status mismatch: got=%s want=%s", got, want)
|
|
||||||
}
|
|
||||||
if got, want := record.Scenario, scenarioFailImmediate.Name; got != want {
|
|
||||||
t.Fatalf("record scenario mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyScenarioTransition_SetsFinalSuccess(t *testing.T) {
|
|
||||||
svc, repo, _ := newTestService(t)
|
|
||||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
|
||||||
IdempotencyKey: "idem-transition-success",
|
|
||||||
IntentRef: "intent-transition-success",
|
|
||||||
OperationRef: "op-transition-success",
|
|
||||||
PaymentRef: "payment-transition-success",
|
|
||||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USD"},
|
|
||||||
Status: storagemodel.PaymentStatusWaiting,
|
|
||||||
Scenario: scenarioSlowSuccess.Name,
|
|
||||||
})
|
|
||||||
|
|
||||||
svc.applyScenarioTransition("idem-transition-success", scenarioSlowSuccess)
|
|
||||||
|
|
||||||
record := repo.payments.records["idem-transition-success"]
|
|
||||||
if record == nil {
|
|
||||||
t.Fatalf("expected payment record")
|
|
||||||
}
|
|
||||||
if got, want := record.Status, storagemodel.PaymentStatusSuccess; got != want {
|
|
||||||
t.Fatalf("record status mismatch: got=%s want=%s", got, want)
|
|
||||||
}
|
|
||||||
if record.ExecutedMoney == nil {
|
|
||||||
t.Fatalf("expected executed money")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,417 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
envelope "github.com/tech/sendico/pkg/messaging/envelope"
|
|
||||||
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
|
|
||||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
//
|
|
||||||
// FAKE STORES
|
|
||||||
//
|
|
||||||
|
|
||||||
type fakePaymentsStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
records map[string]*storagemodel.PaymentRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if f.records == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return f.records[key], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if f.records == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
for _, record := range f.records {
|
|
||||||
if record != nil && record.OperationRef == key {
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if f.records == nil {
|
|
||||||
f.records = map[string]*storagemodel.PaymentRecord{}
|
|
||||||
}
|
|
||||||
f.records[record.IdempotencyKey] = record
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeTelegramStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
records map[string]*storagemodel.TelegramConfirmation
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if f.records == nil {
|
|
||||||
f.records = map[string]*storagemodel.TelegramConfirmation{}
|
|
||||||
}
|
|
||||||
f.records[record.RequestID] = record
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeRepo struct {
|
|
||||||
payments *fakePaymentsStore
|
|
||||||
tg *fakeTelegramStore
|
|
||||||
pending *fakePendingStore
|
|
||||||
treasury storage.TreasuryRequestsStore
|
|
||||||
users storage.TreasuryTelegramUsersStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
|
||||||
return f.payments
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
|
|
||||||
return f.tg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
|
|
||||||
return f.pending
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore {
|
|
||||||
return f.treasury
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeRepo) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
|
|
||||||
return f.users
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakePendingStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
records map[string]*storagemodel.PendingConfirmation
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePendingStore) Upsert(_ context.Context, record *storagemodel.PendingConfirmation) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if f.records == nil {
|
|
||||||
f.records = map[string]*storagemodel.PendingConfirmation{}
|
|
||||||
}
|
|
||||||
cp := *record
|
|
||||||
f.records[record.RequestID] = &cp
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePendingStore) FindByRequestID(_ context.Context, requestID string) (*storagemodel.PendingConfirmation, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if f.records == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return f.records[requestID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePendingStore) FindByMessageID(_ context.Context, messageID string) (*storagemodel.PendingConfirmation, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
for _, record := range f.records {
|
|
||||||
if record != nil && record.MessageID == messageID {
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePendingStore) MarkClarified(_ context.Context, requestID string) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if record := f.records[requestID]; record != nil {
|
|
||||||
record.Clarified = true
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePendingStore) AttachMessage(_ context.Context, requestID string, messageID string) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if record := f.records[requestID]; record != nil {
|
|
||||||
if record.MessageID == "" {
|
|
||||||
record.MessageID = messageID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
delete(f.records, requestID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]storagemodel.PendingConfirmation, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
result := make([]storagemodel.PendingConfirmation, 0)
|
|
||||||
for _, record := range f.records {
|
|
||||||
if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, *record)
|
|
||||||
if int64(len(result)) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
|
|
||||||
//
|
|
||||||
|
|
||||||
type fakeBroker struct{}
|
|
||||||
|
|
||||||
func (f *fakeBroker) Publish(_ envelope.Envelope) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeBroker) Subscribe(event model.NotificationEvent) (<-chan envelope.Envelope, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeBroker) Unsubscribe(event model.NotificationEvent, subChan <-chan envelope.Envelope) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// CAPTURE ONLY TELEGRAM REACTIONS
|
|
||||||
//
|
|
||||||
|
|
||||||
type captureProducer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
reactions []envelope.Envelope
|
|
||||||
sig string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *captureProducer) SendMessage(env envelope.Envelope) error {
|
|
||||||
if env.GetSignature().ToString() != c.sig {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
c.mu.Lock()
|
|
||||||
c.reactions = append(c.reactions, env)
|
|
||||||
c.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// TESTS
|
|
||||||
//
|
|
||||||
|
|
||||||
func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
|
|
||||||
logger := mloggerfactory.NewLogger(false)
|
|
||||||
|
|
||||||
repo := &fakeRepo{
|
|
||||||
payments: &fakePaymentsStore{},
|
|
||||||
tg: &fakeTelegramStore{},
|
|
||||||
pending: &fakePendingStore{},
|
|
||||||
}
|
|
||||||
|
|
||||||
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
|
|
||||||
RequestID: "x",
|
|
||||||
ChatID: "1",
|
|
||||||
MessageID: "2",
|
|
||||||
Emoji: "ok",
|
|
||||||
})
|
|
||||||
|
|
||||||
prod := &captureProducer{
|
|
||||||
sig: sigEnv.GetSignature().ToString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewService(logger, repo, prod, &fakeBroker{}, Config{
|
|
||||||
Rail: "card",
|
|
||||||
SuccessReaction: "👍",
|
|
||||||
})
|
|
||||||
|
|
||||||
return svc, repo, prod
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfirmed(t *testing.T) {
|
|
||||||
svc, repo, prod := newTestService(t)
|
|
||||||
|
|
||||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
|
||||||
IdempotencyKey: "idem-1",
|
|
||||||
PaymentIntentID: "pi-1",
|
|
||||||
QuoteRef: "quote-1",
|
|
||||||
OutgoingLeg: "card",
|
|
||||||
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
|
||||||
Status: storagemodel.PaymentStatusWaiting,
|
|
||||||
})
|
|
||||||
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: "idem-1",
|
|
||||||
Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
|
||||||
Status: model.ConfirmationStatusConfirmed,
|
|
||||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = svc.onConfirmationResult(context.Background(), result)
|
|
||||||
|
|
||||||
rec := repo.payments.records["idem-1"]
|
|
||||||
|
|
||||||
if rec.Status != storagemodel.PaymentStatusSuccess {
|
|
||||||
t.Fatalf("expected success, got %s", rec.Status)
|
|
||||||
}
|
|
||||||
if rec.RequestedMoney == nil {
|
|
||||||
t.Fatalf("requested money not set")
|
|
||||||
}
|
|
||||||
if rec.ExecutedAt.IsZero() {
|
|
||||||
t.Fatalf("executedAt not set")
|
|
||||||
}
|
|
||||||
if repo.tg.records["idem-1"] == nil {
|
|
||||||
t.Fatalf("telegram confirmation not stored")
|
|
||||||
}
|
|
||||||
if len(prod.reactions) != 1 {
|
|
||||||
t.Fatalf("reaction must be published")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClarified(t *testing.T) {
|
|
||||||
svc, repo, prod := newTestService(t)
|
|
||||||
|
|
||||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
|
||||||
IdempotencyKey: "idem-2",
|
|
||||||
Status: storagemodel.PaymentStatusWaiting,
|
|
||||||
})
|
|
||||||
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: "idem-2",
|
|
||||||
Status: model.ConfirmationStatusClarified,
|
|
||||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = svc.onConfirmationResult(context.Background(), result)
|
|
||||||
|
|
||||||
rec := repo.payments.records["idem-2"]
|
|
||||||
|
|
||||||
if rec.Status != storagemodel.PaymentStatusWaiting {
|
|
||||||
t.Fatalf("clarified must not change status")
|
|
||||||
}
|
|
||||||
if repo.tg.records["idem-2"] == nil {
|
|
||||||
t.Fatalf("telegram confirmation must be stored")
|
|
||||||
}
|
|
||||||
if len(prod.reactions) != 0 {
|
|
||||||
t.Fatalf("clarified must not publish reaction")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRejected(t *testing.T) {
|
|
||||||
svc, repo, prod := newTestService(t)
|
|
||||||
|
|
||||||
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
|
|
||||||
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
|
|
||||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
|
||||||
IdempotencyKey: "idem-3",
|
|
||||||
PaymentIntentID: "pi-3",
|
|
||||||
QuoteRef: "quote-3",
|
|
||||||
OutgoingLeg: "card",
|
|
||||||
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
|
||||||
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
|
|
||||||
Status: storagemodel.PaymentStatusWaiting,
|
|
||||||
})
|
|
||||||
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: "idem-3",
|
|
||||||
Status: model.ConfirmationStatusRejected,
|
|
||||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = svc.onConfirmationResult(context.Background(), result)
|
|
||||||
|
|
||||||
rec := repo.payments.records["idem-3"]
|
|
||||||
|
|
||||||
if rec.Status != storagemodel.PaymentStatusFailed {
|
|
||||||
t.Fatalf("expected failed")
|
|
||||||
}
|
|
||||||
if repo.tg.records["idem-3"] == nil {
|
|
||||||
t.Fatalf("telegram confirmation must be stored")
|
|
||||||
}
|
|
||||||
if len(prod.reactions) != 0 {
|
|
||||||
t.Fatalf("rejected must not publish reaction")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTimeout(t *testing.T) {
|
|
||||||
svc, repo, prod := newTestService(t)
|
|
||||||
|
|
||||||
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
|
|
||||||
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
|
|
||||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
|
||||||
IdempotencyKey: "idem-4",
|
|
||||||
PaymentIntentID: "pi-4",
|
|
||||||
QuoteRef: "quote-4",
|
|
||||||
OutgoingLeg: "card",
|
|
||||||
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
|
||||||
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
|
|
||||||
Status: storagemodel.PaymentStatusWaiting,
|
|
||||||
})
|
|
||||||
|
|
||||||
result := &model.ConfirmationResult{
|
|
||||||
RequestID: "idem-4",
|
|
||||||
Status: model.ConfirmationStatusTimeout,
|
|
||||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = svc.onConfirmationResult(context.Background(), result)
|
|
||||||
|
|
||||||
rec := repo.payments.records["idem-4"]
|
|
||||||
|
|
||||||
if rec.Status != storagemodel.PaymentStatusFailed {
|
|
||||||
t.Fatalf("timeout must be failed")
|
|
||||||
}
|
|
||||||
if repo.tg.records["idem-4"] == nil {
|
|
||||||
t.Fatalf("telegram confirmation must be stored")
|
|
||||||
}
|
|
||||||
if len(prod.reactions) != 0 {
|
|
||||||
t.Fatalf("timeout must not publish reaction")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntentFromSubmitTransfer_NormalizesOutgoingLeg(t *testing.T) {
|
|
||||||
intent, err := intentFromSubmitTransfer(&chainv1.SubmitTransferRequest{
|
|
||||||
IdempotencyKey: "idem-5",
|
|
||||||
IntentRef: "pi-5",
|
|
||||||
OperationRef: "op-5",
|
|
||||||
PaymentRef: "pay-5",
|
|
||||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
|
||||||
Metadata: map[string]string{
|
|
||||||
metadataOutgoingLeg: "card",
|
|
||||||
},
|
|
||||||
}, "provider_settlement", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if got, want := intent.OutgoingLeg, discovery.RailCardPayout; got != want {
|
|
||||||
t.Fatalf("unexpected outgoing leg: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package gateway
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
|
|
||||||
pmodel "github.com/tech/sendico/pkg/model"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
|
||||||
"github.com/tech/sendico/pkg/payments/rail"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func isFinalStatus(t *model.PaymentRecord) bool {
|
|
||||||
switch t.Status {
|
|
||||||
case model.PaymentStatusFailed, model.PaymentStatusSuccess, model.PaymentStatusCancelled:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toOpStatus(t *model.PaymentRecord) (rail.OperationResult, error) {
|
|
||||||
switch t.Status {
|
|
||||||
case model.PaymentStatusFailed:
|
|
||||||
return rail.OperationResultFailed, nil
|
|
||||||
case model.PaymentStatusSuccess:
|
|
||||||
return rail.OperationResultSuccess, nil
|
|
||||||
case model.PaymentStatusCancelled:
|
|
||||||
return rail.OperationResultCancelled, nil
|
|
||||||
default:
|
|
||||||
return rail.OperationResultFailed, merrors.InvalidArgument("unexpected transfer status", "payment.status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error {
|
|
||||||
if record == nil {
|
|
||||||
return merrors.InvalidArgument("payment record is required", "record")
|
|
||||||
}
|
|
||||||
s.logger.Debug("Persisting transfer status",
|
|
||||||
zap.String("idempotency_key", record.IdempotencyKey),
|
|
||||||
zap.String("payment_ref", record.PaymentIntentID),
|
|
||||||
zap.String("status", string(record.Status)),
|
|
||||||
zap.Bool("is_final", isFinalStatus(record)))
|
|
||||||
if !isFinalStatus(record) {
|
|
||||||
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
|
|
||||||
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.logger.Debug("Transfer status persisted (non-final)",
|
|
||||||
zap.String("idempotency_key", record.IdempotencyKey),
|
|
||||||
zap.String("status", string(record.Status)))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
|
||||||
if upsertErr := s.repo.Payments().Upsert(txCtx, record); upsertErr != nil {
|
|
||||||
return nil, upsertErr
|
|
||||||
}
|
|
||||||
if isFinalStatus(record) {
|
|
||||||
if emitErr := s.emitTransferStatusEvent(txCtx, record); emitErr != nil {
|
|
||||||
return nil, emitErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.logger.Info("Transfer status persisted (final)",
|
|
||||||
zap.String("idempotency_key", record.IdempotencyKey),
|
|
||||||
zap.String("status", string(record.Status)))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) emitTransferStatusEvent(ctx context.Context, record *model.PaymentRecord) error {
|
|
||||||
if s == nil || record == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if s.producer == nil || s.outboxStore() == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
status, err := toOpStatus(record)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
exec := pmodel.PaymentGatewayExecution{
|
|
||||||
PaymentIntentID: record.PaymentIntentID,
|
|
||||||
IdempotencyKey: record.IdempotencyKey,
|
|
||||||
ExecutedMoney: record.ExecutedMoney,
|
|
||||||
PaymentRef: record.PaymentRef,
|
|
||||||
Status: status,
|
|
||||||
OperationRef: record.OperationRef,
|
|
||||||
Error: record.FailureReason,
|
|
||||||
TransferRef: record.ID.Hex(),
|
|
||||||
}
|
|
||||||
env := paymentgateway.PaymentGatewayExecution(mservice.ChSettle, &exec)
|
|
||||||
if sendErr := s.sendWithOutbox(ctx, env); sendErr != nil {
|
|
||||||
s.logger.Warn("Failed to publish transfer status event", zap.Error(sendErr), mzap.ObjRef("transfer_ref", record.ID))
|
|
||||||
return sendErr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
type Command string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CommandStart Command = "start"
|
|
||||||
CommandHelp Command = "help"
|
|
||||||
CommandFund Command = "fund"
|
|
||||||
CommandWithdraw Command = "withdraw"
|
|
||||||
CommandConfirm Command = "confirm"
|
|
||||||
CommandCancel Command = "cancel"
|
|
||||||
)
|
|
||||||
|
|
||||||
var supportedCommands = []Command{
|
|
||||||
CommandStart,
|
|
||||||
CommandHelp,
|
|
||||||
CommandFund,
|
|
||||||
CommandWithdraw,
|
|
||||||
CommandConfirm,
|
|
||||||
CommandCancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Command) Slash() string {
|
|
||||||
name := strings.TrimSpace(string(c))
|
|
||||||
if name == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "/" + name
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCommand(text string) Command {
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if !strings.HasPrefix(text, "/") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
token := text
|
|
||||||
if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 {
|
|
||||||
token = token[:idx]
|
|
||||||
}
|
|
||||||
token = strings.TrimPrefix(token, "/")
|
|
||||||
if idx := strings.Index(token, "@"); idx >= 0 {
|
|
||||||
token = token[:idx]
|
|
||||||
}
|
|
||||||
return Command(strings.ToLower(strings.TrimSpace(token)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func supportedCommandsMessage() string {
|
|
||||||
lines := make([]string, 0, len(supportedCommands)+2)
|
|
||||||
lines = append(lines, "*Supported Commands*")
|
|
||||||
lines = append(lines, "")
|
|
||||||
for _, cmd := range supportedCommands {
|
|
||||||
lines = append(lines, markdownCommand(cmd))
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmationCommandsMessage() string {
|
|
||||||
return strings.Join([]string{
|
|
||||||
"*Confirm Operation*",
|
|
||||||
"",
|
|
||||||
"Use " + markdownCommand(CommandConfirm) + " to execute.",
|
|
||||||
"Use " + markdownCommand(CommandCancel) + " to abort.",
|
|
||||||
}, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func helpMessage(accountCode string, currency string) string {
|
|
||||||
accountCode = strings.TrimSpace(accountCode)
|
|
||||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
|
||||||
if accountCode == "" {
|
|
||||||
accountCode = "N/A"
|
|
||||||
}
|
|
||||||
if currency == "" {
|
|
||||||
currency = "N/A"
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := []string{
|
|
||||||
"*Treasury Bot Help*",
|
|
||||||
"",
|
|
||||||
"*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")",
|
|
||||||
"",
|
|
||||||
"*How to use*",
|
|
||||||
"1. Start funding with " + markdownCommand(CommandFund) + " or withdrawal with " + markdownCommand(CommandWithdraw) + ".",
|
|
||||||
"2. Enter amount as decimal with dot separator and no currency.",
|
|
||||||
" Example: " + markdownCode("1250.75"),
|
|
||||||
"3. Confirm with " + markdownCommand(CommandConfirm) + " or abort with " + markdownCommand(CommandCancel) + ".",
|
|
||||||
"",
|
|
||||||
"*Cooldown*",
|
|
||||||
"After confirmation there is a cooldown window. You can cancel during it with " + markdownCommand(CommandCancel) + ".",
|
|
||||||
"You will receive a follow-up message with execution success or failure.",
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DialogState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DialogStateWaitingAmount DialogState = "waiting_amount"
|
|
||||||
DialogStateWaitingConfirmation DialogState = "waiting_confirmation"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DialogSession struct {
|
|
||||||
State DialogState
|
|
||||||
OperationType storagemodel.TreasuryOperationType
|
|
||||||
LedgerAccountID string
|
|
||||||
RequestID string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Dialogs struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
sessions map[string]DialogSession
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDialogs() *Dialogs {
|
|
||||||
return &Dialogs{
|
|
||||||
sessions: map[string]DialogSession{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Dialogs) Get(telegramUserID string) (DialogSession, bool) {
|
|
||||||
if d == nil {
|
|
||||||
return DialogSession{}, false
|
|
||||||
}
|
|
||||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
|
||||||
if telegramUserID == "" {
|
|
||||||
return DialogSession{}, false
|
|
||||||
}
|
|
||||||
d.mu.Lock()
|
|
||||||
defer d.mu.Unlock()
|
|
||||||
session, ok := d.sessions[telegramUserID]
|
|
||||||
return session, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Dialogs) Set(telegramUserID string, session DialogSession) {
|
|
||||||
if d == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
|
||||||
if telegramUserID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.mu.Lock()
|
|
||||||
defer d.mu.Unlock()
|
|
||||||
d.sessions[telegramUserID] = session
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Dialogs) Clear(telegramUserID string) {
|
|
||||||
if d == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
|
||||||
if telegramUserID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.mu.Lock()
|
|
||||||
defer d.mu.Unlock()
|
|
||||||
delete(d.sessions, telegramUserID)
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func markdownCode(value string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
value = "N/A"
|
|
||||||
}
|
|
||||||
value = strings.ReplaceAll(value, "`", "'")
|
|
||||||
return "`" + value + "`"
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownCommand(command Command) string {
|
|
||||||
return command.Slash()
|
|
||||||
}
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const unauthorizedMessage = "*Unauthorized*\nYour Telegram account is not allowed to perform treasury operations."
|
|
||||||
const unauthorizedChatMessage = "*Unauthorized Chat*\nThis Telegram chat is not allowed to perform treasury operations."
|
|
||||||
|
|
||||||
const amountInputHint = "*Amount format*\nEnter amount as a decimal number using a dot separator and without currency.\nExample: `1250.75`"
|
|
||||||
|
|
||||||
type SendTextFunc func(ctx context.Context, chatID string, text string) error
|
|
||||||
|
|
||||||
type ScheduleTracker interface {
|
|
||||||
TrackScheduled(record *storagemodel.TreasuryRequest)
|
|
||||||
Untrack(requestID string)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountProfile struct {
|
|
||||||
AccountID string
|
|
||||||
AccountCode string
|
|
||||||
Currency string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateRequestInput struct {
|
|
||||||
OperationType storagemodel.TreasuryOperationType
|
|
||||||
TelegramUserID string
|
|
||||||
LedgerAccountID string
|
|
||||||
ChatID string
|
|
||||||
Amount string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TreasuryService interface {
|
|
||||||
ExecutionDelay() time.Duration
|
|
||||||
MaxPerOperationLimit() string
|
|
||||||
|
|
||||||
GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error)
|
|
||||||
GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error)
|
|
||||||
CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error)
|
|
||||||
ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
|
||||||
CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserBinding struct {
|
|
||||||
TelegramUserID string
|
|
||||||
LedgerAccountID string
|
|
||||||
AllowedChatIDs []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserBindingResolver interface {
|
|
||||||
ResolveUserBinding(ctx context.Context, telegramUserID string) (*UserBinding, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type limitError interface {
|
|
||||||
error
|
|
||||||
LimitKind() string
|
|
||||||
LimitMax() string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Router struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
|
|
||||||
service TreasuryService
|
|
||||||
dialogs *Dialogs
|
|
||||||
send SendTextFunc
|
|
||||||
tracker ScheduleTracker
|
|
||||||
|
|
||||||
users UserBindingResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(
|
|
||||||
logger mlogger.Logger,
|
|
||||||
service TreasuryService,
|
|
||||||
send SendTextFunc,
|
|
||||||
tracker ScheduleTracker,
|
|
||||||
users UserBindingResolver,
|
|
||||||
) *Router {
|
|
||||||
if logger != nil {
|
|
||||||
logger = logger.Named("treasury_router")
|
|
||||||
}
|
|
||||||
return &Router{
|
|
||||||
logger: logger,
|
|
||||||
service: service,
|
|
||||||
dialogs: NewDialogs(),
|
|
||||||
send: send,
|
|
||||||
tracker: tracker,
|
|
||||||
users: users,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) Enabled() bool {
|
|
||||||
return r != nil && r.service != nil && r.users != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
|
||||||
if !r.Enabled() || update == nil || update.Message == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
message := update.Message
|
|
||||||
chatID := strings.TrimSpace(message.ChatID)
|
|
||||||
userID := strings.TrimSpace(message.FromUserID)
|
|
||||||
text := strings.TrimSpace(message.Text)
|
|
||||||
|
|
||||||
if chatID == "" || userID == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
command := parseCommand(text)
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Debug("Telegram treasury update received",
|
|
||||||
zap.Int64("update_id", update.UpdateID),
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("telegram_user_id", userID),
|
|
||||||
zap.String("command", strings.TrimSpace(string(command))),
|
|
||||||
zap.String("message_text", text),
|
|
||||||
zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding, err := r.users.ResolveUserBinding(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Warn("Failed to resolve treasury user binding",
|
|
||||||
zap.Error(err),
|
|
||||||
zap.String("telegram_user_id", userID),
|
|
||||||
zap.String("chat_id", chatID))
|
|
||||||
}
|
|
||||||
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check treasury authorization right now. Please try again.")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if binding == nil || strings.TrimSpace(binding.LedgerAccountID) == "" {
|
|
||||||
r.logUnauthorized(update)
|
|
||||||
_ = r.sendText(ctx, chatID, unauthorizedMessage)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if !isChatAllowed(chatID, binding.AllowedChatIDs) {
|
|
||||||
r.logUnauthorized(update)
|
|
||||||
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
accountID := strings.TrimSpace(binding.LedgerAccountID)
|
|
||||||
|
|
||||||
switch command {
|
|
||||||
case CommandStart:
|
|
||||||
profile := r.resolveAccountProfile(ctx, accountID)
|
|
||||||
_ = r.sendText(ctx, chatID, welcomeMessage(profile))
|
|
||||||
return true
|
|
||||||
case CommandHelp:
|
|
||||||
profile := r.resolveAccountProfile(ctx, accountID)
|
|
||||||
_ = r.sendText(ctx, chatID, helpMessage(displayAccountCode(profile, accountID), profile.Currency))
|
|
||||||
return true
|
|
||||||
case CommandFund:
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Info("Treasury funding dialog requested",
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("telegram_user_id", userID),
|
|
||||||
zap.String("ledger_account_id", accountID))
|
|
||||||
}
|
|
||||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
|
|
||||||
return true
|
|
||||||
case CommandWithdraw:
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Info("Treasury withdrawal dialog requested",
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("telegram_user_id", userID),
|
|
||||||
zap.String("ledger_account_id", accountID))
|
|
||||||
}
|
|
||||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
|
|
||||||
return true
|
|
||||||
case CommandConfirm:
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Info("Treasury confirmation requested",
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("telegram_user_id", userID),
|
|
||||||
zap.String("ledger_account_id", accountID))
|
|
||||||
}
|
|
||||||
r.confirm(ctx, userID, accountID, chatID)
|
|
||||||
return true
|
|
||||||
case CommandCancel:
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Info("Treasury cancellation requested",
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("telegram_user_id", userID),
|
|
||||||
zap.String("ledger_account_id", accountID))
|
|
||||||
}
|
|
||||||
r.cancel(ctx, userID, accountID, chatID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
session, hasSession := r.dialogs.Get(userID)
|
|
||||||
if hasSession {
|
|
||||||
switch session.State {
|
|
||||||
case DialogStateWaitingAmount:
|
|
||||||
r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text)
|
|
||||||
return true
|
|
||||||
case DialogStateWaitingConfirmation:
|
|
||||||
_ = r.sendText(ctx, chatID, confirmationCommandsMessage())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(text, "/") {
|
|
||||||
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(message.ReplyToMessageID) != "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if text != "" {
|
|
||||||
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) {
|
|
||||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
|
||||||
if err != nil {
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID))
|
|
||||||
}
|
|
||||||
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check pending treasury operations right now. Please try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if active != nil {
|
|
||||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(active))
|
|
||||||
r.dialogs.Set(userID, DialogSession{
|
|
||||||
State: DialogStateWaitingConfirmation,
|
|
||||||
LedgerAccountID: accountID,
|
|
||||||
RequestID: active.RequestID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.dialogs.Set(userID, DialogSession{
|
|
||||||
State: DialogStateWaitingAmount,
|
|
||||||
OperationType: operation,
|
|
||||||
LedgerAccountID: accountID,
|
|
||||||
})
|
|
||||||
profile := r.resolveAccountProfile(ctx, accountID)
|
|
||||||
_ = r.sendText(ctx, chatID, amountPromptMessage(operation, profile, accountID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) {
|
|
||||||
record, err := r.service.CreateRequest(ctx, CreateRequestInput{
|
|
||||||
OperationType: operation,
|
|
||||||
TelegramUserID: userID,
|
|
||||||
LedgerAccountID: accountID,
|
|
||||||
ChatID: chatID,
|
|
||||||
Amount: amount,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if record != nil {
|
|
||||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(record))
|
|
||||||
r.dialogs.Set(userID, DialogSession{
|
|
||||||
State: DialogStateWaitingConfirmation,
|
|
||||||
LedgerAccountID: accountID,
|
|
||||||
RequestID: record.RequestID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if typed, ok := err.(limitError); ok {
|
|
||||||
switch typed.LimitKind() {
|
|
||||||
case "per_operation":
|
|
||||||
_ = r.sendText(ctx, chatID, "*Amount exceeds allowed limit*\n\n*Max per operation:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
|
||||||
return
|
|
||||||
case "daily":
|
|
||||||
_ = r.sendText(ctx, chatID, "*Daily amount limit exceeded*\n\n*Max per day:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
|
||||||
_ = r.sendText(ctx, chatID, "*Invalid amount*\n\n"+amountInputHint+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if record == nil {
|
|
||||||
_ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.dialogs.Set(userID, DialogSession{
|
|
||||||
State: DialogStateWaitingConfirmation,
|
|
||||||
LedgerAccountID: accountID,
|
|
||||||
RequestID: record.RequestID,
|
|
||||||
})
|
|
||||||
_ = r.sendText(ctx, chatID, confirmationPrompt(record))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) {
|
|
||||||
requestID := ""
|
|
||||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
|
||||||
requestID = strings.TrimSpace(session.RequestID)
|
|
||||||
} else {
|
|
||||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
|
||||||
if err == nil && active != nil {
|
|
||||||
requestID = strings.TrimSpace(active.RequestID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if requestID == "" {
|
|
||||||
_ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
record, err := r.service.ConfirmRequest(ctx, requestID, userID)
|
|
||||||
if err != nil {
|
|
||||||
_ = r.sendText(ctx, chatID, "*Unable to confirm treasury request.*\n\nUse "+markdownCommand(CommandCancel)+" or create a new request with "+markdownCommand(CommandFund)+" or "+markdownCommand(CommandWithdraw)+".")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.tracker != nil {
|
|
||||||
r.tracker.TrackScheduled(record)
|
|
||||||
}
|
|
||||||
r.dialogs.Clear(userID)
|
|
||||||
delay := int64(r.service.ExecutionDelay().Seconds())
|
|
||||||
if delay < 0 {
|
|
||||||
delay = 0
|
|
||||||
}
|
|
||||||
_ = r.sendText(ctx, chatID,
|
|
||||||
"*Operation confirmed*\n\n"+
|
|
||||||
"*Execution:* scheduled in "+markdownCode(formatSeconds(delay))+".\n"+
|
|
||||||
"You can cancel during this cooldown with "+markdownCommand(CommandCancel)+".\n\n"+
|
|
||||||
"You will receive a follow-up message with execution success or failure.\n\n"+
|
|
||||||
"*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
|
|
||||||
requestID := ""
|
|
||||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
|
||||||
requestID = strings.TrimSpace(session.RequestID)
|
|
||||||
} else {
|
|
||||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
|
||||||
if err == nil && active != nil {
|
|
||||||
requestID = strings.TrimSpace(active.RequestID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if requestID == "" {
|
|
||||||
r.dialogs.Clear(userID)
|
|
||||||
_ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
record, err := r.service.CancelRequest(ctx, requestID, userID)
|
|
||||||
if err != nil {
|
|
||||||
_ = r.sendText(ctx, chatID, "*Unable to cancel treasury request.*")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.tracker != nil {
|
|
||||||
r.tracker.Untrack(record.RequestID)
|
|
||||||
}
|
|
||||||
r.dialogs.Clear(userID)
|
|
||||||
_ = r.sendText(ctx, chatID, "*Operation cancelled*\n\n*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) sendText(ctx context.Context, chatID string, text string) error {
|
|
||||||
if r == nil || r.send == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
chatID = strings.TrimSpace(chatID)
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
if chatID == "" || text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := r.send(ctx, chatID, text); err != nil {
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Warn("Failed to send treasury bot response",
|
|
||||||
zap.Error(err),
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("message_text", text))
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) {
|
|
||||||
if r == nil || r.logger == nil || update == nil || update.Message == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message := update.Message
|
|
||||||
r.logger.Warn("unauthorized_access",
|
|
||||||
zap.String("event", "unauthorized_access"),
|
|
||||||
zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)),
|
|
||||||
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
|
|
||||||
zap.String("message_text", strings.TrimSpace(message.Text)),
|
|
||||||
zap.Time("timestamp", time.Now()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
|
|
||||||
if record == nil {
|
|
||||||
return "*Pending treasury operation already exists.*\n\nUse " + markdownCommand(CommandCancel) + "."
|
|
||||||
}
|
|
||||||
return "*Pending Treasury Operation*\n\n" +
|
|
||||||
"*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
|
|
||||||
"*Request ID:* " + markdownCode(strings.TrimSpace(record.RequestID)) + "\n" +
|
|
||||||
"*Status:* " + markdownCode(strings.TrimSpace(string(record.Status))) + "\n" +
|
|
||||||
"*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
|
|
||||||
"Wait for execution or cancel with " + markdownCommand(CommandCancel) + "."
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
|
|
||||||
if record == nil {
|
|
||||||
return "*Request created.*\n\nUse " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + "."
|
|
||||||
}
|
|
||||||
title := "*Funding request created.*"
|
|
||||||
if record.OperationType == storagemodel.TreasuryOperationWithdraw {
|
|
||||||
title = "*Withdrawal request created.*"
|
|
||||||
}
|
|
||||||
return title + "\n\n" +
|
|
||||||
"*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
|
|
||||||
"*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
|
|
||||||
confirmationCommandsMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func welcomeMessage(profile *AccountProfile) string {
|
|
||||||
accountCode := displayAccountCode(profile, "")
|
|
||||||
currency := ""
|
|
||||||
if profile != nil {
|
|
||||||
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
|
|
||||||
}
|
|
||||||
if accountCode == "" {
|
|
||||||
accountCode = "N/A"
|
|
||||||
}
|
|
||||||
if currency == "" {
|
|
||||||
currency = "N/A"
|
|
||||||
}
|
|
||||||
return "*Sendico Treasury Bot*\n\n" +
|
|
||||||
"*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n" +
|
|
||||||
"Use " + markdownCommand(CommandFund) + " to credit your account and " + markdownCommand(CommandWithdraw) + " to debit it.\n" +
|
|
||||||
"After entering an amount, use " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + ".\n" +
|
|
||||||
"Use " + markdownCommand(CommandHelp) + " for detailed usage."
|
|
||||||
}
|
|
||||||
|
|
||||||
func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string {
|
|
||||||
title := "*Funding request*"
|
|
||||||
if operation == storagemodel.TreasuryOperationWithdraw {
|
|
||||||
title = "*Withdrawal request*"
|
|
||||||
}
|
|
||||||
accountCode := displayAccountCode(profile, fallbackAccountID)
|
|
||||||
currency := ""
|
|
||||||
if profile != nil {
|
|
||||||
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
|
|
||||||
}
|
|
||||||
if accountCode == "" {
|
|
||||||
accountCode = "N/A"
|
|
||||||
}
|
|
||||||
if currency == "" {
|
|
||||||
currency = "N/A"
|
|
||||||
}
|
|
||||||
return title + "\n\n" +
|
|
||||||
"*Account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n\n" +
|
|
||||||
amountInputHint
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestAccountDisplay(record *storagemodel.TreasuryRequest) string {
|
|
||||||
if record == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if code := strings.TrimSpace(record.LedgerAccountCode); code != "" {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(record.LedgerAccountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func displayAccountCode(profile *AccountProfile, fallbackAccountID string) string {
|
|
||||||
if profile != nil {
|
|
||||||
if code := strings.TrimSpace(profile.AccountCode); code != "" {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
if id := strings.TrimSpace(profile.AccountID); id != "" {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(fallbackAccountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID string) *AccountProfile {
|
|
||||||
if r == nil || r.service == nil {
|
|
||||||
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
|
|
||||||
}
|
|
||||||
profile, err := r.service.GetAccountProfile(ctx, ledgerAccountID)
|
|
||||||
if err != nil {
|
|
||||||
if r.logger != nil {
|
|
||||||
r.logger.Warn("Failed to resolve treasury account profile",
|
|
||||||
zap.Error(err),
|
|
||||||
zap.String("ledger_account_id", strings.TrimSpace(ledgerAccountID)))
|
|
||||||
}
|
|
||||||
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
|
|
||||||
}
|
|
||||||
if profile == nil {
|
|
||||||
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(profile.AccountID) == "" {
|
|
||||||
profile.AccountID = strings.TrimSpace(ledgerAccountID)
|
|
||||||
}
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
func isChatAllowed(chatID string, allowedChatIDs []string) bool {
|
|
||||||
chatID = strings.TrimSpace(chatID)
|
|
||||||
if chatID == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(allowedChatIDs) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, allowed := range allowedChatIDs {
|
|
||||||
if strings.TrimSpace(allowed) == chatID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatSeconds(value int64) string {
|
|
||||||
if value == 1 {
|
|
||||||
return "1 second"
|
|
||||||
}
|
|
||||||
return strconv.FormatInt(value, 10) + " seconds"
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeService struct{}
|
|
||||||
|
|
||||||
type fakeUserBindingResolver struct {
|
|
||||||
bindings map[string]*UserBinding
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeUserBindingResolver) ResolveUserBinding(_ context.Context, telegramUserID string) (*UserBinding, error) {
|
|
||||||
if f.err != nil {
|
|
||||||
return nil, f.err
|
|
||||||
}
|
|
||||||
if f.bindings == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return f.bindings[telegramUserID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeService) ExecutionDelay() time.Duration {
|
|
||||||
return 30 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeService) MaxPerOperationLimit() string {
|
|
||||||
return "1000000"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeService) GetAccountProfile(_ context.Context, ledgerAccountID string) (*AccountProfile, error) {
|
|
||||||
return &AccountProfile{
|
|
||||||
AccountID: ledgerAccountID,
|
|
||||||
AccountCode: ledgerAccountID,
|
|
||||||
Currency: "USD",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeService) ConfirmRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fakeService) CancelRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
AllowedChatIDs: []string{"100"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "100",
|
|
||||||
FromUserID: "999",
|
|
||||||
Text: "/fund",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != unauthorizedMessage {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterUnknownChatGetsDenied(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
AllowedChatIDs: []string{"100"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "999",
|
|
||||||
FromUserID: "123",
|
|
||||||
Text: "/fund",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != unauthorizedChatMessage {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "999",
|
|
||||||
FromUserID: "123",
|
|
||||||
Text: "/fund",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != amountPromptMessage(
|
|
||||||
storagemodel.TreasuryOperationFund,
|
|
||||||
&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"},
|
|
||||||
"acct-1",
|
|
||||||
) {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "777",
|
|
||||||
FromUserID: "999",
|
|
||||||
Text: "/fund",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != unauthorizedMessage {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "777",
|
|
||||||
FromUserID: "123",
|
|
||||||
Text: "/start",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != welcomeMessage(&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"}) {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterHelpAuthorizedShowsHelp(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "777",
|
|
||||||
FromUserID: "123",
|
|
||||||
Text: "/help",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != helpMessage("acct-1", "USD") {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterStartUnauthorizedGetsDenied(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "777",
|
|
||||||
FromUserID: "999",
|
|
||||||
Text: "/start",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != unauthorizedMessage {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(t *testing.T) {
|
|
||||||
var sent []string
|
|
||||||
router := NewRouter(
|
|
||||||
mloggerfactory.NewLogger(false),
|
|
||||||
fakeService{},
|
|
||||||
func(_ context.Context, _ string, text string) error {
|
|
||||||
sent = append(sent, text)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
nil,
|
|
||||||
fakeUserBindingResolver{
|
|
||||||
bindings: map[string]*UserBinding{
|
|
||||||
"123": {
|
|
||||||
TelegramUserID: "123",
|
|
||||||
LedgerAccountID: "acct-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
|
||||||
Message: &model.TelegramMessage{
|
|
||||||
ChatID: "777",
|
|
||||||
FromUserID: "123",
|
|
||||||
Text: "hello",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
t.Fatalf("expected update to be handled")
|
|
||||||
}
|
|
||||||
if len(sent) != 1 {
|
|
||||||
t.Fatalf("expected one message, got %d", len(sent))
|
|
||||||
}
|
|
||||||
if sent[0] != supportedCommandsMessage() {
|
|
||||||
t.Fatalf("unexpected message: %q", sent[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package treasury
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
ExecutionDelay time.Duration
|
|
||||||
PollInterval time.Duration
|
|
||||||
|
|
||||||
MaxAmountPerOperation string
|
|
||||||
MaxDailyAmount string
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
package ledger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const ledgerConnectorID = "ledger"
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Endpoint string
|
|
||||||
Timeout time.Duration
|
|
||||||
Insecure bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Account struct {
|
|
||||||
AccountID string
|
|
||||||
AccountCode string
|
|
||||||
Currency string
|
|
||||||
OrganizationRef string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Balance struct {
|
|
||||||
AccountID string
|
|
||||||
Amount string
|
|
||||||
Currency string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PostRequest struct {
|
|
||||||
AccountID string
|
|
||||||
OrganizationRef string
|
|
||||||
Amount string
|
|
||||||
Currency string
|
|
||||||
Reference string
|
|
||||||
IdempotencyKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
type OperationResult struct {
|
|
||||||
Reference string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client interface {
|
|
||||||
GetAccount(ctx context.Context, accountID string) (*Account, error)
|
|
||||||
GetBalance(ctx context.Context, accountID string) (*Balance, error)
|
|
||||||
ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error)
|
|
||||||
ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error)
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
type grpcConnectorClient interface {
|
|
||||||
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
|
|
||||||
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
|
|
||||||
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type connectorClient struct {
|
|
||||||
cfg Config
|
|
||||||
conn *grpc.ClientConn
|
|
||||||
client grpcConnectorClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(cfg Config) (Client, error) {
|
|
||||||
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
|
|
||||||
if cfg.Endpoint == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint")
|
|
||||||
}
|
|
||||||
if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" {
|
|
||||||
cfg.Endpoint = normalized
|
|
||||||
if insecure {
|
|
||||||
cfg.Insecure = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.Timeout <= 0 {
|
|
||||||
cfg.Timeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
dialOpts := []grpc.DialOption{}
|
|
||||||
if cfg.Insecure {
|
|
||||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
} else {
|
|
||||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
|
||||||
}
|
|
||||||
conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint))
|
|
||||||
}
|
|
||||||
return &connectorClient{
|
|
||||||
cfg: cfg,
|
|
||||||
conn: conn,
|
|
||||||
client: connectorv1.NewConnectorServiceClient(conn),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connectorClient) Close() error {
|
|
||||||
if c == nil || c.conn == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return c.conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
|
|
||||||
accountID = strings.TrimSpace(accountID)
|
|
||||||
if accountID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
|
||||||
}
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{
|
|
||||||
AccountRef: &connectorv1.AccountRef{
|
|
||||||
ConnectorId: ledgerConnectorID,
|
|
||||||
AccountId: accountID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
account := resp.GetAccount()
|
|
||||||
if account == nil {
|
|
||||||
return nil, merrors.NoData("ledger account not found")
|
|
||||||
}
|
|
||||||
accountCode := strings.TrimSpace(account.GetLabel())
|
|
||||||
organizationRef := strings.TrimSpace(account.GetOwnerRef())
|
|
||||||
if organizationRef == "" && account.GetProviderDetails() != nil {
|
|
||||||
details := account.GetProviderDetails().AsMap()
|
|
||||||
if organizationRef == "" {
|
|
||||||
organizationRef = firstDetailValue(details, "organization_ref", "organizationRef", "org_ref")
|
|
||||||
}
|
|
||||||
if accountCode == "" {
|
|
||||||
accountCode = firstDetailValue(details, "account_code", "accountCode", "code", "ledger_account_code")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Account{
|
|
||||||
AccountID: accountID,
|
|
||||||
AccountCode: accountCode,
|
|
||||||
Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())),
|
|
||||||
OrganizationRef: organizationRef,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
|
|
||||||
accountID = strings.TrimSpace(accountID)
|
|
||||||
if accountID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
|
||||||
}
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{
|
|
||||||
AccountRef: &connectorv1.AccountRef{
|
|
||||||
ConnectorId: ledgerConnectorID,
|
|
||||||
AccountId: accountID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
balance := resp.GetBalance()
|
|
||||||
if balance == nil || balance.GetAvailable() == nil {
|
|
||||||
return nil, merrors.Internal("ledger balance is unavailable")
|
|
||||||
}
|
|
||||||
return &Balance{
|
|
||||||
AccountID: accountID,
|
|
||||||
Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()),
|
|
||||||
Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
|
||||||
return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
|
||||||
return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) {
|
|
||||||
req.AccountID = strings.TrimSpace(req.AccountID)
|
|
||||||
req.OrganizationRef = strings.TrimSpace(req.OrganizationRef)
|
|
||||||
req.Amount = strings.TrimSpace(req.Amount)
|
|
||||||
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
||||||
req.Reference = strings.TrimSpace(req.Reference)
|
|
||||||
req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey)
|
|
||||||
|
|
||||||
if req.AccountID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
|
||||||
}
|
|
||||||
if req.OrganizationRef == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref")
|
|
||||||
}
|
|
||||||
if req.Amount == "" || req.Currency == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger amount is required", "amount")
|
|
||||||
}
|
|
||||||
if req.IdempotencyKey == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key")
|
|
||||||
}
|
|
||||||
|
|
||||||
params := map[string]any{
|
|
||||||
"organization_ref": req.OrganizationRef,
|
|
||||||
"operation": operation,
|
|
||||||
"description": "chsettle treasury operation",
|
|
||||||
"metadata": map[string]any{
|
|
||||||
"reference": req.Reference,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
operationReq := &connectorv1.Operation{
|
|
||||||
Type: opType,
|
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
|
||||||
Money: &moneyv1.Money{
|
|
||||||
Amount: req.Amount,
|
|
||||||
Currency: req.Currency,
|
|
||||||
},
|
|
||||||
Params: structFromMap(params),
|
|
||||||
}
|
|
||||||
account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID}
|
|
||||||
switch opType {
|
|
||||||
case connectorv1.OperationType_CREDIT:
|
|
||||||
operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
|
|
||||||
case connectorv1.OperationType_DEBIT:
|
|
||||||
operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.GetReceipt() == nil {
|
|
||||||
return nil, merrors.Internal("ledger receipt is unavailable")
|
|
||||||
}
|
|
||||||
if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil {
|
|
||||||
message := strings.TrimSpace(receiptErr.GetMessage())
|
|
||||||
if message == "" {
|
|
||||||
message = "ledger operation failed"
|
|
||||||
}
|
|
||||||
return nil, merrors.InvalidArgument(message)
|
|
||||||
}
|
|
||||||
reference := strings.TrimSpace(resp.GetReceipt().GetOperationId())
|
|
||||||
if reference == "" {
|
|
||||||
reference = req.Reference
|
|
||||||
}
|
|
||||||
return &OperationResult{Reference: reference}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
return context.WithTimeout(ctx, c.cfg.Timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func structFromMap(values map[string]any) *structpb.Struct {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result, err := structpb.NewStruct(values)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeEndpoint(raw string) (string, bool) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
||||||
return raw, false
|
|
||||||
}
|
|
||||||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
|
||||||
case "http", "grpc":
|
|
||||||
return parsed.Host, true
|
|
||||||
case "https", "grpcs":
|
|
||||||
return parsed.Host, false
|
|
||||||
default:
|
|
||||||
return raw, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstDetailValue(values map[string]any, keys ...string) string {
|
|
||||||
if len(values) == 0 || len(keys) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, key := range keys {
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if value, ok := values[key]; ok {
|
|
||||||
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
package ledger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/url"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/discovery"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DiscoveryConfig struct {
|
|
||||||
Logger mlogger.Logger
|
|
||||||
Registry *discovery.Registry
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type discoveryEndpoint struct {
|
|
||||||
address string
|
|
||||||
insecure bool
|
|
||||||
raw string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e discoveryEndpoint) key() string {
|
|
||||||
return fmt.Sprintf("%s|%t", e.address, e.insecure)
|
|
||||||
}
|
|
||||||
|
|
||||||
type discoveryClient struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
registry *discovery.Registry
|
|
||||||
timeout time.Duration
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
client Client
|
|
||||||
endpointKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDiscoveryClient(cfg DiscoveryConfig) (Client, error) {
|
|
||||||
if cfg.Registry == nil {
|
|
||||||
return nil, merrors.InvalidArgument("treasury ledger discovery registry is required", "registry")
|
|
||||||
}
|
|
||||||
if cfg.Timeout <= 0 {
|
|
||||||
cfg.Timeout = 5 * time.Second
|
|
||||||
}
|
|
||||||
logger := cfg.Logger
|
|
||||||
if logger != nil {
|
|
||||||
logger = logger.Named("treasury_ledger_discovery")
|
|
||||||
}
|
|
||||||
return &discoveryClient{
|
|
||||||
logger: logger,
|
|
||||||
registry: cfg.Registry,
|
|
||||||
timeout: cfg.Timeout,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryClient) Close() error {
|
|
||||||
if c == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if c.client != nil {
|
|
||||||
err := c.client.Close()
|
|
||||||
c.client = nil
|
|
||||||
c.endpointKey = ""
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
|
|
||||||
client, err := c.resolveClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.GetAccount(ctx, accountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
|
|
||||||
client, err := c.resolveClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.GetBalance(ctx, accountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
|
||||||
client, err := c.resolveClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.ExternalCredit(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
|
||||||
client, err := c.resolveClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.ExternalDebit(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryClient) resolveClient(_ context.Context) (Client, error) {
|
|
||||||
if c == nil || c.registry == nil {
|
|
||||||
return nil, merrors.Internal("treasury ledger discovery is unavailable")
|
|
||||||
}
|
|
||||||
endpoint, err := c.resolveEndpoint()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
key := endpoint.key()
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.client != nil && c.endpointKey == key {
|
|
||||||
return c.client, nil
|
|
||||||
}
|
|
||||||
if c.client != nil {
|
|
||||||
_ = c.client.Close()
|
|
||||||
c.client = nil
|
|
||||||
c.endpointKey = ""
|
|
||||||
}
|
|
||||||
next, err := New(Config{
|
|
||||||
Endpoint: endpoint.address,
|
|
||||||
Timeout: c.timeout,
|
|
||||||
Insecure: endpoint.insecure,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.client = next
|
|
||||||
c.endpointKey = key
|
|
||||||
if c.logger != nil {
|
|
||||||
c.logger.Info("Discovered ledger endpoint selected",
|
|
||||||
zap.String("service", string(mservice.Ledger)),
|
|
||||||
zap.String("invoke_uri", endpoint.raw),
|
|
||||||
zap.String("address", endpoint.address),
|
|
||||||
zap.Bool("insecure", endpoint.insecure))
|
|
||||||
}
|
|
||||||
return c.client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryClient) resolveEndpoint() (discoveryEndpoint, error) {
|
|
||||||
entries := c.registry.List(time.Now(), true)
|
|
||||||
type match struct {
|
|
||||||
entry discovery.RegistryEntry
|
|
||||||
opMatch bool
|
|
||||||
}
|
|
||||||
matches := make([]match, 0, len(entries))
|
|
||||||
requiredOps := discovery.LedgerServiceOperations()
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !matchesService(entry.Service, mservice.Ledger) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
matches = append(matches, match{
|
|
||||||
entry: entry,
|
|
||||||
opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return discoveryEndpoint{}, merrors.NoData("discovery: ledger service unavailable")
|
|
||||||
}
|
|
||||||
sort.Slice(matches, func(i, j int) bool {
|
|
||||||
if matches[i].opMatch != matches[j].opMatch {
|
|
||||||
return matches[i].opMatch
|
|
||||||
}
|
|
||||||
if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority {
|
|
||||||
return matches[i].entry.RoutingPriority > matches[j].entry.RoutingPriority
|
|
||||||
}
|
|
||||||
if matches[i].entry.ID != matches[j].entry.ID {
|
|
||||||
return matches[i].entry.ID < matches[j].entry.ID
|
|
||||||
}
|
|
||||||
return matches[i].entry.InstanceID < matches[j].entry.InstanceID
|
|
||||||
})
|
|
||||||
return parseDiscoveryEndpoint(matches[0].entry.InvokeURI)
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchesService(service string, candidate mservice.Type) bool {
|
|
||||||
service = strings.TrimSpace(service)
|
|
||||||
if service == "" || strings.TrimSpace(string(candidate)) == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(service, strings.TrimSpace(string(candidate)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDiscoveryEndpoint(raw string) (discoveryEndpoint, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(raw, "://") {
|
|
||||||
if _, _, splitErr := net.SplitHostPort(raw); splitErr != nil {
|
|
||||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
|
||||||
}
|
|
||||||
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err != nil || parsed.Scheme == "" {
|
|
||||||
if err != nil {
|
|
||||||
return discoveryEndpoint{}, err
|
|
||||||
}
|
|
||||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
|
||||||
}
|
|
||||||
|
|
||||||
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
|
||||||
switch scheme {
|
|
||||||
case "grpc":
|
|
||||||
address := strings.TrimSpace(parsed.Host)
|
|
||||||
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
|
|
||||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
|
||||||
}
|
|
||||||
return discoveryEndpoint{address: address, insecure: true, raw: raw}, nil
|
|
||||||
case "grpcs":
|
|
||||||
address := strings.TrimSpace(parsed.Host)
|
|
||||||
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
|
|
||||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
|
||||||
}
|
|
||||||
return discoveryEndpoint{address: address, insecure: false, raw: raw}, nil
|
|
||||||
case "dns", "passthrough":
|
|
||||||
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
|
|
||||||
default:
|
|
||||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: unsupported invoke uri scheme")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
package treasury
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/bot"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Module struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
|
|
||||||
service *Service
|
|
||||||
router *bot.Router
|
|
||||||
scheduler *Scheduler
|
|
||||||
ledger ledger.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewModule(
|
|
||||||
logger mlogger.Logger,
|
|
||||||
repo storage.TreasuryRequestsStore,
|
|
||||||
users storage.TreasuryTelegramUsersStore,
|
|
||||||
ledgerClient ledger.Client,
|
|
||||||
cfg Config,
|
|
||||||
send bot.SendTextFunc,
|
|
||||||
) (*Module, error) {
|
|
||||||
if logger != nil {
|
|
||||||
logger = logger.Named("treasury")
|
|
||||||
}
|
|
||||||
if users == nil {
|
|
||||||
return nil, merrors.InvalidArgument("treasury telegram users store is required", "users")
|
|
||||||
}
|
|
||||||
service, err := NewService(
|
|
||||||
logger,
|
|
||||||
repo,
|
|
||||||
ledgerClient,
|
|
||||||
cfg.ExecutionDelay,
|
|
||||||
cfg.MaxAmountPerOperation,
|
|
||||||
cfg.MaxDailyAmount,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
module := &Module{
|
|
||||||
logger: logger,
|
|
||||||
service: service,
|
|
||||||
ledger: ledgerClient,
|
|
||||||
}
|
|
||||||
module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval)
|
|
||||||
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, &botUsersAdapter{store: users})
|
|
||||||
return module, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) Enabled() bool {
|
|
||||||
return m != nil && m.router != nil && m.router.Enabled() && m.scheduler != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) Start() {
|
|
||||||
if m == nil || m.scheduler == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.scheduler.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) Shutdown() {
|
|
||||||
if m == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if m.scheduler != nil {
|
|
||||||
m.scheduler.Shutdown()
|
|
||||||
}
|
|
||||||
if m.ledger != nil {
|
|
||||||
_ = m.ledger.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
|
||||||
if m == nil || m.router == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return m.router.HandleUpdate(ctx, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
type botServiceAdapter struct {
|
|
||||||
svc *Service
|
|
||||||
}
|
|
||||||
|
|
||||||
type botUsersAdapter struct {
|
|
||||||
store storage.TreasuryTelegramUsersStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botUsersAdapter) ResolveUserBinding(ctx context.Context, telegramUserID string) (*bot.UserBinding, error) {
|
|
||||||
if a == nil || a.store == nil {
|
|
||||||
return nil, merrors.Internal("treasury users store unavailable")
|
|
||||||
}
|
|
||||||
record, err := a.store.FindByTelegramUserID(ctx, telegramUserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if record == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &bot.UserBinding{
|
|
||||||
TelegramUserID: strings.TrimSpace(record.TelegramUserID),
|
|
||||||
LedgerAccountID: strings.TrimSpace(record.LedgerAccountID),
|
|
||||||
AllowedChatIDs: normalizeChatIDs(record.AllowedChatIDs),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return a.svc.ExecutionDelay()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botServiceAdapter) MaxPerOperationLimit() string {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return a.svc.MaxPerOperationLimit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botServiceAdapter) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return a.svc.GetActiveRequestForAccount(ctx, ledgerAccountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botServiceAdapter) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*bot.AccountProfile, error) {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
profile, err := a.svc.GetAccountProfile(ctx, ledgerAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if profile == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &bot.AccountProfile{
|
|
||||||
AccountID: strings.TrimSpace(profile.AccountID),
|
|
||||||
AccountCode: strings.TrimSpace(profile.AccountCode),
|
|
||||||
Currency: strings.TrimSpace(profile.Currency),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botServiceAdapter) CreateRequest(ctx context.Context, input bot.CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return a.svc.CreateRequest(ctx, CreateRequestInput{
|
|
||||||
OperationType: input.OperationType,
|
|
||||||
TelegramUserID: input.TelegramUserID,
|
|
||||||
LedgerAccountID: input.LedgerAccountID,
|
|
||||||
ChatID: input.ChatID,
|
|
||||||
Amount: input.Amount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botServiceAdapter) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return a.svc.ConfirmRequest(ctx, requestID, telegramUserID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
if a == nil || a.svc == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return a.svc.CancelRequest(ctx, requestID, telegramUserID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeChatIDs(values []string) []string {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, next := range values {
|
|
||||||
next = strings.TrimSpace(next)
|
|
||||||
if next == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[next]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[next] = struct{}{}
|
|
||||||
out = append(out, next)
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package treasury
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NotifyFunc func(ctx context.Context, chatID string, text string) error
|
|
||||||
|
|
||||||
type Scheduler struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
service *Service
|
|
||||||
notify NotifyFunc
|
|
||||||
safetySweepInterval time.Duration
|
|
||||||
|
|
||||||
cancel context.CancelFunc
|
|
||||||
wg sync.WaitGroup
|
|
||||||
|
|
||||||
timersMu sync.Mutex
|
|
||||||
timers map[string]*time.Timer
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewScheduler(logger mlogger.Logger, service *Service, notify NotifyFunc, safetySweepInterval time.Duration) *Scheduler {
|
|
||||||
if logger != nil {
|
|
||||||
logger = logger.Named("treasury_scheduler")
|
|
||||||
}
|
|
||||||
if safetySweepInterval <= 0 {
|
|
||||||
safetySweepInterval = 30 * time.Second
|
|
||||||
}
|
|
||||||
return &Scheduler{
|
|
||||||
logger: logger,
|
|
||||||
service: service,
|
|
||||||
notify: notify,
|
|
||||||
safetySweepInterval: safetySweepInterval,
|
|
||||||
timers: map[string]*time.Timer{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scheduler) Start() {
|
|
||||||
if s == nil || s.service == nil || s.cancel != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
s.cancel = cancel
|
|
||||||
|
|
||||||
// Rebuild in-memory timers from DB on startup.
|
|
||||||
s.hydrateTimers(ctx)
|
|
||||||
// Safety pass for overdue items at startup.
|
|
||||||
s.sweep(ctx)
|
|
||||||
|
|
||||||
s.wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer s.wg.Done()
|
|
||||||
ticker := time.NewTicker(s.safetySweepInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
s.sweep(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scheduler) Shutdown() {
|
|
||||||
if s == nil || s.cancel == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.cancel()
|
|
||||||
s.wg.Wait()
|
|
||||||
s.timersMu.Lock()
|
|
||||||
for requestID, timer := range s.timers {
|
|
||||||
if timer != nil {
|
|
||||||
timer.Stop()
|
|
||||||
}
|
|
||||||
delete(s.timers, requestID)
|
|
||||||
}
|
|
||||||
s.timersMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scheduler) TrackScheduled(record *storagemodel.TreasuryRequest) {
|
|
||||||
if s == nil || s.service == nil || record == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(record.RequestID) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if record.Status != storagemodel.TreasuryRequestStatusScheduled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestID := strings.TrimSpace(record.RequestID)
|
|
||||||
when := record.ScheduledAt
|
|
||||||
if when.IsZero() {
|
|
||||||
when = time.Now()
|
|
||||||
}
|
|
||||||
delay := time.Until(when)
|
|
||||||
if delay <= 0 {
|
|
||||||
s.Untrack(requestID)
|
|
||||||
go s.executeAndNotifyByID(context.Background(), requestID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.timersMu.Lock()
|
|
||||||
if existing := s.timers[requestID]; existing != nil {
|
|
||||||
existing.Stop()
|
|
||||||
}
|
|
||||||
s.timers[requestID] = time.AfterFunc(delay, func() {
|
|
||||||
s.Untrack(requestID)
|
|
||||||
s.executeAndNotifyByID(context.Background(), requestID)
|
|
||||||
})
|
|
||||||
s.timersMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scheduler) Untrack(requestID string) {
|
|
||||||
if s == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.timersMu.Lock()
|
|
||||||
if timer := s.timers[requestID]; timer != nil {
|
|
||||||
timer.Stop()
|
|
||||||
}
|
|
||||||
delete(s.timers, requestID)
|
|
||||||
s.timersMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scheduler) hydrateTimers(ctx context.Context) {
|
|
||||||
if s == nil || s.service == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scheduled, err := s.service.ScheduledRequests(ctx, 1000)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to hydrate scheduled treasury requests", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, record := range scheduled {
|
|
||||||
s.TrackScheduled(&record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scheduler) sweep(ctx context.Context) {
|
|
||||||
if s == nil || s.service == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
confirmed, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
|
|
||||||
storagemodel.TreasuryRequestStatusConfirmed,
|
|
||||||
}, now, 100)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to list confirmed treasury requests", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, request := range confirmed {
|
|
||||||
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduled, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
|
|
||||||
storagemodel.TreasuryRequestStatusScheduled,
|
|
||||||
}, now, 100)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to list scheduled treasury requests", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, request := range scheduled {
|
|
||||||
s.Untrack(strings.TrimSpace(request.RequestID))
|
|
||||||
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scheduler) executeAndNotifyByID(ctx context.Context, requestID string) {
|
|
||||||
if s == nil || s.service == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCtx := ctx
|
|
||||||
if runCtx == nil {
|
|
||||||
runCtx = context.Background()
|
|
||||||
}
|
|
||||||
withTimeout, cancel := context.WithTimeout(runCtx, 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result, err := s.service.ExecuteRequest(withTimeout, requestID)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("Failed to execute treasury request", zap.Error(err), zap.String("request_id", requestID))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if result == nil || result.Request == nil {
|
|
||||||
s.logger.Debug("Treasury execution produced no result", zap.String("request_id", requestID))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.notify == nil {
|
|
||||||
s.logger.Warn("Treasury execution notifier is unavailable", zap.String("request_id", requestID))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text := executionMessage(result)
|
|
||||||
if strings.TrimSpace(text) == "" {
|
|
||||||
s.logger.Debug("Treasury execution result has no notification text",
|
|
||||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
|
||||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatID := strings.TrimSpace(result.Request.ChatID)
|
|
||||||
if chatID == "" {
|
|
||||||
s.logger.Warn("Treasury execution notification skipped: empty chat_id",
|
|
||||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("Sending treasury execution notification",
|
|
||||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
|
||||||
|
|
||||||
notifyCtx := context.Background()
|
|
||||||
if ctx != nil {
|
|
||||||
notifyCtx = ctx
|
|
||||||
}
|
|
||||||
notifyCtx, notifyCancel := context.WithTimeout(notifyCtx, 15*time.Second)
|
|
||||||
defer notifyCancel()
|
|
||||||
|
|
||||||
if err := s.notify(notifyCtx, chatID, text); err != nil {
|
|
||||||
s.logger.Warn("Failed to notify treasury execution result",
|
|
||||||
zap.Error(err),
|
|
||||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.logger.Info("Treasury execution notification sent",
|
|
||||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
|
||||||
zap.String("chat_id", chatID),
|
|
||||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
|
||||||
}
|
|
||||||
|
|
||||||
func executionMessage(result *ExecutionResult) string {
|
|
||||||
if result == nil || result.Request == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
request := result.Request
|
|
||||||
switch request.Status {
|
|
||||||
case storagemodel.TreasuryRequestStatusExecuted:
|
|
||||||
op := "Funding"
|
|
||||||
sign := "+"
|
|
||||||
if request.OperationType == storagemodel.TreasuryOperationWithdraw {
|
|
||||||
op = "Withdrawal"
|
|
||||||
sign = "-"
|
|
||||||
}
|
|
||||||
balanceAmount := "unavailable"
|
|
||||||
balanceCurrency := strings.TrimSpace(request.Currency)
|
|
||||||
if result.NewBalance != nil {
|
|
||||||
if strings.TrimSpace(result.NewBalance.Amount) != "" {
|
|
||||||
balanceAmount = strings.TrimSpace(result.NewBalance.Amount)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(result.NewBalance.Currency) != "" {
|
|
||||||
balanceCurrency = strings.TrimSpace(result.NewBalance.Currency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "*" + op + " completed*\n\n" +
|
|
||||||
"*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
|
|
||||||
"*Amount:* " + markdownCode(sign+strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
|
|
||||||
"*New balance:* " + markdownCode(balanceAmount+" "+balanceCurrency) + "\n\n" +
|
|
||||||
"*Reference:* " + markdownCode(strings.TrimSpace(request.RequestID))
|
|
||||||
case storagemodel.TreasuryRequestStatusFailed:
|
|
||||||
reason := strings.TrimSpace(request.ErrorMessage)
|
|
||||||
if reason == "" && result.ExecutionError != nil {
|
|
||||||
reason = strings.TrimSpace(result.ExecutionError.Error())
|
|
||||||
}
|
|
||||||
if reason == "" {
|
|
||||||
reason = "Unknown error."
|
|
||||||
}
|
|
||||||
return "*Execution failed*\n\n" +
|
|
||||||
"*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
|
|
||||||
"*Amount:* " + markdownCode(strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
|
|
||||||
"*Status:* " + markdownCode("FAILED") + "\n" +
|
|
||||||
"*Reason:* " + markdownCode(compactForMarkdown(reason)) + "\n\n" +
|
|
||||||
"*Request ID:* " + markdownCode(strings.TrimSpace(request.RequestID))
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestAccountCode(request *storagemodel.TreasuryRequest) string {
|
|
||||||
if request == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if code := strings.TrimSpace(request.LedgerAccountCode); code != "" {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(request.LedgerAccountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownCode(value string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
value = "N/A"
|
|
||||||
}
|
|
||||||
value = strings.ReplaceAll(value, "`", "'")
|
|
||||||
return "`" + value + "`"
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactForMarkdown(value string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return "Unknown error."
|
|
||||||
}
|
|
||||||
value = strings.ReplaceAll(value, "\r\n", " ")
|
|
||||||
value = strings.ReplaceAll(value, "\n", " ")
|
|
||||||
value = strings.ReplaceAll(value, "\r", " ")
|
|
||||||
return strings.Join(strings.Fields(value), " ")
|
|
||||||
}
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
package treasury
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrActiveTreasuryRequest = errors.New("active treasury request exists")
|
|
||||||
|
|
||||||
type CreateRequestInput struct {
|
|
||||||
OperationType storagemodel.TreasuryOperationType
|
|
||||||
TelegramUserID string
|
|
||||||
LedgerAccountID string
|
|
||||||
ChatID string
|
|
||||||
Amount string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountProfile struct {
|
|
||||||
AccountID string
|
|
||||||
AccountCode string
|
|
||||||
Currency string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExecutionResult struct {
|
|
||||||
Request *storagemodel.TreasuryRequest
|
|
||||||
NewBalance *ledger.Balance
|
|
||||||
ExecutionError error
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo storage.TreasuryRequestsStore
|
|
||||||
ledger ledger.Client
|
|
||||||
|
|
||||||
validator *Validator
|
|
||||||
executionDelay time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewService(
|
|
||||||
logger mlogger.Logger,
|
|
||||||
repo storage.TreasuryRequestsStore,
|
|
||||||
ledgerClient ledger.Client,
|
|
||||||
executionDelay time.Duration,
|
|
||||||
maxPerOperation string,
|
|
||||||
maxDaily string,
|
|
||||||
) (*Service, error) {
|
|
||||||
if logger == nil {
|
|
||||||
return nil, merrors.InvalidArgument("logger is required", "logger")
|
|
||||||
}
|
|
||||||
if repo == nil {
|
|
||||||
return nil, merrors.InvalidArgument("treasury repository is required", "repo")
|
|
||||||
}
|
|
||||||
if ledgerClient == nil {
|
|
||||||
return nil, merrors.InvalidArgument("ledger client is required", "ledger_client")
|
|
||||||
}
|
|
||||||
if executionDelay <= 0 {
|
|
||||||
executionDelay = 30 * time.Second
|
|
||||||
}
|
|
||||||
validator, err := NewValidator(repo, maxPerOperation, maxDaily)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Service{
|
|
||||||
logger: logger.Named("treasury_service"),
|
|
||||||
repo: repo,
|
|
||||||
ledger: ledgerClient,
|
|
||||||
validator: validator,
|
|
||||||
executionDelay: executionDelay,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ExecutionDelay() time.Duration {
|
|
||||||
if s == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return s.executionDelay
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) MaxPerOperationLimit() string {
|
|
||||||
if s == nil || s.validator == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return s.validator.MaxPerOperation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
if s == nil || s.repo == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return s.repo.FindActiveByLedgerAccountID(ctx, ledgerAccountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
if s == nil || s.repo == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return s.repo.FindByRequestID(ctx, requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error) {
|
|
||||||
if s == nil || s.ledger == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
|
|
||||||
if ledgerAccountID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
account, err := s.ledger.GetAccount(ctx, ledgerAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if account == nil {
|
|
||||||
return nil, merrors.NoData("ledger account not found")
|
|
||||||
}
|
|
||||||
return &AccountProfile{
|
|
||||||
AccountID: ledgerAccountID,
|
|
||||||
AccountCode: resolveAccountCode(account, ledgerAccountID),
|
|
||||||
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
if s == nil || s.repo == nil || s.ledger == nil || s.validator == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
input.TelegramUserID = strings.TrimSpace(input.TelegramUserID)
|
|
||||||
input.LedgerAccountID = strings.TrimSpace(input.LedgerAccountID)
|
|
||||||
input.ChatID = strings.TrimSpace(input.ChatID)
|
|
||||||
input.Amount = strings.TrimSpace(input.Amount)
|
|
||||||
|
|
||||||
switch input.OperationType {
|
|
||||||
case storagemodel.TreasuryOperationFund, storagemodel.TreasuryOperationWithdraw:
|
|
||||||
default:
|
|
||||||
return nil, merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
|
||||||
}
|
|
||||||
if input.TelegramUserID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
|
||||||
}
|
|
||||||
if input.LedgerAccountID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
|
||||||
}
|
|
||||||
if input.ChatID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("chat_id is required", "chat_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
active, err := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if active != nil {
|
|
||||||
return active, ErrActiveTreasuryRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
amountRat, normalizedAmount, err := s.validator.ValidateAmount(input.Amount)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := s.validator.ValidateDailyLimit(ctx, input.LedgerAccountID, amountRat, time.Now()); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
account, err := s.ledger.GetAccount(ctx, input.LedgerAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if account == nil || strings.TrimSpace(account.Currency) == "" {
|
|
||||||
return nil, merrors.Internal("ledger account currency is unavailable")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(account.OrganizationRef) == "" {
|
|
||||||
return nil, merrors.Internal("ledger account organization is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
requestID := newRequestID()
|
|
||||||
record := &storagemodel.TreasuryRequest{
|
|
||||||
RequestID: requestID,
|
|
||||||
OperationType: input.OperationType,
|
|
||||||
TelegramUserID: input.TelegramUserID,
|
|
||||||
LedgerAccountID: input.LedgerAccountID,
|
|
||||||
LedgerAccountCode: resolveAccountCode(account, input.LedgerAccountID),
|
|
||||||
OrganizationRef: account.OrganizationRef,
|
|
||||||
ChatID: input.ChatID,
|
|
||||||
Amount: normalizedAmount,
|
|
||||||
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
|
|
||||||
Status: storagemodel.TreasuryRequestStatusCreated,
|
|
||||||
IdempotencyKey: fmt.Sprintf("chsettle:%s", requestID),
|
|
||||||
Active: true,
|
|
||||||
}
|
|
||||||
if err := s.repo.Create(ctx, record); err != nil {
|
|
||||||
if errors.Is(err, storage.ErrDuplicate) {
|
|
||||||
active, fetchErr := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
|
||||||
if fetchErr != nil {
|
|
||||||
return nil, fetchErr
|
|
||||||
}
|
|
||||||
if active != nil {
|
|
||||||
return active, ErrActiveTreasuryRequest
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logRequest(record, "created", nil)
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
|
||||||
if requestID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if record == nil {
|
|
||||||
return nil, merrors.NoData("treasury request not found")
|
|
||||||
}
|
|
||||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
|
||||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch record.Status {
|
|
||||||
case storagemodel.TreasuryRequestStatusScheduled:
|
|
||||||
return record, nil
|
|
||||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed:
|
|
||||||
now := time.Now()
|
|
||||||
record.ConfirmedAt = now
|
|
||||||
record.ScheduledAt = now.Add(s.executionDelay)
|
|
||||||
record.Status = storagemodel.TreasuryRequestStatusScheduled
|
|
||||||
record.Active = true
|
|
||||||
record.ErrorMessage = ""
|
|
||||||
default:
|
|
||||||
return nil, merrors.InvalidArgument("treasury request cannot be confirmed in current status", "status")
|
|
||||||
}
|
|
||||||
if err := s.repo.Update(ctx, record); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.logRequest(record, "scheduled", nil)
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
|
||||||
if requestID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if record == nil {
|
|
||||||
return nil, merrors.NoData("treasury request not found")
|
|
||||||
}
|
|
||||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
|
||||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch record.Status {
|
|
||||||
case storagemodel.TreasuryRequestStatusCancelled:
|
|
||||||
return record, nil
|
|
||||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed, storagemodel.TreasuryRequestStatusScheduled:
|
|
||||||
record.Status = storagemodel.TreasuryRequestStatusCancelled
|
|
||||||
record.CancelledAt = time.Now()
|
|
||||||
record.Active = false
|
|
||||||
default:
|
|
||||||
return nil, merrors.InvalidArgument("treasury request cannot be cancelled in current status", "status")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.repo.Update(ctx, record); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.logRequest(record, "cancelled", nil)
|
|
||||||
return record, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ExecuteRequest(ctx context.Context, requestID string) (*ExecutionResult, error) {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if record == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch record.Status {
|
|
||||||
case storagemodel.TreasuryRequestStatusExecuted,
|
|
||||||
storagemodel.TreasuryRequestStatusCancelled,
|
|
||||||
storagemodel.TreasuryRequestStatusFailed:
|
|
||||||
return nil, nil
|
|
||||||
case storagemodel.TreasuryRequestStatusScheduled:
|
|
||||||
claimed, err := s.repo.ClaimScheduled(ctx, requestID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !claimed {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
record, err = s.repo.FindByRequestID(ctx, requestID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if record == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if record.Status != storagemodel.TreasuryRequestStatusConfirmed {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return s.executeClaimed(ctx, record)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.TreasuryRequest) (*ExecutionResult, error) {
|
|
||||||
if record == nil {
|
|
||||||
return nil, merrors.InvalidArgument("treasury request is required", "request")
|
|
||||||
}
|
|
||||||
postReq := ledger.PostRequest{
|
|
||||||
AccountID: record.LedgerAccountID,
|
|
||||||
OrganizationRef: record.OrganizationRef,
|
|
||||||
Amount: record.Amount,
|
|
||||||
Currency: record.Currency,
|
|
||||||
Reference: record.RequestID,
|
|
||||||
IdempotencyKey: record.IdempotencyKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
opResult *ledger.OperationResult
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
switch record.OperationType {
|
|
||||||
case storagemodel.TreasuryOperationFund:
|
|
||||||
opResult, err = s.ledger.ExternalCredit(ctx, postReq)
|
|
||||||
case storagemodel.TreasuryOperationWithdraw:
|
|
||||||
opResult, err = s.ledger.ExternalDebit(ctx, postReq)
|
|
||||||
default:
|
|
||||||
err = merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if err != nil {
|
|
||||||
record.Status = storagemodel.TreasuryRequestStatusFailed
|
|
||||||
record.Active = false
|
|
||||||
record.ExecutedAt = now
|
|
||||||
record.ErrorMessage = strings.TrimSpace(err.Error())
|
|
||||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
|
||||||
return nil, saveErr
|
|
||||||
}
|
|
||||||
s.logRequest(record, "failed", err)
|
|
||||||
return &ExecutionResult{
|
|
||||||
Request: record,
|
|
||||||
ExecutionError: err,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if opResult != nil {
|
|
||||||
record.LedgerReference = strings.TrimSpace(opResult.Reference)
|
|
||||||
}
|
|
||||||
record.Status = storagemodel.TreasuryRequestStatusExecuted
|
|
||||||
record.Active = false
|
|
||||||
record.ExecutedAt = now
|
|
||||||
record.ErrorMessage = ""
|
|
||||||
|
|
||||||
balance, balanceErr := s.ledger.GetBalance(ctx, record.LedgerAccountID)
|
|
||||||
if balanceErr != nil {
|
|
||||||
record.ErrorMessage = strings.TrimSpace(balanceErr.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
|
||||||
return nil, saveErr
|
|
||||||
}
|
|
||||||
s.logRequest(record, "executed", nil)
|
|
||||||
return &ExecutionResult{
|
|
||||||
Request: record,
|
|
||||||
NewBalance: balance,
|
|
||||||
ExecutionError: balanceErr,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]storagemodel.TreasuryRequest, error) {
|
|
||||||
if s == nil || s.repo == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return s.repo.FindDueByStatus(ctx, statuses, now, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ScheduledRequests(ctx context.Context, limit int64) ([]storagemodel.TreasuryRequest, error) {
|
|
||||||
if s == nil || s.repo == nil {
|
|
||||||
return nil, merrors.Internal("treasury service unavailable")
|
|
||||||
}
|
|
||||||
return s.repo.FindDueByStatus(
|
|
||||||
ctx,
|
|
||||||
[]storagemodel.TreasuryRequestStatus{storagemodel.TreasuryRequestStatusScheduled},
|
|
||||||
time.Now().Add(10*365*24*time.Hour),
|
|
||||||
limit,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ParseAmount(value string) (*big.Rat, error) {
|
|
||||||
return parseAmountRat(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string, err error) {
|
|
||||||
if s == nil || s.logger == nil || record == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fields := []zap.Field{
|
|
||||||
zap.String("request_id", strings.TrimSpace(record.RequestID)),
|
|
||||||
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
|
|
||||||
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
|
|
||||||
zap.String("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)),
|
|
||||||
zap.String("chat_id", strings.TrimSpace(record.ChatID)),
|
|
||||||
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
|
|
||||||
zap.String("amount", strings.TrimSpace(record.Amount)),
|
|
||||||
zap.String("currency", strings.TrimSpace(record.Currency)),
|
|
||||||
zap.String("status", status),
|
|
||||||
zap.String("ledger_reference", strings.TrimSpace(record.LedgerReference)),
|
|
||||||
zap.String("error_message", strings.TrimSpace(record.ErrorMessage)),
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fields = append(fields, zap.Error(err))
|
|
||||||
}
|
|
||||||
s.logger.Info("treasury_request", fields...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRequestID() string {
|
|
||||||
return "TG-TREASURY-" + strings.ToUpper(bson.NewObjectID().Hex())
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string {
|
|
||||||
if account != nil {
|
|
||||||
if code := strings.TrimSpace(account.AccountCode); code != "" {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
if code := strings.TrimSpace(account.AccountID); code != "" {
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(fallbackAccountID)
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
package treasury
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math/big"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
)
|
|
||||||
|
|
||||||
var treasuryAmountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
|
|
||||||
|
|
||||||
type LimitKind string
|
|
||||||
|
|
||||||
const (
|
|
||||||
LimitKindPerOperation LimitKind = "per_operation"
|
|
||||||
LimitKindDaily LimitKind = "daily"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LimitError struct {
|
|
||||||
Kind LimitKind
|
|
||||||
Max string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *LimitError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return "limit exceeded"
|
|
||||||
}
|
|
||||||
switch e.Kind {
|
|
||||||
case LimitKindPerOperation:
|
|
||||||
return "max amount per operation exceeded"
|
|
||||||
case LimitKindDaily:
|
|
||||||
return "max daily amount exceeded"
|
|
||||||
default:
|
|
||||||
return "limit exceeded"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *LimitError) LimitKind() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(e.Kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *LimitError) LimitMax() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Max
|
|
||||||
}
|
|
||||||
|
|
||||||
type Validator struct {
|
|
||||||
repo storage.TreasuryRequestsStore
|
|
||||||
|
|
||||||
maxPerOperation *big.Rat
|
|
||||||
maxDaily *big.Rat
|
|
||||||
|
|
||||||
maxPerOperationRaw string
|
|
||||||
maxDailyRaw string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewValidator(repo storage.TreasuryRequestsStore, maxPerOperation string, maxDaily string) (*Validator, error) {
|
|
||||||
validator := &Validator{
|
|
||||||
repo: repo,
|
|
||||||
maxPerOperationRaw: strings.TrimSpace(maxPerOperation),
|
|
||||||
maxDailyRaw: strings.TrimSpace(maxDaily),
|
|
||||||
}
|
|
||||||
if validator.maxPerOperationRaw != "" {
|
|
||||||
value, err := parseAmountRat(validator.maxPerOperationRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("treasury max_amount_per_operation is invalid", "treasury.limits.max_amount_per_operation")
|
|
||||||
}
|
|
||||||
validator.maxPerOperation = value
|
|
||||||
}
|
|
||||||
if validator.maxDailyRaw != "" {
|
|
||||||
value, err := parseAmountRat(validator.maxDailyRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("treasury max_daily_amount is invalid", "treasury.limits.max_daily_amount")
|
|
||||||
}
|
|
||||||
validator.maxDaily = value
|
|
||||||
}
|
|
||||||
return validator, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) MaxPerOperation() string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return v.maxPerOperationRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) MaxDaily() string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return v.maxDailyRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) ValidateAmount(amount string) (*big.Rat, string, error) {
|
|
||||||
amount = strings.TrimSpace(amount)
|
|
||||||
value, err := parseAmountRat(amount)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if v != nil && v.maxPerOperation != nil && value.Cmp(v.maxPerOperation) > 0 {
|
|
||||||
return nil, "", &LimitError{
|
|
||||||
Kind: LimitKindPerOperation,
|
|
||||||
Max: v.maxPerOperationRaw,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value, amount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) ValidateDailyLimit(ctx context.Context, ledgerAccountID string, amount *big.Rat, now time.Time) error {
|
|
||||||
if v == nil || v.maxDaily == nil || v.repo == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if amount == nil {
|
|
||||||
return merrors.InvalidArgument("amount is required", "amount")
|
|
||||||
}
|
|
||||||
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
dayEnd := dayStart.Add(24 * time.Hour)
|
|
||||||
|
|
||||||
records, err := v.repo.ListByAccountAndStatuses(
|
|
||||||
ctx,
|
|
||||||
ledgerAccountID,
|
|
||||||
[]storagemodel.TreasuryRequestStatus{
|
|
||||||
storagemodel.TreasuryRequestStatusCreated,
|
|
||||||
storagemodel.TreasuryRequestStatusConfirmed,
|
|
||||||
storagemodel.TreasuryRequestStatusScheduled,
|
|
||||||
storagemodel.TreasuryRequestStatusExecuted,
|
|
||||||
},
|
|
||||||
dayStart,
|
|
||||||
dayEnd,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
total := new(big.Rat)
|
|
||||||
for _, record := range records {
|
|
||||||
next, err := parseAmountRat(record.Amount)
|
|
||||||
if err != nil {
|
|
||||||
return merrors.Internal("treasury request amount is invalid")
|
|
||||||
}
|
|
||||||
total.Add(total, next)
|
|
||||||
}
|
|
||||||
total.Add(total, amount)
|
|
||||||
if total.Cmp(v.maxDaily) > 0 {
|
|
||||||
return &LimitError{
|
|
||||||
Kind: LimitKindDaily,
|
|
||||||
Max: v.maxDailyRaw,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAmountRat(value string) (*big.Rat, error) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return nil, merrors.InvalidArgument("amount is required", "amount")
|
|
||||||
}
|
|
||||||
if !treasuryAmountPattern.MatchString(value) {
|
|
||||||
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
|
|
||||||
}
|
|
||||||
amount := new(big.Rat)
|
|
||||||
if _, ok := amount.SetString(value); !ok {
|
|
||||||
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
|
|
||||||
}
|
|
||||||
if amount.Sign() <= 0 {
|
|
||||||
return nil, merrors.InvalidArgument("amount must be positive", "amount")
|
|
||||||
}
|
|
||||||
return amount, nil
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/internal/appversion"
|
|
||||||
si "github.com/tech/sendico/gateway/chsettle/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("gateway", appversion.Create(), factory)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
|
||||||
"github.com/tech/sendico/pkg/model"
|
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PaymentStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PaymentStatusCreated PaymentStatus = "created" // created
|
|
||||||
PaymentStatusProcessing PaymentStatus = "processing" // processing
|
|
||||||
PaymentStatusWaiting PaymentStatus = "waiting" // waiting external action
|
|
||||||
PaymentStatusSuccess PaymentStatus = "success" // final success
|
|
||||||
PaymentStatusFailed PaymentStatus = "failed" // final failure
|
|
||||||
PaymentStatusCancelled PaymentStatus = "cancelled" // cancelled final
|
|
||||||
)
|
|
||||||
|
|
||||||
type PaymentRecord struct {
|
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
|
||||||
OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"`
|
|
||||||
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
|
|
||||||
ConfirmationRef string `bson:"confirmationRef,omitempty" json:"confirmation_ref,omitempty"`
|
|
||||||
ConfirmationMessageID string `bson:"confirmationMessageId,omitempty" json:"confirmation_message_id,omitempty"`
|
|
||||||
ConfirmationReplyMessageID string `bson:"confirmationReplyMessageId,omitempty" json:"confirmation_reply_message_id,omitempty"`
|
|
||||||
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
|
|
||||||
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
|
|
||||||
IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"`
|
|
||||||
PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"`
|
|
||||||
Scenario string `bson:"scenario,omitempty" json:"scenario,omitempty"`
|
|
||||||
OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"`
|
|
||||||
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
|
|
||||||
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
|
|
||||||
ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"`
|
|
||||||
Status PaymentStatus `bson:"status,omitempty" json:"status,omitempty"`
|
|
||||||
FailureReason string `bson:"failureReason,omitempty" json:"Failure_reason,omitempty"`
|
|
||||||
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`
|
|
||||||
ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"`
|
|
||||||
ExpiredAt time.Time `bson:"expiredAt,omitempty" json:"expired_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TelegramConfirmation struct {
|
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
|
||||||
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
|
||||||
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
|
|
||||||
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
|
|
||||||
RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"`
|
|
||||||
ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PendingConfirmation struct {
|
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
|
||||||
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
|
||||||
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
|
|
||||||
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
|
|
||||||
AcceptedUserIDs []string `bson:"acceptedUserIds,omitempty" json:"accepted_user_ids,omitempty"`
|
|
||||||
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
|
|
||||||
SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"`
|
|
||||||
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
|
|
||||||
Clarified bool `bson:"clarified,omitempty" json:"clarified,omitempty"`
|
|
||||||
ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
const (
|
|
||||||
paymentsCollection = "payments"
|
|
||||||
telegramConfirmationsCollection = "telegram_confirmations"
|
|
||||||
pendingConfirmationsCollection = "pending_confirmations"
|
|
||||||
treasuryRequestsCollection = "treasury_requests"
|
|
||||||
treasuryTelegramUsersCollection = "treasury_telegram_users"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (*PaymentRecord) Collection() string {
|
|
||||||
return paymentsCollection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*TelegramConfirmation) Collection() string {
|
|
||||||
return telegramConfirmationsCollection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*PendingConfirmation) Collection() string {
|
|
||||||
return pendingConfirmationsCollection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*TreasuryRequest) Collection() string {
|
|
||||||
return treasuryRequestsCollection
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*TreasuryTelegramUser) Collection() string {
|
|
||||||
return treasuryTelegramUsersCollection
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TreasuryOperationType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TreasuryOperationFund TreasuryOperationType = "fund"
|
|
||||||
TreasuryOperationWithdraw TreasuryOperationType = "withdraw"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TreasuryRequestStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
TreasuryRequestStatusCreated TreasuryRequestStatus = "created"
|
|
||||||
TreasuryRequestStatusConfirmed TreasuryRequestStatus = "confirmed"
|
|
||||||
TreasuryRequestStatusScheduled TreasuryRequestStatus = "scheduled"
|
|
||||||
TreasuryRequestStatusExecuted TreasuryRequestStatus = "executed"
|
|
||||||
TreasuryRequestStatusCancelled TreasuryRequestStatus = "cancelled"
|
|
||||||
TreasuryRequestStatusFailed TreasuryRequestStatus = "failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TreasuryRequest struct {
|
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
|
||||||
|
|
||||||
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
|
||||||
OperationType TreasuryOperationType `bson:"operationType,omitempty" json:"operation_type,omitempty"`
|
|
||||||
TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"`
|
|
||||||
LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"`
|
|
||||||
LedgerAccountCode string `bson:"ledgerAccountCode,omitempty" json:"ledger_account_code,omitempty"`
|
|
||||||
OrganizationRef string `bson:"organizationRef,omitempty" json:"organization_ref,omitempty"`
|
|
||||||
ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"`
|
|
||||||
Amount string `bson:"amount,omitempty" json:"amount,omitempty"`
|
|
||||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
|
||||||
Status TreasuryRequestStatus `bson:"status,omitempty" json:"status,omitempty"`
|
|
||||||
|
|
||||||
ConfirmedAt time.Time `bson:"confirmedAt,omitempty" json:"confirmed_at,omitempty"`
|
|
||||||
ScheduledAt time.Time `bson:"scheduledAt,omitempty" json:"scheduled_at,omitempty"`
|
|
||||||
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`
|
|
||||||
CancelledAt time.Time `bson:"cancelledAt,omitempty" json:"cancelled_at,omitempty"`
|
|
||||||
|
|
||||||
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
|
|
||||||
LedgerReference string `bson:"ledgerReference,omitempty" json:"ledger_reference,omitempty"`
|
|
||||||
ErrorMessage string `bson:"errorMessage,omitempty" json:"error_message,omitempty"`
|
|
||||||
|
|
||||||
Active bool `bson:"active,omitempty" json:"active,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TreasuryTelegramUser struct {
|
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
|
||||||
|
|
||||||
TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"`
|
|
||||||
LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"`
|
|
||||||
AllowedChatIDs []string `bson:"allowedChatIds,omitempty" json:"allowed_chat_ids,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package mongo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/mongo/store"
|
|
||||||
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
|
|
||||||
"github.com/tech/sendico/pkg/db"
|
|
||||||
"github.com/tech/sendico/pkg/db/transaction"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Repository struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
conn *db.MongoConnection
|
|
||||||
db *mongo.Database
|
|
||||||
txFactory transaction.Factory
|
|
||||||
|
|
||||||
payments storage.PaymentsStore
|
|
||||||
tg storage.TelegramConfirmationsStore
|
|
||||||
pending storage.PendingConfirmationsStore
|
|
||||||
treasury storage.TreasuryRequestsStore
|
|
||||||
users storage.TreasuryTelegramUsersStore
|
|
||||||
outbox gatewayoutbox.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
if conn == nil {
|
|
||||||
return nil, merrors.InvalidArgument("mongo connection is nil")
|
|
||||||
}
|
|
||||||
client := conn.Client()
|
|
||||||
if client == nil {
|
|
||||||
return nil, merrors.Internal("mongo client is not initialised")
|
|
||||||
}
|
|
||||||
db := conn.Database()
|
|
||||||
if db == nil {
|
|
||||||
return nil, merrors.Internal("mongo database is not initialised")
|
|
||||||
}
|
|
||||||
dbName := db.Name()
|
|
||||||
logger = logger.Named("storage").Named("mongo")
|
|
||||||
if dbName != "" {
|
|
||||||
logger = logger.With(zap.String("database", dbName))
|
|
||||||
}
|
|
||||||
result := &Repository{
|
|
||||||
logger: logger,
|
|
||||||
conn: conn,
|
|
||||||
db: db,
|
|
||||||
txFactory: newMongoTransactionFactory(client),
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := result.conn.Ping(ctx); err != nil {
|
|
||||||
result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paymentsStore, err := store.NewPayments(result.logger, result.db)
|
|
||||||
if err != nil {
|
|
||||||
result.logger.Error("Failed to initialise payments store", zap.Error(err), zap.String("store", "payments"))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tgStore, err := store.NewTelegramConfirmations(result.logger, result.db)
|
|
||||||
if err != nil {
|
|
||||||
result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations"))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pendingStore, err := store.NewPendingConfirmations(result.logger, result.db)
|
|
||||||
if err != nil {
|
|
||||||
result.logger.Error("Failed to initialise pending confirmations store", zap.Error(err), zap.String("store", "pending_confirmations"))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
treasuryStore, err := store.NewTreasuryRequests(result.logger, result.db)
|
|
||||||
if err != nil {
|
|
||||||
result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests"))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
treasuryUsersStore, err := store.NewTreasuryTelegramUsers(result.logger, result.db)
|
|
||||||
if err != nil {
|
|
||||||
result.logger.Error("Failed to initialise treasury telegram users store", zap.Error(err), zap.String("store", "treasury_telegram_users"))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
|
|
||||||
if err != nil {
|
|
||||||
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result.payments = paymentsStore
|
|
||||||
result.tg = tgStore
|
|
||||||
result.pending = pendingStore
|
|
||||||
result.treasury = treasuryStore
|
|
||||||
result.users = treasuryUsersStore
|
|
||||||
result.outbox = outboxStore
|
|
||||||
result.logger.Info("Payment gateway MongoDB storage initialised")
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) Payments() storage.PaymentsStore {
|
|
||||||
return r.payments
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore {
|
|
||||||
return r.tg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore {
|
|
||||||
return r.pending
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore {
|
|
||||||
return r.treasury
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
|
|
||||||
return r.users
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) Outbox() gatewayoutbox.Store {
|
|
||||||
return r.outbox
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Repository) TransactionFactory() transaction.Factory {
|
|
||||||
return r.txFactory
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.Repository = (*Repository)(nil)
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
paymentsCollection = "payments"
|
|
||||||
fieldIdempotencyKey = "idempotencyKey"
|
|
||||||
fieldOperationRef = "operationRef"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Payments struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo repository.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) {
|
|
||||||
if db == nil {
|
|
||||||
return nil, merrors.InvalidArgument("mongo database is nil")
|
|
||||||
}
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("payments").With(zap.String("collection", paymentsCollection))
|
|
||||||
|
|
||||||
repo := repository.CreateMongoRepository(db, paymentsCollection)
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldIdempotencyKey, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create payments idempotency index", zap.Error(err), zap.String("index_field", fieldIdempotencyKey))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldOperationRef, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
Sparse: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create payments operation index", zap.Error(err), zap.String("index_field", fieldOperationRef))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &Payments{
|
|
||||||
logger: logger,
|
|
||||||
repo: repo,
|
|
||||||
}
|
|
||||||
p.logger.Debug("Payments store initialised")
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error) {
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key == "" {
|
|
||||||
return nil, merrors.InvalidArgument("idempotency key is required", "idempotency_key")
|
|
||||||
}
|
|
||||||
var result model.PaymentRecord
|
|
||||||
err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldIdempotencyKey, key), &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
p.logger.Warn("Payment record lookup failed", zap.String("idempotency_key", key), zap.Error(err))
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Payments) FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error) {
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key == "" {
|
|
||||||
return nil, merrors.InvalidArgument("operation reference is required", "operation_ref")
|
|
||||||
}
|
|
||||||
var result model.PaymentRecord
|
|
||||||
err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldOperationRef, key), &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
p.logger.Warn("Payment record lookup by operation ref failed", zap.String("operation_ref", key), zap.Error(err))
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) error {
|
|
||||||
if record == nil {
|
|
||||||
return merrors.InvalidArgument("payment record is nil", "record")
|
|
||||||
}
|
|
||||||
record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey)
|
|
||||||
record.QuoteRef = strings.TrimSpace(record.QuoteRef)
|
|
||||||
record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg)
|
|
||||||
record.TargetChatID = strings.TrimSpace(record.TargetChatID)
|
|
||||||
record.IntentRef = strings.TrimSpace(record.IntentRef)
|
|
||||||
record.OperationRef = strings.TrimSpace(record.OperationRef)
|
|
||||||
if record.IntentRef == "" {
|
|
||||||
return merrors.InvalidArgument("intention reference is required", "intent_ref")
|
|
||||||
}
|
|
||||||
if record.IdempotencyKey == "" {
|
|
||||||
return merrors.InvalidArgument("idempotency key is required", "idempotency_key")
|
|
||||||
}
|
|
||||||
if record.IntentRef == "" {
|
|
||||||
return merrors.InvalidArgument("intention reference key is required", "intent_ref")
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, err := p.FindByIdempotencyKey(ctx, record.IdempotencyKey)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
record.ID = existing.ID
|
|
||||||
if record.CreatedAt.IsZero() {
|
|
||||||
record.CreatedAt = existing.CreatedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = p.repo.Upsert(ctx, record)
|
|
||||||
if mongo.IsDuplicateKeyError(err) {
|
|
||||||
// Concurrent insert by idempotency key: resolve existing ID and retry replace-by-ID.
|
|
||||||
existing, lookupErr := p.FindByIdempotencyKey(ctx, record.IdempotencyKey)
|
|
||||||
if lookupErr != nil {
|
|
||||||
err = lookupErr
|
|
||||||
} else if existing != nil {
|
|
||||||
record.ID = existing.ID
|
|
||||||
if record.CreatedAt.IsZero() {
|
|
||||||
record.CreatedAt = existing.CreatedAt
|
|
||||||
}
|
|
||||||
err = p.repo.Upsert(ctx, record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
p.logger.Warn("Failed to upsert payment record",
|
|
||||||
zap.String("idempotency_key", record.IdempotencyKey),
|
|
||||||
zap.String("intent_ref", record.IntentRef),
|
|
||||||
zap.String("quote_ref", record.QuoteRef),
|
|
||||||
zap.Error(err))
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.PaymentsStore = (*Payments)(nil)
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakePaymentsRepo struct {
|
|
||||||
repository.Repository
|
|
||||||
|
|
||||||
records map[string]*model.PaymentRecord
|
|
||||||
findErrByCall map[int]error
|
|
||||||
duplicateWhenZeroID bool
|
|
||||||
findCalls int
|
|
||||||
upsertCalls int
|
|
||||||
upsertIDs []bson.ObjectID
|
|
||||||
upsertIdempotencyKey []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePaymentsRepo) FindOneByFilter(_ context.Context, query repository.FilterQuery, result storable.Storable) error {
|
|
||||||
f.findCalls++
|
|
||||||
if err, ok := f.findErrByCall[f.findCalls]; ok {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, ok := result.(*model.PaymentRecord)
|
|
||||||
if !ok {
|
|
||||||
return merrors.InvalidDataType("expected *model.PaymentRecord")
|
|
||||||
}
|
|
||||||
|
|
||||||
doc := query.BuildQuery()
|
|
||||||
if key := stringField(doc, fieldIdempotencyKey); key != "" {
|
|
||||||
stored, ok := f.records[key]
|
|
||||||
if !ok {
|
|
||||||
return merrors.NoData("payment not found by filter")
|
|
||||||
}
|
|
||||||
*rec = *stored
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if operationRef := stringField(doc, fieldOperationRef); operationRef != "" {
|
|
||||||
for _, stored := range f.records {
|
|
||||||
if strings.TrimSpace(stored.OperationRef) == operationRef {
|
|
||||||
*rec = *stored
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merrors.NoData("payment not found by operation ref")
|
|
||||||
}
|
|
||||||
|
|
||||||
return merrors.NoData("payment not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePaymentsRepo) Upsert(_ context.Context, obj storable.Storable) error {
|
|
||||||
f.upsertCalls++
|
|
||||||
|
|
||||||
rec, ok := obj.(*model.PaymentRecord)
|
|
||||||
if !ok {
|
|
||||||
return merrors.InvalidDataType("expected *model.PaymentRecord")
|
|
||||||
}
|
|
||||||
f.upsertIDs = append(f.upsertIDs, rec.ID)
|
|
||||||
f.upsertIdempotencyKey = append(f.upsertIdempotencyKey, rec.IdempotencyKey)
|
|
||||||
|
|
||||||
if f.duplicateWhenZeroID && rec.ID.IsZero() {
|
|
||||||
if _, exists := f.records[rec.IdempotencyKey]; exists {
|
|
||||||
return mongo.WriteException{
|
|
||||||
WriteErrors: mongo.WriteErrors{
|
|
||||||
{
|
|
||||||
Code: 11000,
|
|
||||||
Message: "E11000 duplicate key error collection: chsettle_gateway.payments",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyRec := *rec
|
|
||||||
if copyRec.ID.IsZero() {
|
|
||||||
copyRec.ID = bson.NewObjectID()
|
|
||||||
}
|
|
||||||
if copyRec.CreatedAt.IsZero() {
|
|
||||||
copyRec.CreatedAt = time.Now().UTC()
|
|
||||||
}
|
|
||||||
copyRec.UpdatedAt = time.Now().UTC()
|
|
||||||
if f.records == nil {
|
|
||||||
f.records = map[string]*model.PaymentRecord{}
|
|
||||||
}
|
|
||||||
f.records[copyRec.IdempotencyKey] = ©Rec
|
|
||||||
*rec = copyRec
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentsUpsert_ReusesExistingIDFromIdempotencyLookup(t *testing.T) {
|
|
||||||
key := "idem-existing"
|
|
||||||
existingID := bson.NewObjectID()
|
|
||||||
existingCreatedAt := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
repo := &fakePaymentsRepo{
|
|
||||||
records: map[string]*model.PaymentRecord{
|
|
||||||
key: {
|
|
||||||
Base: storable.Base{
|
|
||||||
ID: existingID,
|
|
||||||
CreatedAt: existingCreatedAt,
|
|
||||||
UpdatedAt: existingCreatedAt,
|
|
||||||
},
|
|
||||||
IdempotencyKey: key,
|
|
||||||
IntentRef: "pi-old",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
duplicateWhenZeroID: true,
|
|
||||||
}
|
|
||||||
store := &Payments{logger: zap.NewNop(), repo: repo}
|
|
||||||
|
|
||||||
record := &model.PaymentRecord{
|
|
||||||
IdempotencyKey: key,
|
|
||||||
IntentRef: "pi-new",
|
|
||||||
QuoteRef: "quote-new",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Upsert(context.Background(), record); err != nil {
|
|
||||||
t.Fatalf("upsert failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if repo.upsertCalls != 1 {
|
|
||||||
t.Fatalf("expected one upsert call, got %d", repo.upsertCalls)
|
|
||||||
}
|
|
||||||
if len(repo.upsertIDs) != 1 || repo.upsertIDs[0] != existingID {
|
|
||||||
t.Fatalf("expected upsert to reuse existing id %s, got %+v", existingID.Hex(), repo.upsertIDs)
|
|
||||||
}
|
|
||||||
if record.ID != existingID {
|
|
||||||
t.Fatalf("record ID mismatch: got %s want %s", record.ID.Hex(), existingID.Hex())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentsUpsert_RetriesAfterDuplicateKeyRace(t *testing.T) {
|
|
||||||
key := "idem-race"
|
|
||||||
existingID := bson.NewObjectID()
|
|
||||||
|
|
||||||
repo := &fakePaymentsRepo{
|
|
||||||
records: map[string]*model.PaymentRecord{
|
|
||||||
key: {
|
|
||||||
Base: storable.Base{
|
|
||||||
ID: existingID,
|
|
||||||
CreatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC),
|
|
||||||
UpdatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC),
|
|
||||||
},
|
|
||||||
IdempotencyKey: key,
|
|
||||||
IntentRef: "pi-existing",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
findErrByCall: map[int]error{
|
|
||||||
1: merrors.NoData("payment not found by filter"),
|
|
||||||
},
|
|
||||||
duplicateWhenZeroID: true,
|
|
||||||
}
|
|
||||||
store := &Payments{logger: zap.NewNop(), repo: repo}
|
|
||||||
|
|
||||||
record := &model.PaymentRecord{
|
|
||||||
IdempotencyKey: key,
|
|
||||||
IntentRef: "pi-new",
|
|
||||||
QuoteRef: "quote-new",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.Upsert(context.Background(), record); err != nil {
|
|
||||||
t.Fatalf("upsert failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if repo.upsertCalls != 2 {
|
|
||||||
t.Fatalf("expected two upsert calls, got %d", repo.upsertCalls)
|
|
||||||
}
|
|
||||||
if len(repo.upsertIDs) != 2 {
|
|
||||||
t.Fatalf("expected two upsert IDs, got %d", len(repo.upsertIDs))
|
|
||||||
}
|
|
||||||
if !repo.upsertIDs[0].IsZero() {
|
|
||||||
t.Fatalf("expected first upsert to use zero id due stale read, got %s", repo.upsertIDs[0].Hex())
|
|
||||||
}
|
|
||||||
if repo.upsertIDs[1] != existingID {
|
|
||||||
t.Fatalf("expected retry to use existing id %s, got %s", existingID.Hex(), repo.upsertIDs[1].Hex())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentsUpsert_PropagatesNoSuchTransactionAfterDuplicate(t *testing.T) {
|
|
||||||
key := "idem-nosuchtx"
|
|
||||||
|
|
||||||
repo := &fakePaymentsRepo{
|
|
||||||
records: map[string]*model.PaymentRecord{
|
|
||||||
key: {
|
|
||||||
Base: storable.Base{
|
|
||||||
ID: bson.NewObjectID(),
|
|
||||||
CreatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC),
|
|
||||||
UpdatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC),
|
|
||||||
},
|
|
||||||
IdempotencyKey: key,
|
|
||||||
IntentRef: "pi-existing",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
findErrByCall: map[int]error{
|
|
||||||
1: merrors.NoData("payment not found by filter"),
|
|
||||||
2: mongo.CommandError{
|
|
||||||
Code: 251,
|
|
||||||
Name: "NoSuchTransaction",
|
|
||||||
Message: "Transaction with { txnNumber: 2 } has been aborted.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
duplicateWhenZeroID: true,
|
|
||||||
}
|
|
||||||
store := &Payments{logger: zap.NewNop(), repo: repo}
|
|
||||||
|
|
||||||
record := &model.PaymentRecord{
|
|
||||||
IdempotencyKey: key,
|
|
||||||
IntentRef: "pi-new",
|
|
||||||
QuoteRef: "quote-new",
|
|
||||||
}
|
|
||||||
|
|
||||||
err := store.Upsert(context.Background(), record)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "NoSuchTransaction") {
|
|
||||||
t.Fatalf("expected NoSuchTransaction error, got %v", err)
|
|
||||||
}
|
|
||||||
if repo.upsertCalls != 1 {
|
|
||||||
t.Fatalf("expected one upsert attempt before lookup failure, got %d", repo.upsertCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringField(doc bson.D, key string) string {
|
|
||||||
for _, entry := range doc {
|
|
||||||
if entry.Key != key {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
res, _ := entry.Value.(string)
|
|
||||||
return strings.TrimSpace(res)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
mutil "github.com/tech/sendico/pkg/mutil/db"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
pendingConfirmationsCollection = "pending_confirmations"
|
|
||||||
fieldPendingRequestID = "requestId"
|
|
||||||
fieldPendingMessageID = "messageId"
|
|
||||||
fieldPendingExpiresAt = "expiresAt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PendingConfirmations struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo repository.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPendingConfirmations(logger mlogger.Logger, db *mongo.Database) (*PendingConfirmations, error) {
|
|
||||||
if db == nil {
|
|
||||||
return nil, merrors.InvalidArgument("mongo database is nil")
|
|
||||||
}
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("pending_confirmations").With(zap.String("collection", pendingConfirmationsCollection))
|
|
||||||
|
|
||||||
repo := repository.CreateMongoRepository(db, pendingConfirmationsCollection)
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldPendingRequestID, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create pending confirmations request_id index", zap.Error(err), zap.String("index_field", fieldPendingRequestID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldPendingMessageID, Sort: ri.Asc}},
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create pending confirmations message_id index", zap.Error(err), zap.String("index_field", fieldPendingMessageID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldPendingExpiresAt, Sort: ri.Asc}},
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create pending confirmations expires_at index", zap.Error(err), zap.String("index_field", fieldPendingExpiresAt))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &PendingConfirmations{
|
|
||||||
logger: logger,
|
|
||||||
repo: repo,
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PendingConfirmations) Upsert(ctx context.Context, record *model.PendingConfirmation) error {
|
|
||||||
if record == nil {
|
|
||||||
return merrors.InvalidArgument("pending confirmation is nil", "record")
|
|
||||||
}
|
|
||||||
record.RequestID = strings.TrimSpace(record.RequestID)
|
|
||||||
record.MessageID = strings.TrimSpace(record.MessageID)
|
|
||||||
record.TargetChatID = strings.TrimSpace(record.TargetChatID)
|
|
||||||
record.SourceService = strings.TrimSpace(record.SourceService)
|
|
||||||
record.Rail = strings.TrimSpace(record.Rail)
|
|
||||||
if record.RequestID == "" {
|
|
||||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
if record.TargetChatID == "" {
|
|
||||||
return merrors.InvalidArgument("target_chat_id is required", "target_chat_id")
|
|
||||||
}
|
|
||||||
if record.ExpiresAt.IsZero() {
|
|
||||||
return merrors.InvalidArgument("expires_at is required", "expires_at")
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := repository.Filter(fieldPendingRequestID, record.RequestID)
|
|
||||||
err := p.repo.Insert(ctx, record, filter)
|
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
|
||||||
patch := repository.Patch().
|
|
||||||
Set(repository.Field(fieldPendingMessageID), record.MessageID).
|
|
||||||
Set(repository.Field("targetChatId"), record.TargetChatID).
|
|
||||||
Set(repository.Field("acceptedUserIds"), record.AcceptedUserIDs).
|
|
||||||
Set(repository.Field("requestedMoney"), record.RequestedMoney).
|
|
||||||
Set(repository.Field("sourceService"), record.SourceService).
|
|
||||||
Set(repository.Field("rail"), record.Rail).
|
|
||||||
Set(repository.Field("clarified"), record.Clarified).
|
|
||||||
Set(repository.Field(fieldPendingExpiresAt), record.ExpiresAt)
|
|
||||||
_, err = p.repo.PatchMany(ctx, filter, patch)
|
|
||||||
}
|
|
||||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
p.logger.Warn("Failed to upsert pending confirmation", zap.Error(err), zap.String("request_id", record.RequestID))
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PendingConfirmations) FindByRequestID(ctx context.Context, requestID string) (*model.PendingConfirmation, error) {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
var result model.PendingConfirmation
|
|
||||||
err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldPendingRequestID, requestID), &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PendingConfirmations) FindByMessageID(ctx context.Context, messageID string) (*model.PendingConfirmation, error) {
|
|
||||||
messageID = strings.TrimSpace(messageID)
|
|
||||||
if messageID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("message_id is required", "message_id")
|
|
||||||
}
|
|
||||||
var result model.PendingConfirmation
|
|
||||||
err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldPendingMessageID, messageID), &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PendingConfirmations) MarkClarified(ctx context.Context, requestID string) error {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
patch := repository.Patch().
|
|
||||||
Set(repository.Field("clarified"), true)
|
|
||||||
_, err := p.repo.PatchMany(ctx, repository.Filter(fieldPendingRequestID, requestID), patch)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PendingConfirmations) AttachMessage(ctx context.Context, requestID string, messageID string) error {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
messageID = strings.TrimSpace(messageID)
|
|
||||||
if requestID == "" {
|
|
||||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
if messageID == "" {
|
|
||||||
return merrors.InvalidArgument("message_id is required", "message_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := repository.Filter(fieldPendingRequestID, requestID).And(
|
|
||||||
repository.Query().Or(
|
|
||||||
repository.Exists(repository.Field(fieldPendingMessageID), false),
|
|
||||||
repository.Filter(fieldPendingMessageID, ""),
|
|
||||||
repository.Filter(fieldPendingMessageID, messageID),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
patch := repository.Patch().
|
|
||||||
Set(repository.Field(fieldPendingMessageID), messageID)
|
|
||||||
updated, err := p.repo.PatchMany(ctx, filter, patch)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if updated == 0 {
|
|
||||||
return merrors.NoData("pending confirmation not found")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PendingConfirmations) DeleteByRequestID(ctx context.Context, requestID string) error {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
return p.repo.DeleteMany(ctx, repository.Filter(fieldPendingRequestID, requestID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PendingConfirmations) ListExpired(ctx context.Context, now time.Time, limit int64) ([]model.PendingConfirmation, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
query := repository.Query().
|
|
||||||
Comparison(repository.Field(fieldPendingExpiresAt), builder.Lte, now).
|
|
||||||
Sort(repository.Field(fieldPendingExpiresAt), true).
|
|
||||||
Limit(&limit)
|
|
||||||
|
|
||||||
items, err := mutil.GetObjects[model.PendingConfirmation](ctx, p.logger, query, nil, p.repo)
|
|
||||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.PendingConfirmationsStore = (*PendingConfirmations)(nil)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
telegramCollection = "telegram_confirmations"
|
|
||||||
fieldRequestID = "requestId"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TelegramConfirmations struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo repository.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTelegramConfirmations(logger mlogger.Logger, db *mongo.Database) (*TelegramConfirmations, error) {
|
|
||||||
if db == nil {
|
|
||||||
return nil, merrors.InvalidArgument("mongo database is nil")
|
|
||||||
}
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("telegram_confirmations").With(zap.String("collection", telegramCollection))
|
|
||||||
|
|
||||||
repo := repository.CreateMongoRepository(db, telegramCollection)
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldRequestID, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create telegram confirmations request_id index", zap.Error(err), zap.String("index_field", fieldRequestID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t := &TelegramConfirmations{
|
|
||||||
logger: logger,
|
|
||||||
repo: repo,
|
|
||||||
}
|
|
||||||
t.logger.Debug("Telegram confirmations store initialised")
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TelegramConfirmations) Upsert(ctx context.Context, record *model.TelegramConfirmation) error {
|
|
||||||
if record == nil {
|
|
||||||
return merrors.InvalidArgument("telegram confirmation is nil", "record")
|
|
||||||
}
|
|
||||||
record.RequestID = strings.TrimSpace(record.RequestID)
|
|
||||||
record.PaymentIntentID = strings.TrimSpace(record.PaymentIntentID)
|
|
||||||
record.QuoteRef = strings.TrimSpace(record.QuoteRef)
|
|
||||||
if record.RequestID == "" {
|
|
||||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
if record.ReceivedAt.IsZero() {
|
|
||||||
record.ReceivedAt = time.Now()
|
|
||||||
}
|
|
||||||
filter := repository.Filter(fieldRequestID, record.RequestID)
|
|
||||||
err := t.repo.Insert(ctx, record, filter)
|
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
|
||||||
patch := repository.Patch().
|
|
||||||
Set(repository.Field("paymentIntentId"), record.PaymentIntentID).
|
|
||||||
Set(repository.Field("quoteRef"), record.QuoteRef).
|
|
||||||
Set(repository.Field("rawReply"), record.RawReply).
|
|
||||||
Set(repository.Field("receivedAt"), record.ReceivedAt)
|
|
||||||
_, err = t.repo.PatchMany(ctx, filter, patch)
|
|
||||||
}
|
|
||||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
fields := []zap.Field{zap.String("request_id", record.RequestID)}
|
|
||||||
if record.PaymentIntentID != "" {
|
|
||||||
fields = append(fields, zap.String("payment_intent_id", record.PaymentIntentID))
|
|
||||||
}
|
|
||||||
if record.QuoteRef != "" {
|
|
||||||
fields = append(fields, zap.String("quote_ref", record.QuoteRef))
|
|
||||||
}
|
|
||||||
t.logger.Warn("Failed to upsert telegram confirmation", append(fields, zap.Error(err))...)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.TelegramConfirmationsStore = (*TelegramConfirmations)(nil)
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
mutil "github.com/tech/sendico/pkg/mutil/db"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
treasuryRequestsCollection = "treasury_requests"
|
|
||||||
|
|
||||||
fieldTreasuryRequestID = "requestId"
|
|
||||||
fieldTreasuryLedgerAccount = "ledgerAccountId"
|
|
||||||
fieldTreasuryIdempotencyKey = "idempotencyKey"
|
|
||||||
fieldTreasuryStatus = "status"
|
|
||||||
fieldTreasuryScheduledAt = "scheduledAt"
|
|
||||||
fieldTreasuryCreatedAt = "createdAt"
|
|
||||||
fieldTreasuryActive = "active"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TreasuryRequests struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo repository.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTreasuryRequests(logger mlogger.Logger, db *mongo.Database) (*TreasuryRequests, error) {
|
|
||||||
if db == nil {
|
|
||||||
return nil, merrors.InvalidArgument("mongo database is nil")
|
|
||||||
}
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("treasury_requests").With(zap.String("collection", treasuryRequestsCollection))
|
|
||||||
|
|
||||||
repo := repository.CreateMongoRepository(db, treasuryRequestsCollection)
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldTreasuryRequestID, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create treasury requests request_id index", zap.Error(err), zap.String("index_field", fieldTreasuryRequestID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldTreasuryIdempotencyKey, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create treasury requests idempotency index", zap.Error(err), zap.String("index_field", fieldTreasuryIdempotencyKey))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{
|
|
||||||
{Field: fieldTreasuryLedgerAccount, Sort: ri.Asc},
|
|
||||||
{Field: fieldTreasuryActive, Sort: ri.Asc},
|
|
||||||
},
|
|
||||||
Unique: true,
|
|
||||||
PartialFilter: repository.Filter(fieldTreasuryActive, true),
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create treasury requests active-account index", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{
|
|
||||||
{Field: fieldTreasuryStatus, Sort: ri.Asc},
|
|
||||||
{Field: fieldTreasuryScheduledAt, Sort: ri.Asc},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create treasury requests execution index", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{
|
|
||||||
{Field: fieldTreasuryLedgerAccount, Sort: ri.Asc},
|
|
||||||
{Field: fieldTreasuryCreatedAt, Sort: ri.Asc},
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create treasury requests daily-amount index", zap.Error(err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t := &TreasuryRequests{
|
|
||||||
logger: logger,
|
|
||||||
repo: repo,
|
|
||||||
}
|
|
||||||
t.logger.Debug("Treasury requests store initialised")
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryRequests) Create(ctx context.Context, record *model.TreasuryRequest) error {
|
|
||||||
if record == nil {
|
|
||||||
return merrors.InvalidArgument("treasury request is nil", "record")
|
|
||||||
}
|
|
||||||
record.RequestID = strings.TrimSpace(record.RequestID)
|
|
||||||
record.TelegramUserID = strings.TrimSpace(record.TelegramUserID)
|
|
||||||
record.LedgerAccountID = strings.TrimSpace(record.LedgerAccountID)
|
|
||||||
record.LedgerAccountCode = strings.TrimSpace(record.LedgerAccountCode)
|
|
||||||
record.OrganizationRef = strings.TrimSpace(record.OrganizationRef)
|
|
||||||
record.ChatID = strings.TrimSpace(record.ChatID)
|
|
||||||
record.Amount = strings.TrimSpace(record.Amount)
|
|
||||||
record.Currency = strings.ToUpper(strings.TrimSpace(record.Currency))
|
|
||||||
record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey)
|
|
||||||
record.LedgerReference = strings.TrimSpace(record.LedgerReference)
|
|
||||||
record.ErrorMessage = strings.TrimSpace(record.ErrorMessage)
|
|
||||||
|
|
||||||
if record.RequestID == "" {
|
|
||||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
if record.TelegramUserID == "" {
|
|
||||||
return merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
|
||||||
}
|
|
||||||
if record.LedgerAccountID == "" {
|
|
||||||
return merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
|
||||||
}
|
|
||||||
if record.Amount == "" {
|
|
||||||
return merrors.InvalidArgument("amount is required", "amount")
|
|
||||||
}
|
|
||||||
if record.Currency == "" {
|
|
||||||
return merrors.InvalidArgument("currency is required", "currency")
|
|
||||||
}
|
|
||||||
if record.IdempotencyKey == "" {
|
|
||||||
return merrors.InvalidArgument("idempotency_key is required", "idempotency_key")
|
|
||||||
}
|
|
||||||
if record.Status == "" {
|
|
||||||
return merrors.InvalidArgument("status is required", "status")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := t.repo.Insert(ctx, record, repository.Filter(fieldTreasuryRequestID, record.RequestID))
|
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
|
||||||
return storage.ErrDuplicate
|
|
||||||
}
|
|
||||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.logger.Warn("Failed to create treasury request", zap.Error(err), zap.String("request_id", record.RequestID))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t.logger.Info("Treasury request created",
|
|
||||||
zap.String("request_id", record.RequestID),
|
|
||||||
zap.String("telegram_user_id", record.TelegramUserID),
|
|
||||||
zap.String("chat_id", record.ChatID),
|
|
||||||
zap.String("ledger_account_id", record.LedgerAccountID),
|
|
||||||
zap.String("ledger_account_code", record.LedgerAccountCode),
|
|
||||||
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
|
|
||||||
zap.String("status", strings.TrimSpace(string(record.Status))),
|
|
||||||
zap.String("amount", record.Amount),
|
|
||||||
zap.String("currency", record.Currency))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryRequests) FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error) {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
var result model.TreasuryRequest
|
|
||||||
err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryRequestID, requestID), &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
t.logger.Debug("Treasury request not found", zap.String("request_id", requestID))
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.logger.Warn("Failed to load treasury request", zap.Error(err), zap.String("request_id", requestID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t.logger.Debug("Treasury request loaded",
|
|
||||||
zap.String("request_id", requestID),
|
|
||||||
zap.String("status", strings.TrimSpace(string(result.Status))),
|
|
||||||
zap.String("ledger_account_id", strings.TrimSpace(result.LedgerAccountID)))
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryRequests) FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error) {
|
|
||||||
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
|
|
||||||
if ledgerAccountID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
|
||||||
}
|
|
||||||
var result model.TreasuryRequest
|
|
||||||
query := repository.Query().
|
|
||||||
Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID).
|
|
||||||
Filter(repository.Field(fieldTreasuryActive), true)
|
|
||||||
err := t.repo.FindOneByFilter(ctx, query, &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
t.logger.Debug("Active treasury request not found", zap.String("ledger_account_id", ledgerAccountID))
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.logger.Warn("Failed to load active treasury request", zap.Error(err), zap.String("ledger_account_id", ledgerAccountID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t.logger.Debug("Active treasury request loaded",
|
|
||||||
zap.String("request_id", strings.TrimSpace(result.RequestID)),
|
|
||||||
zap.String("ledger_account_id", ledgerAccountID),
|
|
||||||
zap.String("status", strings.TrimSpace(string(result.Status))))
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryRequests) FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]model.TreasuryRequest, error) {
|
|
||||||
if len(statuses) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
statusValues := make([]any, 0, len(statuses))
|
|
||||||
for _, status := range statuses {
|
|
||||||
next := strings.TrimSpace(string(status))
|
|
||||||
if next == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
statusValues = append(statusValues, next)
|
|
||||||
}
|
|
||||||
if len(statusValues) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
query := repository.Query().
|
|
||||||
In(repository.Field(fieldTreasuryStatus), statusValues...).
|
|
||||||
Comparison(repository.Field(fieldTreasuryScheduledAt), builder.Lte, now).
|
|
||||||
Sort(repository.Field(fieldTreasuryScheduledAt), true).
|
|
||||||
Limit(&limit)
|
|
||||||
|
|
||||||
result, err := mutil.GetObjects[model.TreasuryRequest](ctx, t.logger, query, nil, t.repo)
|
|
||||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
|
||||||
t.logger.Warn("Failed to list due treasury requests",
|
|
||||||
zap.Error(err),
|
|
||||||
zap.Any("statuses", statusValues),
|
|
||||||
zap.Time("scheduled_before", now),
|
|
||||||
zap.Int64("limit", limit))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t.logger.Debug("Due treasury requests loaded",
|
|
||||||
zap.Any("statuses", statusValues),
|
|
||||||
zap.Time("scheduled_before", now),
|
|
||||||
zap.Int64("limit", limit),
|
|
||||||
zap.Int("count", len(result)))
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryRequests) ClaimScheduled(ctx context.Context, requestID string) (bool, error) {
|
|
||||||
requestID = strings.TrimSpace(requestID)
|
|
||||||
if requestID == "" {
|
|
||||||
return false, merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
patch := repository.Patch().
|
|
||||||
Set(repository.Field(fieldTreasuryStatus), string(model.TreasuryRequestStatusConfirmed))
|
|
||||||
updated, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, requestID).And(
|
|
||||||
repository.Filter(fieldTreasuryStatus, string(model.TreasuryRequestStatusScheduled)),
|
|
||||||
), patch)
|
|
||||||
if err != nil {
|
|
||||||
t.logger.Warn("Failed to claim scheduled treasury request", zap.Error(err), zap.String("request_id", requestID))
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if updated > 0 {
|
|
||||||
t.logger.Info("Scheduled treasury request claimed", zap.String("request_id", requestID))
|
|
||||||
} else {
|
|
||||||
t.logger.Debug("Scheduled treasury request claim skipped", zap.String("request_id", requestID))
|
|
||||||
}
|
|
||||||
return updated > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryRequests) Update(ctx context.Context, record *model.TreasuryRequest) error {
|
|
||||||
if record == nil {
|
|
||||||
return merrors.InvalidArgument("treasury request is nil", "record")
|
|
||||||
}
|
|
||||||
record.RequestID = strings.TrimSpace(record.RequestID)
|
|
||||||
record.TelegramUserID = strings.TrimSpace(record.TelegramUserID)
|
|
||||||
record.LedgerAccountID = strings.TrimSpace(record.LedgerAccountID)
|
|
||||||
record.LedgerAccountCode = strings.TrimSpace(record.LedgerAccountCode)
|
|
||||||
record.OrganizationRef = strings.TrimSpace(record.OrganizationRef)
|
|
||||||
record.ChatID = strings.TrimSpace(record.ChatID)
|
|
||||||
record.Amount = strings.TrimSpace(record.Amount)
|
|
||||||
record.Currency = strings.ToUpper(strings.TrimSpace(record.Currency))
|
|
||||||
record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey)
|
|
||||||
record.LedgerReference = strings.TrimSpace(record.LedgerReference)
|
|
||||||
record.ErrorMessage = strings.TrimSpace(record.ErrorMessage)
|
|
||||||
if record.RequestID == "" {
|
|
||||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
|
||||||
}
|
|
||||||
existing, err := t.FindByRequestID(ctx, record.RequestID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if existing == nil {
|
|
||||||
return merrors.NoData("treasury request not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := repository.Patch().
|
|
||||||
Set(repository.Field("operationType"), record.OperationType).
|
|
||||||
Set(repository.Field("telegramUserId"), record.TelegramUserID).
|
|
||||||
Set(repository.Field("ledgerAccountId"), record.LedgerAccountID).
|
|
||||||
Set(repository.Field("organizationRef"), record.OrganizationRef).
|
|
||||||
Set(repository.Field("chatId"), record.ChatID).
|
|
||||||
Set(repository.Field("amount"), record.Amount).
|
|
||||||
Set(repository.Field("currency"), record.Currency).
|
|
||||||
Set(repository.Field(fieldTreasuryStatus), record.Status).
|
|
||||||
Set(repository.Field(fieldTreasuryIdempotencyKey), record.IdempotencyKey).
|
|
||||||
Set(repository.Field(fieldTreasuryActive), record.Active)
|
|
||||||
if record.LedgerAccountCode != "" {
|
|
||||||
patch = patch.Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode)
|
|
||||||
} else {
|
|
||||||
patch = patch.Unset(repository.Field("ledgerAccountCode"))
|
|
||||||
}
|
|
||||||
if !record.ConfirmedAt.IsZero() {
|
|
||||||
patch = patch.Set(repository.Field("confirmedAt"), record.ConfirmedAt)
|
|
||||||
} else {
|
|
||||||
patch = patch.Unset(repository.Field("confirmedAt"))
|
|
||||||
}
|
|
||||||
if !record.ScheduledAt.IsZero() {
|
|
||||||
patch = patch.Set(repository.Field("scheduledAt"), record.ScheduledAt)
|
|
||||||
} else {
|
|
||||||
patch = patch.Unset(repository.Field("scheduledAt"))
|
|
||||||
}
|
|
||||||
if !record.ExecutedAt.IsZero() {
|
|
||||||
patch = patch.Set(repository.Field("executedAt"), record.ExecutedAt)
|
|
||||||
} else {
|
|
||||||
patch = patch.Unset(repository.Field("executedAt"))
|
|
||||||
}
|
|
||||||
if !record.CancelledAt.IsZero() {
|
|
||||||
patch = patch.Set(repository.Field("cancelledAt"), record.CancelledAt)
|
|
||||||
} else {
|
|
||||||
patch = patch.Unset(repository.Field("cancelledAt"))
|
|
||||||
}
|
|
||||||
if record.LedgerReference != "" {
|
|
||||||
patch = patch.Set(repository.Field("ledgerReference"), record.LedgerReference)
|
|
||||||
} else {
|
|
||||||
patch = patch.Unset(repository.Field("ledgerReference"))
|
|
||||||
}
|
|
||||||
if record.ErrorMessage != "" {
|
|
||||||
patch = patch.Set(repository.Field("errorMessage"), record.ErrorMessage)
|
|
||||||
} else {
|
|
||||||
patch = patch.Unset(repository.Field("errorMessage"))
|
|
||||||
}
|
|
||||||
if _, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, record.RequestID), patch); err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.logger.Warn("Failed to update treasury request", zap.Error(err), zap.String("request_id", record.RequestID))
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t.logger.Info("Treasury request updated",
|
|
||||||
zap.String("request_id", record.RequestID),
|
|
||||||
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
|
|
||||||
zap.String("chat_id", strings.TrimSpace(record.ChatID)),
|
|
||||||
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
|
|
||||||
zap.String("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)),
|
|
||||||
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
|
|
||||||
zap.String("status", strings.TrimSpace(string(record.Status))),
|
|
||||||
zap.String("amount", strings.TrimSpace(record.Amount)),
|
|
||||||
zap.String("currency", strings.TrimSpace(record.Currency)),
|
|
||||||
zap.String("error_message", strings.TrimSpace(record.ErrorMessage)))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryRequests) ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]model.TreasuryRequest, error) {
|
|
||||||
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
|
|
||||||
if ledgerAccountID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
|
||||||
}
|
|
||||||
statusValues := make([]any, 0, len(statuses))
|
|
||||||
for _, status := range statuses {
|
|
||||||
next := strings.TrimSpace(string(status))
|
|
||||||
if next == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
statusValues = append(statusValues, next)
|
|
||||||
}
|
|
||||||
if len(statusValues) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
query := repository.Query().
|
|
||||||
Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID).
|
|
||||||
In(repository.Field(fieldTreasuryStatus), statusValues...).
|
|
||||||
Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Gte, dayStart).
|
|
||||||
Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Lt, dayEnd)
|
|
||||||
|
|
||||||
result, err := mutil.GetObjects[model.TreasuryRequest](ctx, t.logger, query, nil, t.repo)
|
|
||||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
|
||||||
t.logger.Warn("Failed to list treasury requests by account and statuses",
|
|
||||||
zap.Error(err),
|
|
||||||
zap.String("ledger_account_id", ledgerAccountID),
|
|
||||||
zap.Any("statuses", statusValues),
|
|
||||||
zap.Time("day_start", dayStart),
|
|
||||||
zap.Time("day_end", dayEnd))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t.logger.Debug("Treasury requests loaded by account and statuses",
|
|
||||||
zap.String("ledger_account_id", ledgerAccountID),
|
|
||||||
zap.Any("statuses", statusValues),
|
|
||||||
zap.Time("day_start", dayStart),
|
|
||||||
zap.Time("day_end", dayEnd),
|
|
||||||
zap.Int("count", len(result)))
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.TreasuryRequestsStore = (*TreasuryRequests)(nil)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
treasuryTelegramUsersCollection = "treasury_telegram_users"
|
|
||||||
fieldTreasuryTelegramUserID = "telegramUserId"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TreasuryTelegramUsers struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo repository.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTreasuryTelegramUsers(logger mlogger.Logger, db *mongo.Database) (*TreasuryTelegramUsers, error) {
|
|
||||||
if db == nil {
|
|
||||||
return nil, merrors.InvalidArgument("mongo database is nil")
|
|
||||||
}
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("treasury_telegram_users").With(zap.String("collection", treasuryTelegramUsersCollection))
|
|
||||||
|
|
||||||
repo := repository.CreateMongoRepository(db, treasuryTelegramUsersCollection)
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldTreasuryTelegramUserID, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create treasury telegram users user_id index", zap.Error(err), zap.String("index_field", fieldTreasuryTelegramUserID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TreasuryTelegramUsers{
|
|
||||||
logger: logger,
|
|
||||||
repo: repo,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryTelegramUsers) FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) {
|
|
||||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
|
||||||
if telegramUserID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
|
||||||
}
|
|
||||||
var result model.TreasuryTelegramUser
|
|
||||||
err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryTelegramUserID, telegramUserID), &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.logger.Warn("Failed to load treasury telegram user", zap.Error(err), zap.String("telegram_user_id", telegramUserID))
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result.TelegramUserID = strings.TrimSpace(result.TelegramUserID)
|
|
||||||
result.LedgerAccountID = strings.TrimSpace(result.LedgerAccountID)
|
|
||||||
if len(result.AllowedChatIDs) > 0 {
|
|
||||||
normalized := make([]string, 0, len(result.AllowedChatIDs))
|
|
||||||
for _, next := range result.AllowedChatIDs {
|
|
||||||
next = strings.TrimSpace(next)
|
|
||||||
if next == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
normalized = append(normalized, next)
|
|
||||||
}
|
|
||||||
result.AllowedChatIDs = normalized
|
|
||||||
}
|
|
||||||
if result.TelegramUserID == "" || result.LedgerAccountID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.TreasuryTelegramUsersStore = (*TreasuryTelegramUsers)(nil)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package mongo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/transaction"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mongoTransactionFactory struct {
|
|
||||||
client *mongo.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction {
|
|
||||||
return &mongoTransaction{client: f.client}
|
|
||||||
}
|
|
||||||
|
|
||||||
type mongoTransaction struct {
|
|
||||||
client *mongo.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
|
||||||
session, err := t.client.StartSession()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer session.EndSession(ctx)
|
|
||||||
|
|
||||||
run := func(sessCtx context.Context) (any, error) {
|
|
||||||
return cb(sessCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
return session.WithTransaction(ctx, run)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMongoTransactionFactory(client *mongo.Client) transaction.Factory {
|
|
||||||
return &mongoTransactionFactory{client: client}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record")
|
|
||||||
|
|
||||||
type Repository interface {
|
|
||||||
Payments() PaymentsStore
|
|
||||||
TelegramConfirmations() TelegramConfirmationsStore
|
|
||||||
PendingConfirmations() PendingConfirmationsStore
|
|
||||||
TreasuryRequests() TreasuryRequestsStore
|
|
||||||
TreasuryTelegramUsers() TreasuryTelegramUsersStore
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentsStore interface {
|
|
||||||
FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error)
|
|
||||||
FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error)
|
|
||||||
Upsert(ctx context.Context, record *model.PaymentRecord) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type TelegramConfirmationsStore interface {
|
|
||||||
Upsert(ctx context.Context, record *model.TelegramConfirmation) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type PendingConfirmationsStore interface {
|
|
||||||
Upsert(ctx context.Context, record *model.PendingConfirmation) error
|
|
||||||
FindByRequestID(ctx context.Context, requestID string) (*model.PendingConfirmation, error)
|
|
||||||
FindByMessageID(ctx context.Context, messageID string) (*model.PendingConfirmation, error)
|
|
||||||
MarkClarified(ctx context.Context, requestID string) error
|
|
||||||
AttachMessage(ctx context.Context, requestID string, messageID string) error
|
|
||||||
DeleteByRequestID(ctx context.Context, requestID string) error
|
|
||||||
ListExpired(ctx context.Context, now time.Time, limit int64) ([]model.PendingConfirmation, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TreasuryRequestsStore interface {
|
|
||||||
Create(ctx context.Context, record *model.TreasuryRequest) error
|
|
||||||
FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error)
|
|
||||||
FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error)
|
|
||||||
FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]model.TreasuryRequest, error)
|
|
||||||
ClaimScheduled(ctx context.Context, requestID string) (bool, error)
|
|
||||||
Update(ctx context.Context, record *model.TreasuryRequest) error
|
|
||||||
ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]model.TreasuryRequest, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TreasuryTelegramUsersStore interface {
|
|
||||||
FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error)
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type stubConnectorClient struct {
|
|
||||||
submitReq *connectorv1.SubmitOperationRequest
|
|
||||||
getReq *connectorv1.GetOperationRequest
|
|
||||||
|
|
||||||
submitResp *connectorv1.SubmitOperationResponse
|
|
||||||
getResp *connectorv1.GetOperationResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubConnectorClient) SubmitOperation(_ context.Context, in *connectorv1.SubmitOperationRequest, _ ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) {
|
|
||||||
s.submitReq = in
|
|
||||||
if s.submitResp != nil {
|
|
||||||
return s.submitResp, nil
|
|
||||||
}
|
|
||||||
return &connectorv1.SubmitOperationResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubConnectorClient) GetOperation(_ context.Context, in *connectorv1.GetOperationRequest, _ ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) {
|
|
||||||
s.getReq = in
|
|
||||||
if s.getResp != nil {
|
|
||||||
return s.getResp, nil
|
|
||||||
}
|
|
||||||
return &connectorv1.GetOperationResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateCardPayout_UsesOperationRefIdentity(t *testing.T) {
|
|
||||||
stub := &stubConnectorClient{
|
|
||||||
submitResp: &connectorv1.SubmitOperationResponse{
|
|
||||||
Receipt: &connectorv1.OperationReceipt{
|
|
||||||
OperationId: "payment-1:hop_4_card_payout_send",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_WAITING,
|
|
||||||
ProviderRef: "provider-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := &gatewayClient{client: stub, cfg: Config{}}
|
|
||||||
|
|
||||||
resp, err := client.CreateCardPayout(context.Background(), &mntxv1.CardPayoutRequest{
|
|
||||||
PayoutId: "payout-legacy-id",
|
|
||||||
OperationRef: "payment-1:hop_4_card_payout_send",
|
|
||||||
IdempotencyKey: "",
|
|
||||||
AmountMinor: 12345,
|
|
||||||
Currency: "RUB",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCardPayout returned error: %v", err)
|
|
||||||
}
|
|
||||||
if resp.GetPayout() == nil {
|
|
||||||
t.Fatal("expected payout")
|
|
||||||
}
|
|
||||||
if got, want := resp.GetPayout().GetPayoutId(), "payment-1:hop_4_card_payout_send"; got != want {
|
|
||||||
t.Fatalf("payout_id mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if stub.submitReq == nil || stub.submitReq.GetOperation() == nil {
|
|
||||||
t.Fatal("expected submitted operation")
|
|
||||||
}
|
|
||||||
if got, want := stub.submitReq.GetOperation().GetOperationRef(), "payment-1:hop_4_card_payout_send"; got != want {
|
|
||||||
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := stub.submitReq.GetOperation().GetIdempotencyKey(), "payment-1:hop_4_card_payout_send"; got != want {
|
|
||||||
t.Fatalf("idempotency_key mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCardPayoutStatus_UsesPayoutIDAsOperationID(t *testing.T) {
|
|
||||||
stub := &stubConnectorClient{
|
|
||||||
getResp: &connectorv1.GetOperationResponse{
|
|
||||||
Operation: &connectorv1.Operation{
|
|
||||||
OperationId: "payment-2:hop_4_card_payout_send",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_PROCESSING,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := &gatewayClient{client: stub, cfg: Config{}}
|
|
||||||
|
|
||||||
resp, err := client.GetCardPayoutStatus(context.Background(), &mntxv1.GetCardPayoutStatusRequest{
|
|
||||||
PayoutId: "payment-2:hop_4_card_payout_send",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetCardPayoutStatus returned error: %v", err)
|
|
||||||
}
|
|
||||||
if stub.getReq == nil {
|
|
||||||
t.Fatal("expected get operation request")
|
|
||||||
}
|
|
||||||
if got, want := stub.getReq.GetOperationId(), "payment-2:hop_4_card_payout_send"; got != want {
|
|
||||||
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if resp.GetPayout() == nil {
|
|
||||||
t.Fatal("expected payout")
|
|
||||||
}
|
|
||||||
if got, want := resp.GetPayout().GetPayoutId(), "payment-2:hop_4_card_payout_send"; got != want {
|
|
||||||
t.Fatalf("payout_id mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateCardTokenPayout_UsesOperationRefWhenReceiptOperationIDMissing(t *testing.T) {
|
|
||||||
stub := &stubConnectorClient{
|
|
||||||
submitResp: &connectorv1.SubmitOperationResponse{
|
|
||||||
Receipt: &connectorv1.OperationReceipt{
|
|
||||||
OperationId: "",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_PROCESSING,
|
|
||||||
ProviderRef: "provider-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := &gatewayClient{client: stub, cfg: Config{}}
|
|
||||||
|
|
||||||
resp, err := client.CreateCardTokenPayout(context.Background(), &mntxv1.CardTokenPayoutRequest{
|
|
||||||
PayoutId: "legacy-payout-id",
|
|
||||||
OperationRef: "payment-3:hop_4_card_payout_send_2",
|
|
||||||
IdempotencyKey: "",
|
|
||||||
AmountMinor: 777,
|
|
||||||
Currency: "RUB",
|
|
||||||
CardToken: "tok_123",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCardTokenPayout returned error: %v", err)
|
|
||||||
}
|
|
||||||
if stub.submitReq == nil || stub.submitReq.GetOperation() == nil {
|
|
||||||
t.Fatal("expected submitted operation")
|
|
||||||
}
|
|
||||||
if got, want := stub.submitReq.GetOperation().GetOperationRef(), "payment-3:hop_4_card_payout_send_2"; got != want {
|
|
||||||
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := stub.submitReq.GetOperation().GetIdempotencyKey(), "payment-3:hop_4_card_payout_send_2"; got != want {
|
|
||||||
t.Fatalf("idempotency_key mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if resp.GetPayout() == nil {
|
|
||||||
t.Fatal("expected payout")
|
|
||||||
}
|
|
||||||
if got, want := resp.GetPayout().GetPayoutId(), "payment-3:hop_4_card_payout_send_2"; got != want {
|
|
||||||
t.Fatalf("payout_id mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
|
||||||
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
treasuryTelegramUsersCollection = "treasury_telegram_users"
|
|
||||||
fieldTreasuryTelegramUserID = "telegramUserId"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TreasuryTelegramUsers struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
repo repository.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTreasuryTelegramUsers(logger mlogger.Logger, db *mongo.Database) (*TreasuryTelegramUsers, error) {
|
|
||||||
if db == nil {
|
|
||||||
return nil, merrors.InvalidArgument("mongo database is nil")
|
|
||||||
}
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("treasury_telegram_users").With(zap.String("collection", treasuryTelegramUsersCollection))
|
|
||||||
|
|
||||||
repo := repository.CreateMongoRepository(db, treasuryTelegramUsersCollection)
|
|
||||||
if err := repo.CreateIndex(&ri.Definition{
|
|
||||||
Keys: []ri.Key{{Field: fieldTreasuryTelegramUserID, Sort: ri.Asc}},
|
|
||||||
Unique: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Error("Failed to create treasury telegram users user_id index", zap.Error(err), zap.String("index_field", fieldTreasuryTelegramUserID))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TreasuryTelegramUsers{
|
|
||||||
logger: logger,
|
|
||||||
repo: repo,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TreasuryTelegramUsers) FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) {
|
|
||||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
|
||||||
if telegramUserID == "" {
|
|
||||||
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
|
||||||
}
|
|
||||||
var result model.TreasuryTelegramUser
|
|
||||||
err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryTelegramUserID, telegramUserID), &result)
|
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.logger.Warn("Failed to load treasury telegram user", zap.Error(err), zap.String("telegram_user_id", telegramUserID))
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result.TelegramUserID = strings.TrimSpace(result.TelegramUserID)
|
|
||||||
result.LedgerAccountID = strings.TrimSpace(result.LedgerAccountID)
|
|
||||||
if len(result.AllowedChatIDs) > 0 {
|
|
||||||
normalized := make([]string, 0, len(result.AllowedChatIDs))
|
|
||||||
for _, next := range result.AllowedChatIDs {
|
|
||||||
next = strings.TrimSpace(next)
|
|
||||||
if next == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
normalized = append(normalized, next)
|
|
||||||
}
|
|
||||||
result.AllowedChatIDs = normalized
|
|
||||||
}
|
|
||||||
if result.TelegramUserID == "" || result.LedgerAccountID == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.TreasuryTelegramUsersStore = (*TreasuryTelegramUsers)(nil)
|
|
||||||
@@ -441,8 +441,6 @@ func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Ope
|
|||||||
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
|
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
|
||||||
Money: req.GetAmount(),
|
Money: req.GetAmount(),
|
||||||
Params: structFromMap(params),
|
Params: structFromMap(params),
|
||||||
IntentRef: strings.TrimSpace(req.GetIntentRef()),
|
|
||||||
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
|
||||||
}
|
}
|
||||||
to, err := destinationToParty(req.GetDestination())
|
to, err := destinationToParty(req.GetDestination())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -492,9 +490,6 @@ func transferFromReceipt(req *chainv1.SubmitTransferRequest, receipt *connectorv
|
|||||||
transfer.Destination = req.GetDestination()
|
transfer.Destination = req.GetDestination()
|
||||||
transfer.RequestedAmount = req.GetAmount()
|
transfer.RequestedAmount = req.GetAmount()
|
||||||
transfer.NetAmount = req.GetAmount()
|
transfer.NetAmount = req.GetAmount()
|
||||||
transfer.IntentRef = strings.TrimSpace(req.GetIntentRef())
|
|
||||||
transfer.OperationRef = strings.TrimSpace(req.GetOperationRef())
|
|
||||||
transfer.PaymentRef = strings.TrimSpace(req.GetPaymentRef())
|
|
||||||
}
|
}
|
||||||
if receipt != nil {
|
if receipt != nil {
|
||||||
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())
|
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -14,11 +13,6 @@ import (
|
|||||||
|
|
||||||
type stubConnectorClient struct {
|
type stubConnectorClient struct {
|
||||||
listReq *connectorv1.ListAccountsRequest
|
listReq *connectorv1.ListAccountsRequest
|
||||||
getReq *connectorv1.GetOperationRequest
|
|
||||||
|
|
||||||
submitReq *connectorv1.SubmitOperationRequest
|
|
||||||
submitResp *connectorv1.SubmitOperationResponse
|
|
||||||
getResp *connectorv1.GetOperationResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubConnectorClient) GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error) {
|
func (s *stubConnectorClient) GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||||
@@ -43,18 +37,10 @@ func (s *stubConnectorClient) GetBalance(ctx context.Context, in *connectorv1.Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubConnectorClient) SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) {
|
func (s *stubConnectorClient) SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) {
|
||||||
s.submitReq = in
|
|
||||||
if s.submitResp != nil {
|
|
||||||
return s.submitResp, nil
|
|
||||||
}
|
|
||||||
return &connectorv1.SubmitOperationResponse{}, nil
|
return &connectorv1.SubmitOperationResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubConnectorClient) GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) {
|
func (s *stubConnectorClient) GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) {
|
||||||
s.getReq = in
|
|
||||||
if s.getResp != nil {
|
|
||||||
return s.getResp, nil
|
|
||||||
}
|
|
||||||
return &connectorv1.GetOperationResponse{}, nil
|
return &connectorv1.GetOperationResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,84 +66,3 @@ func TestListManagedWallets_ForwardsOrganizationRef(t *testing.T) {
|
|||||||
require.Equal(t, "owner-1", stub.listReq.GetOwnerRefFilter().GetValue())
|
require.Equal(t, "owner-1", stub.listReq.GetOwnerRefFilter().GetValue())
|
||||||
require.Equal(t, connectorv1.AccountKind_CHAIN_MANAGED_WALLET, stub.listReq.GetKind())
|
require.Equal(t, connectorv1.AccountKind_CHAIN_MANAGED_WALLET, stub.listReq.GetKind())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTransferFromReceipt_PreservesRequestReferences(t *testing.T) {
|
|
||||||
req := &chainv1.SubmitTransferRequest{
|
|
||||||
IdempotencyKey: "idem-1",
|
|
||||||
OrganizationRef: "org-1",
|
|
||||||
SourceWalletRef: "wallet-src",
|
|
||||||
Amount: &moneyv1.Money{Amount: "12.34", Currency: "USDT"},
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
OperationRef: "payment-1:hop_1_crypto_send",
|
|
||||||
PaymentRef: "payment-1",
|
|
||||||
}
|
|
||||||
receipt := &connectorv1.OperationReceipt{
|
|
||||||
OperationId: "payment-1:hop_1_crypto_send",
|
|
||||||
ProviderRef: "provider-1",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_WAITING,
|
|
||||||
}
|
|
||||||
|
|
||||||
transfer := transferFromReceipt(req, receipt)
|
|
||||||
require.NotNil(t, transfer)
|
|
||||||
require.Equal(t, "payment-1:hop_1_crypto_send", transfer.GetTransferRef())
|
|
||||||
require.Equal(t, "intent-1", transfer.GetIntentRef())
|
|
||||||
require.Equal(t, "payment-1:hop_1_crypto_send", transfer.GetOperationRef())
|
|
||||||
require.Equal(t, "payment-1", transfer.GetPaymentRef())
|
|
||||||
require.Equal(t, chainv1.TransferStatus_TRANSFER_WAITING, transfer.GetStatus())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmitTransfer_ForwardsOperationAndIntentReferences(t *testing.T) {
|
|
||||||
stub := &stubConnectorClient{
|
|
||||||
submitResp: &connectorv1.SubmitOperationResponse{
|
|
||||||
Receipt: &connectorv1.OperationReceipt{
|
|
||||||
OperationId: "payment-1:hop_1_crypto_send",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_WAITING,
|
|
||||||
ProviderRef: "provider-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := NewWithClient(Config{}, stub)
|
|
||||||
|
|
||||||
resp, err := client.SubmitTransfer(context.Background(), &chainv1.SubmitTransferRequest{
|
|
||||||
IdempotencyKey: "idem-1",
|
|
||||||
OrganizationRef: "org-1",
|
|
||||||
SourceWalletRef: "wallet-src",
|
|
||||||
Destination: &chainv1.TransferDestination{
|
|
||||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{
|
|
||||||
ManagedWalletRef: "wallet-dst",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Amount: &moneyv1.Money{Amount: "12.34", Currency: "USDT"},
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
OperationRef: "payment-1:hop_1_crypto_send",
|
|
||||||
PaymentRef: "payment-1",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, stub.submitReq)
|
|
||||||
require.NotNil(t, stub.submitReq.GetOperation())
|
|
||||||
require.Equal(t, "intent-1", stub.submitReq.GetOperation().GetIntentRef())
|
|
||||||
require.Equal(t, "payment-1:hop_1_crypto_send", stub.submitReq.GetOperation().GetOperationRef())
|
|
||||||
require.NotNil(t, resp.GetTransfer())
|
|
||||||
require.Equal(t, "payment-1:hop_1_crypto_send", resp.GetTransfer().GetTransferRef())
|
|
||||||
require.Equal(t, "payment-1:hop_1_crypto_send", resp.GetTransfer().GetOperationRef())
|
|
||||||
require.Equal(t, "intent-1", resp.GetTransfer().GetIntentRef())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetTransfer_UsesTransferRefAsOperationID(t *testing.T) {
|
|
||||||
stub := &stubConnectorClient{
|
|
||||||
getResp: &connectorv1.GetOperationResponse{
|
|
||||||
Operation: &connectorv1.Operation{
|
|
||||||
OperationId: "transfer-1",
|
|
||||||
Status: connectorv1.OperationStatus_OPERATION_PROCESSING,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client := NewWithClient(Config{}, stub)
|
|
||||||
|
|
||||||
resp, err := client.GetTransfer(context.Background(), &chainv1.GetTransferRequest{TransferRef: "transfer-1"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, stub.getReq)
|
|
||||||
require.Equal(t, "transfer-1", stub.getReq.GetOperationId())
|
|
||||||
require.NotNil(t, resp.GetTransfer())
|
|
||||||
require.Equal(t, "transfer-1", resp.GetTransfer().GetTransferRef())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -375,8 +375,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:
|
|||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -51,6 +51,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
google.golang.org/grpc v1.79.2 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -227,8 +227,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ require (
|
|||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
google.golang.org/grpc v1.79.2
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -344,8 +344,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:
|
|||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const (
|
|||||||
ChainTransfers Type = "chain_transfers" // Represents chain transfers
|
ChainTransfers Type = "chain_transfers" // Represents chain transfers
|
||||||
ChainDeposits Type = "chain_deposits" // Represents chain deposits
|
ChainDeposits Type = "chain_deposits" // Represents chain deposits
|
||||||
Callbacks Type = "callbacks" // Represents webhook callback subscriptions
|
Callbacks Type = "callbacks" // Represents webhook callback subscriptions
|
||||||
ChSettle Type = "chsettle_gateway" // Represents chimera settlement gateway
|
|
||||||
Notifications Type = "notifications" // Represents notifications sent to users
|
Notifications Type = "notifications" // Represents notifications sent to users
|
||||||
Organizations Type = "organizations" // Represents organizations in the system
|
Organizations Type = "organizations" // Represents organizations in the system
|
||||||
Payments Type = "payments" // Represents payments service
|
Payments Type = "payments" // Represents payments service
|
||||||
@@ -67,7 +66,7 @@ func StringToSType(s string) (Type, error) {
|
|||||||
ChainTransfers, ChainDeposits, Callbacks, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
|
ChainTransfers, ChainDeposits, Callbacks, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
|
||||||
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
|
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
|
||||||
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
|
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
|
||||||
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery, ChSettle:
|
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:
|
||||||
return Type(s), nil
|
return Type(s), nil
|
||||||
default:
|
default:
|
||||||
return "", merrors.InvalidArgument("invalid service type", s)
|
return "", merrors.InvalidArgument("invalid service type", s)
|
||||||
|
|||||||
@@ -5,18 +5,94 @@ package billing.documents.v1;
|
|||||||
option go_package = "github.com/tech/sendico/pkg/proto/billing/documents/v1;documentsv1";
|
option go_package = "github.com/tech/sendico/pkg/proto/billing/documents/v1;documentsv1";
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// ENUMS
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
// DocumentType defines supported accounting document kinds.
|
||||||
|
enum DocumentType {
|
||||||
|
DOCUMENT_TYPE_UNSPECIFIED = 0;
|
||||||
|
|
||||||
|
// Invoice issued for the payment
|
||||||
|
DOCUMENT_TYPE_INVOICE = 1;
|
||||||
|
|
||||||
|
// Service acceptance act (common in EU/RU accounting)
|
||||||
|
DOCUMENT_TYPE_ACT = 2;
|
||||||
|
|
||||||
|
// Simple receipt confirmation
|
||||||
|
DOCUMENT_TYPE_RECEIPT = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// SERVICE
|
// SERVICE
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
||||||
// DocumentService provides operation-level document generation.
|
// DocumentService provides document metadata for payment lists
|
||||||
|
// and lazy document generation on demand.
|
||||||
service DocumentService {
|
service DocumentService {
|
||||||
|
|
||||||
|
// BatchResolveDocuments is used by BFF when rendering
|
||||||
|
// a page of payments. This prevents N+1 calls by resolving
|
||||||
|
// document metadata for many payments in a single request.
|
||||||
|
rpc BatchResolveDocuments(BatchResolveDocumentsRequest)
|
||||||
|
returns (BatchResolveDocumentsResponse);
|
||||||
|
|
||||||
|
// GetDocument returns the actual PDF file.
|
||||||
|
// If the document was not generated before, the service
|
||||||
|
// generates it lazily, stores it, and returns it.
|
||||||
|
rpc GetDocument(GetDocumentRequest)
|
||||||
|
returns (GetDocumentResponse);
|
||||||
|
|
||||||
// GetOperationDocument returns a generated PDF file for
|
// GetOperationDocument returns a generated PDF file for
|
||||||
// a gateway operation snapshot provided by the caller.
|
// a gateway operation snapshot provided by the caller.
|
||||||
rpc GetOperationDocument(GetOperationDocumentRequest)
|
rpc GetOperationDocument(GetOperationDocumentRequest)
|
||||||
returns (GetDocumentResponse);
|
returns (GetDocumentResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// BATCH RESOLVE (for payment tables)
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
// BatchResolveDocumentsRequest contains a list of payment references
|
||||||
|
// for which document availability should be resolved.
|
||||||
|
message BatchResolveDocumentsRequest {
|
||||||
|
repeated string payment_refs = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentMeta describes document availability for a single payment.
|
||||||
|
message DocumentMeta {
|
||||||
|
// Payment reference
|
||||||
|
string payment_ref = 1;
|
||||||
|
|
||||||
|
// Document types that are applicable for this payment
|
||||||
|
// based on business rules and payment snapshot.
|
||||||
|
repeated DocumentType available_types = 2;
|
||||||
|
|
||||||
|
// Document types that were already generated and stored.
|
||||||
|
// Other available types will be generated lazily when requested.
|
||||||
|
repeated DocumentType ready_types = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchResolveDocumentsResponse returns metadata for all requested payments.
|
||||||
|
message BatchResolveDocumentsResponse {
|
||||||
|
repeated DocumentMeta items = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// GET DOCUMENT (lazy generation)
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
// GetDocumentRequest requests a specific document for a payment.
|
||||||
|
message GetDocumentRequest {
|
||||||
|
string payment_ref = 1;
|
||||||
|
|
||||||
|
// Type of document to retrieve (invoice, act, receipt, etc.)
|
||||||
|
DocumentType type = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// GetDocumentResponse returns the generated PDF content.
|
// GetDocumentResponse returns the generated PDF content.
|
||||||
message GetDocumentResponse {
|
message GetDocumentResponse {
|
||||||
// Raw PDF bytes
|
// Raw PDF bytes
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user