29 Commits

Author SHA1 Message Date
d87e709f43 Merge pull request 'removed kaniko caching' (#698) from cicd-697 into main
Reviewed-on: #698
2026-03-06 18:05:42 +00:00
Stephan D
185f8f2ed6 removed kaniko caching 2026-03-06 19:04:36 +01:00
2160b6bf4d Merge pull request 'Billing docs improvement + build opt' (#696) from docs-693 into main
Some checks are pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline is running
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/ledger Pipeline is running
Reviewed-on: #696
2026-03-06 15:20:31 +00:00
Stephan D
54bbe41f7a Billing docs improvement + build opt 2026-03-06 16:20:01 +01:00
6633a1d807 Merge pull request 'gw <-> po contracts tests' (#692) from gw-691 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #692
2026-03-06 14:45:46 +00:00
Stephan D
88b279dd78 gw <-> po contracts tests 2026-03-06 15:45:14 +01:00
0f42a0e77f Merge pull request 'Chimera Settle service' (#690) from chsettle-689 into main
Reviewed-on: #690
2026-03-06 14:43:02 +00:00
Stephan D
10bcdb4fe2 Chimera Settle service 2026-03-06 15:42:32 +01:00
ea5ec79a6e Merge pull request 'fixed po <-> tgsettle contract' (#688) from po-687 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #688
2026-03-06 14:12:46 +00:00
Stephan D
3295b9d9f0 fixed po <-> tgsettle contract 2026-03-06 15:12:14 +01:00
031b8931ca Merge pull request 'fixed tgsettle upsert logic' (#686) from tg-685 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #686
2026-03-06 12:50:56 +00:00
Stephan D
4295456f63 fixed tgsettle upsert logic 2026-03-06 13:50:13 +01:00
2b1b4135f4 Merge pull request 'mntx error codes update' (#684) from mntx-683 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #684
2026-03-06 12:35:09 +00:00
Stephan D
c60e7d2329 mntx error codes update 2026-03-06 12:14:32 +01:00
be49254769 Merge pull request 'bff USDT ledger creation' (#682) from bff-681 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #682
2026-03-06 10:58:27 +00:00
Stephan D
34e507b664 bff USDT ledger creation 2026-03-06 11:58:07 +01:00
b481de9ffc Merge pull request 'New comments section in the requests/responses' (#679) from bff-677 into main
Reviewed-on: #679
2026-03-05 19:29:10 +00:00
Stephan D
0c29e7686d New comments section in the requests/responses 2026-03-05 20:28:28 +01:00
5b26a70a15 Merge pull request 'New comments section in the requests/responses' (#678) from bff-677 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #678
2026-03-05 19:28:05 +00:00
Stephan D
b832c2a7c4 New comments section in the requests/responses 2026-03-05 20:27:45 +01:00
15393765b9 Merge pull request 'fixes for multiple payout' (#674) from SEND067 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #674
2026-03-05 16:35:37 +00:00
Arseni
440b6a2553 fixes for multiple payout 2026-03-05 19:28:02 +03:00
bc76cfe063 Merge pull request 'tg-670' (#671) from tg-670 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #671
2026-03-05 13:47:37 +00:00
Stephan D
ed8f7c519c moved tg settings to db 2026-03-05 14:46:34 +01:00
Stephan D
71d99338f2 moved tg settings to db 2026-03-05 14:46:26 +01:00
b499778bce Merge pull request 'fixed treasury messages' (#669) from tg-666 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #669
2026-03-05 13:18:13 +00:00
Stephan D
4a554833c4 fixed treasury messages 2026-03-05 14:17:50 +01:00
b7ea11a62b Merge pull request 'fixed treasury messages' (#668) from tg-666 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #668
2026-03-05 13:09:47 +00:00
Stephan D
026f698d9b fixed treasury messages 2026-03-05 14:09:21 +01:00
178 changed files with 11735 additions and 995 deletions

View File

@@ -1,2 +1,18 @@
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

View File

@@ -1,10 +1,31 @@
# Sendico Development Environment - Makefile
# Docker Compose + Makefile build system
.PHONY: help init build up down restart logs rebuild clean vault-init proto generate generate-api generate-frontend update update-api update-frontend test test-api test-frontend
.PHONY: help init build up down restart logs rebuild clean vault-init proto generate generate-api generate-frontend update update-api update-frontend test test-api test-frontend backend-up backend-down backend-rebuild
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
SERVICE ?=
BACKEND_SERVICES := \
dev-discovery \
dev-fx-oracle \
dev-fx-ingestor \
dev-billing-fees \
dev-billing-documents \
dev-ledger \
dev-payments-orchestrator \
dev-payments-quotation \
dev-payments-methods \
dev-chain-gateway-vault-agent \
dev-chain-gateway \
dev-tron-gateway-vault-agent \
dev-tron-gateway \
dev-aurora-gateway \
dev-chsettle-gateway \
dev-notification \
dev-callbacks-vault-agent \
dev-callbacks \
dev-bff-vault-agent \
dev-bff
# Colors
GREEN := \033[0;32m
@@ -31,13 +52,16 @@ help:
@echo "$(YELLOW)Selective Operations:$(NC)"
@echo " make infra-up Start infrastructure only (mongo, nats, vault)"
@echo " make services-up Start application services only"
@echo " make backend-up Start backend services only (no infrastructure/frontend)"
@echo " make backend-down Stop backend services only"
@echo " make backend-rebuild Rebuild and restart backend services only"
@echo " make list-services List all available services"
@echo ""
@echo "$(YELLOW)Build Groups:$(NC)"
@echo " make build-core Build core services (discovery, ledger, fees, documents)"
@echo " make build-fx Build FX services (oracle, ingestor)"
@echo " make build-payments Build payment orchestrator"
@echo " make build-gateways Build gateway services (chain, tron, aurora, tgsettle)"
@echo " make build-gateways Build gateway services (chain, tron, aurora, chsettle)"
@echo " make build-api Build API services (notification, callbacks, bff)"
@echo " make build-frontend Build Flutter web frontend"
@echo ""
@@ -223,12 +247,27 @@ services-up:
dev-chain-gateway \
dev-tron-gateway \
dev-aurora-gateway \
dev-tgsettle-gateway \
dev-chsettle-gateway \
dev-notification \
dev-callbacks \
dev-bff \
dev-frontend
# Backend services only (no infrastructure, no frontend)
backend-up:
@echo "$(GREEN)Starting backend services only (no infra changes)...$(NC)"
@$(COMPOSE) up -d --no-deps $(BACKEND_SERVICES)
backend-down:
@echo "$(YELLOW)Stopping backend services only...$(NC)"
@$(COMPOSE) stop $(BACKEND_SERVICES)
backend-rebuild:
@echo "$(GREEN)Rebuilding backend services only (no infra changes)...$(NC)"
@$(COMPOSE) build $(BACKEND_SERVICES)
@$(COMPOSE) up -d --no-deps --force-recreate $(BACKEND_SERVICES)
@echo "$(GREEN)✅ Backend services rebuilt$(NC)"
# Status check
status:
@$(COMPOSE) ps
@@ -253,7 +292,7 @@ list-services:
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
@echo " - dev-aurora-gateway :50075, :9405, :8084 (Card Payouts Simulator)"
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
@echo " - dev-chsettle-gateway :50080, :9406 (Chimera Settlements Simulator)"
@echo " - dev-notification :8081 (Notifications)"
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
@echo " - dev-bff :8080 (Backend for Frontend)"
@@ -283,7 +322,7 @@ build-payments:
build-gateways:
@echo "$(GREEN)Building gateway services...$(NC)"
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-tgsettle-gateway
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-chsettle-gateway
build-api:
@echo "$(GREEN)Building API services...$(NC)"

View File

@@ -24,13 +24,25 @@ Financial services platform providing payment orchestration, ledger accounting,
| FX Ingestor | `api/fx/ingestor/` | FX rate ingestion |
| Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway |
| Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway |
| 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 TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
| Gateway TGSettle (legacy) | `api/gateway/tgsettle/` | Legacy Telegram settlement gateway (not used in dev compose) |
| Notification | `api/notification/` | Notifications |
| BFF | `api/edge/bff/` | Backend for frontend |
| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery |
| 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.
## Prerequisites
- Docker with Docker Compose plugin
- GNU Make
- Go toolchain
- Dart SDK
- Flutter SDK
## Development
Development uses Docker Compose via the Makefile. Run `make help` for all available commands.
@@ -54,6 +66,8 @@ make status # Show service status
make logs # View all logs
make logs SERVICE=dev-ledger # View logs for a specific service
make rebuild SERVICE=dev-ledger # Rebuild and restart a specific service
make list-services # List all services and ports
make health # Check service health
make clean # Remove all containers and volumes
```
@@ -62,6 +76,10 @@ make clean # Remove all containers and volumes
```bash
make infra-up # Start infrastructure only (MongoDB, NATS, Vault)
make services-up # Start application services only (assumes infra is running)
make backend-up # Start backend services only (no infrastructure/frontend changes)
make backend-down # Stop backend services only
make backend-rebuild # Rebuild and restart backend services only
make list-services # Show service names, ports, and descriptions
```
### Build Groups
@@ -69,8 +87,8 @@ make services-up # Start application services only (assumes infra is running)
```bash
make build-core # discovery, ledger, fees, documents
make build-fx # oracle, ingestor
make build-payments # orchestrator
make build-gateways # chain, tron, mntx, tgsettle
make build-payments # orchestrator, quotation, methods
make build-gateways # chain, tron, aurora, chsettle
make build-api # notification, callbacks, bff
make build-frontend # Flutter web UI
```

View File

@@ -24,8 +24,6 @@ database:
documents:
issuer:
legal_name: "Sendico Ltd"
legal_address: "12 Market Street, London, UK"
logo_path: "assets/logo.png"
templates:
acceptance_path: "templates/acceptance.tpl"

View File

@@ -24,8 +24,6 @@ database:
documents:
issuer:
legal_name: "Sendico Ltd"
legal_address: "12 Market Street, London, UK"
logo_path: "/app/assets/logo.png"
templates:
acceptance_path: "/app/templates/acceptance.tpl"

View File

@@ -8,14 +8,14 @@ require (
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/credentials v1.19.11
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
github.com/jung-kurt/gofpdf v1.16.2
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.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.1
google.golang.org/grpc v1.79.2
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/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/v4a v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // 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/presigned-url v1.13.19 // indirect

View File

@@ -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/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/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
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/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
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/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/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/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/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
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.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
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/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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -0,0 +1,116 @@
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",
}

View File

@@ -3,18 +3,24 @@ package documents
import (
"strings"
"github.com/tech/sendico/billing/documents/internal/content"
"github.com/tech/sendico/billing/documents/internal/docstore"
"github.com/tech/sendico/billing/documents/renderer"
)
// Config holds document service settings loaded from YAML.
type Config struct {
Issuer renderer.Issuer `yaml:"issuer"`
Issuer IssuerConfig `yaml:"issuer"`
Templates TemplateConfig `yaml:"templates"`
Protection ProtectionConfig `yaml:"protection"`
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.
type TemplateConfig struct {
AcceptancePath string `yaml:"acceptance_path"`
@@ -25,6 +31,14 @@ type ProtectionConfig struct {
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 {
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
return "templates/acceptance.tpl"

View File

@@ -7,7 +7,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
"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/status"
)
@@ -17,7 +16,6 @@ var (
requestsTotal *prometheus.CounterVec
requestLatency *prometheus.HistogramVec
batchSize prometheus.Histogram
documentBytes *prometheus.HistogramVec
)
@@ -44,16 +42,6 @@ func initMetrics() {
[]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(
prometheus.HistogramOpts{
Namespace: "billing",
@@ -67,18 +55,14 @@ func initMetrics() {
})
}
func observeRequest(call string, docType documentsv1.DocumentType, statusLabel string, took time.Duration) {
typeLabel := docTypeLabel(docType)
requestsTotal.WithLabelValues(call, statusLabel, typeLabel).Inc()
requestLatency.WithLabelValues(call, statusLabel, typeLabel).Observe(took.Seconds())
func observeRequest(call string, documentKind, statusLabel string, took time.Duration) {
kind := docKindLabel(documentKind)
requestsTotal.WithLabelValues(call, statusLabel, kind).Inc()
requestLatency.WithLabelValues(call, statusLabel, kind).Observe(took.Seconds())
}
func observeBatchSize(size int) {
batchSize.Observe(float64(size))
}
func observeDocumentBytes(docType documentsv1.DocumentType, size int) {
documentBytes.WithLabelValues(docTypeLabel(docType)).Observe(float64(size))
func observeDocumentBytes(documentKind string, size int) {
documentBytes.WithLabelValues(docKindLabel(documentKind)).Observe(float64(size))
}
func statusFromError(err error) string {
@@ -100,10 +84,10 @@ func statusFromError(err error) string {
return strings.ToLower(code.String())
}
func docTypeLabel(docType documentsv1.DocumentType) string {
label := docType.String()
func docKindLabel(documentKind string) string {
label := strings.TrimSpace(documentKind)
if label == "" {
return "DOCUMENT_TYPE_UNSPECIFIED"
return "operation"
}
return label

View File

@@ -5,11 +5,11 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path/filepath"
"strings"
"time"
"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/renderer"
"github.com/tech/sendico/billing/documents/storage"
@@ -145,94 +145,6 @@ 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) {
start := time.Now()
organizationRef := ""
@@ -253,11 +165,10 @@ func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOp
defer func() {
statusLabel := statusFromError(err)
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
observeRequest("get_operation_document", "operation", statusLabel, time.Since(start))
if resp != nil {
observeDocumentBytes(docType, len(resp.GetContent()))
observeDocumentBytes("operation", len(resp.GetContent()))
}
contentBytes := 0
@@ -342,12 +253,6 @@ func (e serviceError) Error() string {
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) {
blocks, err := s.template.Render(snapshot)
if err != nil {
@@ -363,7 +268,7 @@ func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, stri
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
generated := renderer.Renderer{
Issuer: s.config.Issuer,
Issuer: s.config.IssuerDetails(),
OwnerPassword: s.config.Protection.OwnerPassword,
}
@@ -427,39 +332,41 @@ func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest)
}
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
documentCopy := content.OperationDocument
rows := [][]string{
{"Organization", snapshot.OrganizationRef},
{"Gateway Service", snapshot.GatewayService},
{"Operation Ref", snapshot.OperationRef},
{"Payment Ref", safeValue(snapshot.PaymentRef)},
{"Code", safeValue(snapshot.OperationCode)},
{"State", safeValue(snapshot.OperationState)},
{"Label", safeValue(snapshot.OperationLabel)},
{"Started At (UTC)", formatSnapshotTime(snapshot.StartedAt)},
{"Completed At (UTC)", formatSnapshotTime(snapshot.CompletedAt)},
{documentCopy.RowOrganization, snapshot.OrganizationRef},
{documentCopy.RowGatewayService, snapshot.GatewayService},
{documentCopy.RowOperationRef, snapshot.OperationRef},
{documentCopy.RowPaymentRef, safeValue(snapshot.PaymentRef)},
{documentCopy.RowCode, safeValue(snapshot.OperationCode)},
{documentCopy.RowState, safeValue(snapshot.OperationState)},
{documentCopy.RowLabel, safeValue(snapshot.OperationLabel)},
{documentCopy.RowStartedAtUTC, formatSnapshotTime(snapshot.StartedAt)},
{documentCopy.RowCompletedAtUTC, formatSnapshotTime(snapshot.CompletedAt)},
}
if snapshot.Amount != "" || snapshot.Currency != "" {
rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
rows = append(rows, []string{documentCopy.RowAmount, strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
}
blocks := []renderer.Block{
{
Tag: renderer.TagTitle,
Lines: []string{"OPERATION BILLING DOCUMENT"},
Lines: []string{documentCopy.Title},
},
{
Tag: renderer.TagSubtitle,
Lines: []string{"Gateway operation statement"},
Lines: []string{documentCopy.Subtitle},
},
{
Tag: renderer.TagMeta,
Lines: []string{
"Document Type: Operation",
documentCopy.MetaDocumentType,
},
},
{
Tag: renderer.TagSection,
Lines: []string{"OPERATION DETAILS"},
Lines: []string{documentCopy.SectionOperation},
},
{
Tag: renderer.TagKV,
@@ -469,12 +376,12 @@ func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
blocks = append(blocks,
renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}},
renderer.Block{Tag: renderer.TagSection, Lines: []string{documentCopy.SectionFailure}},
renderer.Block{
Tag: renderer.TagKV,
Rows: [][]string{
{"Failure Code", safeValue(snapshot.FailureCode)},
{"Failure Reason", safeValue(snapshot.FailureReason)},
{documentCopy.RowFailureCode, safeValue(snapshot.FailureCode)},
{documentCopy.RowFailureReason, safeValue(snapshot.FailureReason)},
},
},
)
@@ -485,7 +392,7 @@ func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
func formatSnapshotTime(value time.Time) string {
if value.IsZero() {
return "n/a"
return content.OperationDocument.MissingValuePlaceholder
}
return value.UTC().Format(time.RFC3339)
@@ -494,7 +401,7 @@ func formatSnapshotTime(value time.Time) string {
func safeValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "n/a"
return content.OperationDocument.MissingValuePlaceholder
}
return trimmed
@@ -535,50 +442,3 @@ func sanitizeFilenameComponent(value string) 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)
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"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/storage"
"github.com/tech/sendico/billing/documents/storage/model"
@@ -59,10 +60,6 @@ type memDocStore struct {
loadCount int
}
func newMemDocStore() *memDocStore {
return &memDocStore{data: map[string][]byte{}}
}
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
m.saveCount++
copyData := make([]byte, len(data))
@@ -112,15 +109,7 @@ 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,
WithConfig(cfg),
WithTemplateRenderer(tmpl),
)
@@ -164,7 +153,7 @@ func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
}
func extractFooterHash(pdf []byte) string {
prefix := []byte("Document integrity hash: ")
prefix := []byte(content.DocumentIntegrityHashPrefix)
idx := bytes.Index(pdf, prefix)
if idx == -1 {
@@ -191,11 +180,7 @@ func isHexDigit(b byte) bool {
}
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
Issuer: renderer.Issuer{
LegalName: "Sendico Ltd",
},
}))
svc := NewService(zap.NewNop(), nil, nil)
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
OrganizationRef: "org-1",

View File

@@ -9,6 +9,7 @@ import (
"time"
"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/storage/model"
)
@@ -17,6 +18,11 @@ type templateRenderer struct {
tpl *template.Template
}
type acceptanceTemplateData struct {
model.ActSnapshot
Content content.AcceptanceTemplateContent
}
func newTemplateRenderer(path string) (*templateRenderer, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -38,7 +44,12 @@ func newTemplateRenderer(path string) (*templateRenderer, error) {
func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
var buf bytes.Buffer
if err := r.tpl.Execute(&buf, snapshot); err != nil {
data := acceptanceTemplateData{
ActSnapshot: snapshot,
Content: content.AcceptanceTemplate,
}
if err := r.tpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"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/storage/model"
)
@@ -42,7 +43,7 @@ func TestTemplateRenderer_Render(t *testing.T) {
t.Fatalf("expected title block")
}
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
if !slices.Contains(title.Lines, content.AcceptanceTemplate.Title) {
t.Fatalf("expected title content not found")
}
@@ -54,7 +55,7 @@ func TestTemplateRenderer_Render(t *testing.T) {
foundExecutor := false
for _, row := range kv.Rows {
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
if len(row) >= 2 && row[0] == content.AcceptanceTemplate.PartyExecutorLabel && row[1] == snapshot.ExecutorFullName {
foundExecutor = true
break

View File

@@ -6,6 +6,7 @@ import (
"strings"
"github.com/jung-kurt/gofpdf"
"github.com/tech/sendico/billing/documents/internal/content"
)
const (
@@ -28,7 +29,7 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
pdf.SetAutoPageBreak(true, pageMarginBottom)
pdf.SetCompression(false)
pdf.SetAuthor(r.Issuer.LegalName, false)
pdf.SetTitle("Act of Acceptance", false)
pdf.SetTitle(content.PDFTitleActOfAcceptance, false)
owner := strings.TrimSpace(r.OwnerPassword)
if owner != "" {
@@ -39,7 +40,7 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
pdf.SetY(-15)
pdf.SetFont("Helvetica", "", 8)
footer := "Document integrity hash: " + footerHash
footer := content.DocumentIntegrityHashPrefix + footerHash
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
})

View File

@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"unicode/utf16"
"github.com/tech/sendico/billing/documents/internal/content"
)
func TestRenderer_RenderContainsText(t *testing.T) {
@@ -31,7 +33,7 @@ func TestRenderer_RenderContainsText(t *testing.T) {
t.Fatalf("expected PDF bytes")
}
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", strings.TrimSpace(strings.TrimSuffix(content.DocumentIntegrityHashPrefix, ": "))}
for _, token := range checks {
if !containsPDFText(pdfBytes, token) {

View File

@@ -6,14 +6,13 @@ import (
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/db/storable"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
)
const (
DocumentRecordsCollection = "document_records"
)
// DocumentType mirrors the protobuf enum but stores string names for Mongo compatibility.
// DocumentType represents document kinds cached in storage.
type DocumentType string
const (
@@ -23,24 +22,6 @@ const (
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.
type ActSnapshot struct {
PaymentID string `bson:"paymentId" json:"paymentId"`

View File

@@ -2,66 +2,66 @@
#title
ACT OF ACCEPTANCE OF SERVICES
{{ .Content.Title }}
#subtitle
under the Public Offer Agreement
{{ .Content.Subtitle }}
#meta
Date: {{ date .Date }}
Act No: {{ .PaymentID }}
{{ .Content.MetaDateLabel }}: {{ date .Date }}
{{ .Content.MetaActNumberLabel }}: {{ .PaymentID }}
#section
PARTIES
{{ .Content.SectionParties }}
#text
This Act is made between the following Parties.
{{ .Content.PartiesIntro }}
#kv
Executor | {{ .ExecutorFullName }}
Status | Individual
{{ .Content.PartyExecutorLabel }} | {{ .ExecutorFullName }}
{{ .Content.PartyStatusLabel }} | {{ .Content.PartyStatusValue }}
#section
BASIS
{{ .Content.SectionBasis }}
#text
This Act is issued pursuant to the Public Offer Agreement
accepted by the Executor by joining the offer.
{{ .Content.BasisLine1 }}
{{ .Content.BasisLine2 }}
#section
SERVICES RENDERED
{{ .Content.SectionServicesRendered }}
#text
The Executor has rendered services to the Customer
in accordance with the terms of the Public Offer Agreement.
{{ .Content.ServicesRenderedLine1 }}
{{ .Content.ServicesRenderedLine2 }}
#section
REMUNERATION
{{ .Content.SectionRemuneration }}
#table
Description | Amount
Services rendered under the Public Offer Agreement | {{ money .Amount .Currency }}
{{ .Content.RemunerationHeaderDesc }} | {{ .Content.RemunerationHeaderAmount }}
{{ .Content.RemunerationServicesDesc }} | {{ money .Amount .Currency }}
#section
CONFIRMATION
{{ .Content.SectionConfirmation }}
#text
The Customer confirms that the services were rendered properly
and accepted without any claims.
{{ .Content.ConfirmationLine1 }}
{{ .Content.ConfirmationLine2 }}
The remuneration for the services was paid to the Executor
using the bank card details provided by the Executor.
{{ .Content.ConfirmationPaymentLine1 }}
{{ .Content.ConfirmationPaymentLine2 }}
#section
SIGNATURES
{{ .Content.SectionSignatures }}
#sign
Customer ___________________________
{{ .Content.SignatureCustomerLine }}
Executor ___________________________
{{ .Content.SignatureExecutorLine }}

View File

@@ -10,7 +10,7 @@ require (
github.com/tech/sendico/fx/oracle v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.79.1
google.golang.org/grpc v1.79.2
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -44,6 +44,6 @@ require (
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
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -18,7 +18,7 @@ require (
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/credentials v1.19.11
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.4.0
@@ -38,7 +38,7 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.51.0
google.golang.org/grpc v1.79.1
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
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/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/v4a v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // 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/presigned-url v1.13.19 // indirect

View File

@@ -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/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/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
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/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
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/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/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/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/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
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.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
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/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/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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -14,6 +14,7 @@ type PaymentIntent struct {
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
FeeTreatment FeeTreatment `json:"fee_treatment,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Comment string `json:"comment,omitempty"`
Customer *Customer `json:"customer,omitempty"`
}

View File

@@ -70,6 +70,7 @@ type Payment struct {
PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
State string `json:"state,omitempty"`
Comment string `json:"comment,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
Operations []PaymentOperation `json:"operations,omitempty"`
@@ -294,6 +295,7 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
return &Payment{
PaymentRef: p.GetPaymentRef(),
State: enumJSONName(p.GetState().String()),
Comment: strings.TrimSpace(p.GetIntentSnapshot().GetComment()),
FailureCode: failureCode,
FailureReason: failureReason,
Operations: operations,

View File

@@ -121,6 +121,22 @@ func TestToPaymentIgnoresHiddenFailures(t *testing.T) {
}
}
func TestToPaymentMapsIntentComment(t *testing.T) {
dto := toPayment(&orchestrationv2.Payment{
PaymentRef: "pay-3",
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED,
IntentSnapshot: &quotationv2.QuoteIntent{
Comment: " invoice-7 ",
},
})
if dto == nil {
t.Fatal("expected non-nil payment dto")
}
if got, want := dto.Comment, "invoice-7"; got != want {
t.Fatalf("comment mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
dto := toPaymentQuote(&quotationv2.PaymentQuote{
QuoteRef: "quote-1",

View File

@@ -338,9 +338,6 @@ func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organi
return merrors.Internal("chain gateway default asset is not configured")
}
// TODO: remove hardcode
currency := "RUB"
var describable *describablev1.Describable
name := strings.TrimSpace(sr.LedgerWallet.Name)
var description *string
@@ -357,6 +354,22 @@ func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organi
}
}
currencies := []string{"RUB", "USDT"}
if chainTokenCurrency := strings.ToUpper(strings.TrimSpace(a.chainAsset.GetTokenSymbol())); chainTokenCurrency != "" {
currencies = append(currencies, chainTokenCurrency)
}
seen := make(map[string]struct{}, len(currencies))
for _, currency := range currencies {
currency = strings.ToUpper(strings.TrimSpace(currency))
if currency == "" {
continue
}
if _, exists := seen[currency]; exists {
continue
}
seen[currency] = struct{}{}
resp, err := a.ledgerClient.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: org.ID.Hex(),
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
@@ -370,13 +383,18 @@ func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organi
Describable: describable,
})
if err != nil {
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org))
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org), zap.String("currency", currency))
return err
}
if resp == nil || resp.GetAccount() == nil || strings.TrimSpace(resp.GetAccount().GetLedgerAccountRef()) == "" {
return merrors.Internal("ledger returned empty account reference")
}
a.logger.Info("Ledger account created for organization", mzap.StorableRef(org), zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
a.logger.Info("Ledger account created for organization",
mzap.StorableRef(org),
zap.String("currency", currency),
zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
}
return nil
}

View File

@@ -16,13 +16,13 @@ import (
)
type stubLedgerAccountClient struct {
createReq *ledgerv1.CreateAccountRequest
createReqs []*ledgerv1.CreateAccountRequest
createResp *ledgerv1.CreateAccountResponse
createErr error
}
func (s *stubLedgerAccountClient) CreateAccount(_ context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
s.createReq = req
s.createReqs = append(s.createReqs, req)
return s.createResp, s.createErr
}
@@ -31,7 +31,7 @@ func (s *stubLedgerAccountClient) Close() error {
}
func TestOpenOrgLedgerAccount(t *testing.T) {
t.Run("creates operating ledger account", func(t *testing.T) {
t.Run("creates operating ledger accounts for RUB and USDT", func(t *testing.T) {
desc := " Main org ledger account "
sr := &srequest.Signup{
Account: model.AccountData{
@@ -65,23 +65,27 @@ func TestOpenOrgLedgerAccount(t *testing.T) {
err := api.openOrgLedgerAccount(context.Background(), org, sr)
assert.NoError(t, err)
if assert.NotNil(t, ledgerStub.createReq) {
assert.Equal(t, org.ID.Hex(), ledgerStub.createReq.GetOrganizationRef())
assert.Equal(t, "RUB", ledgerStub.createReq.GetCurrency())
assert.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, ledgerStub.createReq.GetAccountType())
assert.Equal(t, ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, ledgerStub.createReq.GetStatus())
assert.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerStub.createReq.GetRole())
if assert.Len(t, ledgerStub.createReqs, 2) {
currencies := make([]string, 0, len(ledgerStub.createReqs))
for _, req := range ledgerStub.createReqs {
currencies = append(currencies, req.GetCurrency())
assert.Equal(t, org.ID.Hex(), req.GetOrganizationRef())
assert.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, req.GetAccountType())
assert.Equal(t, ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, req.GetStatus())
assert.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, req.GetRole())
assert.Equal(t, map[string]string{
"source": "signup",
"login": "owner@example.com",
}, ledgerStub.createReq.GetMetadata())
if assert.NotNil(t, ledgerStub.createReq.GetDescribable()) {
assert.Equal(t, "Primary Ledger", ledgerStub.createReq.GetDescribable().GetName())
if assert.NotNil(t, ledgerStub.createReq.GetDescribable().Description) {
assert.Equal(t, "Main org ledger account", ledgerStub.createReq.GetDescribable().GetDescription())
}, req.GetMetadata())
if assert.NotNil(t, req.GetDescribable()) {
assert.Equal(t, "Primary Ledger", req.GetDescribable().GetName())
if assert.NotNil(t, req.GetDescribable().Description) {
assert.Equal(t, "Main org ledger account", req.GetDescribable().GetDescription())
}
}
}
assert.ElementsMatch(t, []string{"RUB", "USDT"}, currencies)
}
})
t.Run("fails when ledger client is missing", func(t *testing.T) {

View File

@@ -61,9 +61,7 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
FeeTreatment: resolvedFeeTreatment,
SettlementCurrency: settlementCurrency,
Fx: mapFXIntent(intent),
}
if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" {
quoteIntent.Comment = comment
Comment: strings.TrimSpace(intent.Comment),
}
return quoteIntent, nil
}

View File

@@ -59,6 +59,6 @@ require (
golang.org/x/text v0.34.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/grpc v1.79.1 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -48,6 +48,6 @@ require (
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
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -13,7 +13,7 @@ require (
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.1
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -9,13 +9,12 @@ replace github.com/tech/sendico/gateway/common => ../common
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/shopspring/decimal v1.4.0
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.1
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -36,6 +35,7 @@ require (
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_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -492,6 +492,9 @@ func transferFromReceipt(req *chainv1.SubmitTransferRequest, receipt *connectorv
transfer.Destination = req.GetDestination()
transfer.RequestedAmount = 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 {
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
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"
"google.golang.org/grpc"
@@ -13,6 +14,11 @@ import (
type stubConnectorClient struct {
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) {
@@ -37,10 +43,18 @@ 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) {
s.submitReq = in
if s.submitResp != nil {
return s.submitResp, nil
}
return &connectorv1.SubmitOperationResponse{}, nil
}
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
}
@@ -66,3 +80,84 @@ func TestListManagedWallets_ForwardsOrganizationRef(t *testing.T) {
require.Equal(t, "owner-1", stub.listReq.GetOwnerRefFilter().GetValue())
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())
}

View File

@@ -16,7 +16,7 @@ require (
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.1
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -0,0 +1,46 @@
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 Normal file
View File

@@ -0,0 +1,4 @@
internal/generated
.gocache
app
tmp

View File

@@ -0,0 +1,101 @@
# 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

View File

@@ -0,0 +1,52 @@
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"

View File

@@ -0,0 +1,52 @@
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: ""

View File

@@ -0,0 +1,52 @@
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
)

221
api/gateway/chsettle/go.sum Normal file
View File

@@ -0,0 +1,221 @@
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=

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
info := version.Info{
Program: "Sendico Payment Gateway Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -0,0 +1,230 @@
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
}

View File

@@ -0,0 +1,11 @@
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)
}

View File

@@ -0,0 +1,443 @@
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
}

View File

@@ -0,0 +1,413 @@
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
}
}

View File

@@ -0,0 +1,119 @@
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)
}
}

View File

@@ -0,0 +1,47 @@
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)
}

View File

@@ -0,0 +1,302 @@
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
}

View File

@@ -0,0 +1,105 @@
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

View File

@@ -0,0 +1,417 @@
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)
}
}

View File

@@ -0,0 +1,108 @@
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
}

View File

@@ -0,0 +1,94 @@
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")
}

View File

@@ -0,0 +1,73 @@
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)
}

View File

@@ -0,0 +1,18 @@
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()
}

View File

@@ -0,0 +1,527 @@
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"
}

View File

@@ -0,0 +1,362 @@
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])
}
}

View File

@@ -0,0 +1,11 @@
package treasury
import "time"
type Config struct {
ExecutionDelay time.Duration
PollInterval time.Duration
MaxAmountPerOperation string
MaxDailyAmount string
}

View File

@@ -0,0 +1,312 @@
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 ""
}

View File

@@ -0,0 +1,235 @@
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")
}
}

View File

@@ -0,0 +1,205 @@
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
}

View File

@@ -0,0 +1,327 @@
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), " ")
}

View File

@@ -0,0 +1,457 @@
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)
}

View File

@@ -0,0 +1,178 @@
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
}

View File

@@ -0,0 +1,17 @@
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)
}

View File

@@ -0,0 +1,65 @@
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"`
}

View File

@@ -0,0 +1,29 @@
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
}

View File

@@ -0,0 +1,59 @@
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"`
}

View File

@@ -0,0 +1,132 @@
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)

View File

@@ -0,0 +1,159 @@
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)

View File

@@ -0,0 +1,245 @@
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] = &copyRec
*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 ""
}

View File

@@ -0,0 +1,205 @@
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)

View File

@@ -0,0 +1,91 @@
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)

View File

@@ -0,0 +1,402 @@
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)

View File

@@ -0,0 +1,87 @@
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)

View File

@@ -0,0 +1,38 @@
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}
}

View File

@@ -0,0 +1,53 @@
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)
}

View File

@@ -0,0 +1,144 @@
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)
}
}

View File

@@ -15,7 +15,7 @@ require (
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.1
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -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=
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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

View File

@@ -54,7 +54,7 @@ type cardPayoutProcessor struct {
dispatchSerialGate chan struct{}
retryPolicy payoutFailurePolicy
retryDelayFn func(attempt uint32) time.Duration
retryDelayFn func(attempt uint32, strategy payoutRetryStrategy) time.Duration
retryMu sync.Mutex
retryTimers map[string]*time.Timer
@@ -149,15 +149,13 @@ func applyCardPayoutSendResult(state *model.CardPayout, result *monetix.CardPayo
return
}
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
if result.Accepted {
state.Status = model.PayoutStatusWaiting
state.ProviderCode = ""
state.ProviderMessage = ""
return
}
state.Status = model.PayoutStatusFailed
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
}
func payoutStateLogFields(state *model.CardPayout) []zap.Field {
@@ -593,13 +591,20 @@ func payoutAcceptedForState(state *model.CardPayout) bool {
return false
}
switch state.Status {
case model.PayoutStatusFailed, model.PayoutStatusCancelled:
case model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
return false
default:
return true
}
}
func terminalStatusAfterRetryExhausted(decision payoutFailureDecision) model.PayoutStatus {
if decision.Action == payoutFailureActionRetry {
return model.PayoutStatusNeedsAttention
}
return model.PayoutStatusFailed
}
func cardPayoutResponseFromState(
state *model.CardPayout,
accepted bool,
@@ -733,15 +738,21 @@ func (p *cardPayoutProcessor) scheduleRetryTimer(operationRef string, delay time
p.retryTimers[key] = timer
}
func retryDelayDuration(attempt uint32) time.Duration {
return time.Duration(retryDelayForAttempt(attempt)) * time.Second
func retryDelayDuration(attempt uint32, strategy payoutRetryStrategy) time.Duration {
return time.Duration(retryDelayForAttempt(attempt, strategy)) * time.Second
}
func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequest, failedAttempt uint32, maxAttempts uint32) {
func (p *cardPayoutProcessor) scheduleCardPayoutRetry(
req *mntxv1.CardPayoutRequest,
failedAttempt uint32,
maxAttempts uint32,
strategy payoutRetryStrategy,
) {
if p == nil || req == nil {
return
}
maxAttempts = maxDispatchAttempts(maxAttempts)
strategy = normalizeRetryStrategy(strategy)
nextAttempt := failedAttempt + 1
if nextAttempt > maxAttempts {
return
@@ -751,12 +762,13 @@ func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequ
return
}
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
delay := retryDelayDuration(failedAttempt)
delay := retryDelayDuration(failedAttempt, strategy)
if p.retryDelayFn != nil {
delay = p.retryDelayFn(failedAttempt)
delay = p.retryDelayFn(failedAttempt, strategy)
}
p.logger.Info("Scheduling card payout retry",
zap.String("operation_ref", operationRef),
zap.String("strategy", strategy.String()),
zap.Uint32("failed_attempt", failedAttempt),
zap.Uint32("next_attempt", nextAttempt),
zap.Uint32("max_attempts", maxAttempts),
@@ -767,11 +779,17 @@ func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequ
})
}
func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(req *mntxv1.CardTokenPayoutRequest, failedAttempt uint32, maxAttempts uint32) {
func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(
req *mntxv1.CardTokenPayoutRequest,
failedAttempt uint32,
maxAttempts uint32,
strategy payoutRetryStrategy,
) {
if p == nil || req == nil {
return
}
maxAttempts = maxDispatchAttempts(maxAttempts)
strategy = normalizeRetryStrategy(strategy)
nextAttempt := failedAttempt + 1
if nextAttempt > maxAttempts {
return
@@ -781,12 +799,13 @@ func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(req *mntxv1.CardToken
return
}
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
delay := retryDelayDuration(failedAttempt)
delay := retryDelayDuration(failedAttempt, strategy)
if p.retryDelayFn != nil {
delay = p.retryDelayFn(failedAttempt)
delay = p.retryDelayFn(failedAttempt, strategy)
}
p.logger.Info("Scheduling card token payout retry",
zap.String("operation_ref", operationRef),
zap.String("strategy", strategy.String()),
zap.Uint32("failed_attempt", failedAttempt),
zap.Uint32("next_attempt", nextAttempt),
zap.Uint32("max_attempts", maxAttempts),
@@ -857,11 +876,11 @@ func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest,
p.logger.Warn("Failed to persist retryable payout transport failure", zap.Error(upErr))
return
}
p.scheduleCardPayoutRetry(req, attempt, maxAttempts)
p.scheduleCardPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
return
}
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason("", err.Error())
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
p.logger.Warn("Failed to persist terminal payout transport failure", zap.Error(upErr))
@@ -889,11 +908,11 @@ func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest,
p.logger.Warn("Failed to persist retryable payout provider failure", zap.Error(upErr))
return
}
p.scheduleCardPayoutRetry(req, attempt, maxAttempts)
p.scheduleCardPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
return
}
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
p.logger.Warn("Failed to persist terminal payout provider failure", zap.Error(upErr))
@@ -946,11 +965,11 @@ func (p *cardPayoutProcessor) runCardTokenPayoutRetry(req *mntxv1.CardTokenPayou
p.logger.Warn("Failed to persist retryable token payout transport failure", zap.Error(upErr))
return
}
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts)
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
return
}
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason("", err.Error())
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
p.logger.Warn("Failed to persist terminal token payout transport failure", zap.Error(upErr))
@@ -978,11 +997,11 @@ func (p *cardPayoutProcessor) runCardTokenPayoutRetry(req *mntxv1.CardTokenPayou
p.logger.Warn("Failed to persist retryable token payout provider failure", zap.Error(upErr))
return
}
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts)
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
return
}
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
p.logger.Warn("Failed to persist terminal token payout provider failure", zap.Error(upErr))
@@ -1067,7 +1086,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
}
if existing != nil {
switch existing.Status {
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled:
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
p.observeExecutionState(existing)
return cardPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
}
@@ -1088,11 +1107,11 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
p.logger.Warn("Failed to update payout status", fields...)
return nil, e
}
p.scheduleCardPayoutRetry(req, 1, maxAttempts)
p.scheduleCardPayoutRetry(req, 1, maxAttempts, decision.Strategy)
return cardPayoutResponseFromState(state, true, "", ""), nil
}
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason("", err.Error())
if e := p.updatePayoutStatus(ctx, state); e != nil {
fields := append([]zap.Field{zap.Error(e)}, payoutStateLogFields(state)...)
@@ -1112,6 +1131,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
errorMessage := strings.TrimSpace(result.ErrorMessage)
scheduleRetry := false
retryMaxAttempts := uint32(0)
retryStrategy := payoutRetryStrategyImmediate
if !result.Accepted {
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
@@ -1124,8 +1144,9 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
errorMessage = ""
scheduleRetry = true
retryMaxAttempts = maxAttempts
retryStrategy = decision.Strategy
} else {
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
p.clearRetryState(state.OperationRef)
}
@@ -1144,7 +1165,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
return nil, err
}
if scheduleRetry {
p.scheduleCardPayoutRetry(req, 1, retryMaxAttempts)
p.scheduleCardPayoutRetry(req, 1, retryMaxAttempts, retryStrategy)
}
resp := cardPayoutResponseFromState(state, accepted, errorCode, errorMessage)
@@ -1231,7 +1252,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
}
if existing != nil {
switch existing.Status {
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled:
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
p.observeExecutionState(existing)
return cardTokenPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
}
@@ -1250,11 +1271,11 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
if e := p.updatePayoutStatus(ctx, state); e != nil {
return nil, e
}
p.scheduleCardTokenPayoutRetry(req, 1, maxAttempts)
p.scheduleCardTokenPayoutRetry(req, 1, maxAttempts, decision.Strategy)
return cardTokenPayoutResponseFromState(state, true, "", ""), nil
}
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason("", err.Error())
if e := p.updatePayoutStatus(ctx, state); e != nil {
return nil, e
@@ -1274,6 +1295,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
errorMessage := strings.TrimSpace(result.ErrorMessage)
scheduleRetry := false
retryMaxAttempts := uint32(0)
retryStrategy := payoutRetryStrategyImmediate
if !result.Accepted {
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
@@ -1286,8 +1308,9 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
errorMessage = ""
scheduleRetry = true
retryMaxAttempts = maxAttempts
retryStrategy = decision.Strategy
} else {
state.Status = model.PayoutStatusFailed
state.Status = terminalStatusAfterRetryExhausted(decision)
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
p.clearRetryState(state.OperationRef)
}
@@ -1301,7 +1324,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
return nil, err
}
if scheduleRetry {
p.scheduleCardTokenPayoutRetry(req, 1, retryMaxAttempts)
p.scheduleCardTokenPayoutRetry(req, 1, retryMaxAttempts, retryStrategy)
}
resp := cardTokenPayoutResponseFromState(state, accepted, errorCode, errorMessage)
@@ -1470,7 +1493,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
}
retryScheduled := false
if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled {
if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled || state.Status == model.PayoutStatusNeedsAttention {
decision := p.retryPolicy.decideProviderFailure(state.ProviderCode)
attemptsUsed := p.currentDispatchAttempt(operationRef)
maxAttempts := p.maxDispatchAttempts()
@@ -1488,7 +1511,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
p.logger.Warn("Failed to persist callback retry scheduling state", zap.Error(err))
return http.StatusInternalServerError, err
}
p.scheduleCardPayoutRetry(req, attemptsUsed, maxAttempts)
p.scheduleCardPayoutRetry(req, attemptsUsed, maxAttempts, decision.Strategy)
retryScheduled = true
} else if req := p.loadCardTokenRetryRequest(operationRef); req != nil {
state.Status = model.PayoutStatusProcessing
@@ -1503,7 +1526,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
p.logger.Warn("Failed to persist callback token retry scheduling state", zap.Error(err))
return http.StatusInternalServerError, err
}
p.scheduleCardTokenPayoutRetry(req, attemptsUsed, maxAttempts)
p.scheduleCardTokenPayoutRetry(req, attemptsUsed, maxAttempts, decision.Strategy)
retryScheduled = true
} else {
p.logger.Warn("Retryable callback decline received but no retry request snapshot found",
@@ -1514,6 +1537,12 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
)
}
}
if !retryScheduled && decision.Action == payoutFailureActionRetry {
state.Status = model.PayoutStatusNeedsAttention
}
if existing != nil && existing.Status == model.PayoutStatusNeedsAttention {
state.Status = model.PayoutStatusNeedsAttention
}
if !retryScheduled && strings.TrimSpace(state.FailureReason) == "" {
state.FailureReason = payoutFailureReason(state.ProviderCode, state.ProviderMessage)
}

View File

@@ -101,6 +101,68 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
}
}
func TestCardPayoutProcessor_Submit_AcceptedBodyErrorRemainsWaiting(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := monetix.APIResponse{
Status: "error",
Code: "3062",
Message: "Payment details not received",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil)
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted payout response")
}
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING {
t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus())
}
if got := resp.GetErrorCode(); got != "3062" {
t.Fatalf("expected response error code %q, got %q", "3062", got)
}
if got := resp.GetErrorMessage(); got != "Payment details not received" {
t.Fatalf("expected response error message, got %q", got)
}
stored, ok := repo.payouts.Get(req.GetPayoutId())
if !ok || stored == nil {
t.Fatalf("expected payout state stored")
}
if got := stored.Status; got != model.PayoutStatusWaiting {
t.Fatalf("expected stored waiting status, got %v", got)
}
if got := stored.ProviderCode; got != "3062" {
t.Fatalf("expected stored provider code %q, got %q", "3062", got)
}
if got := stored.ProviderMessage; got != "Payment details not received" {
t.Fatalf("expected stored provider message, got %q", got)
}
}
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
cfg := monetix.Config{
AllowedCurrencies: []string{"RUB"},
@@ -525,7 +587,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
n := calls.Add(1)
resp := monetix.APIResponse{}
if n == 1 {
resp.Code = providerCodeDeclineAmountOrFrequencyLimit
resp.Code = "10101"
resp.Message = "Decline due to amount or frequency limit"
body, _ := json.Marshal(resp)
return &http.Response{
@@ -554,7 +616,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32) time.Duration { return 10 * time.Millisecond }
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 10 * time.Millisecond }
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
@@ -581,7 +643,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
}
}
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *testing.T) {
func TestCardPayoutProcessor_Submit_ProviderRetryUsesDelayedStrategy(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
@@ -590,12 +652,10 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
}
repo := newMockRepository()
var calls atomic.Int32
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
_ = calls.Add(1)
resp := monetix.APIResponse{
Code: providerCodeDeclineAmountOrFrequencyLimit,
Code: "10101",
Message: "Decline due to amount or frequency limit",
}
body, _ := json.Marshal(resp)
@@ -617,7 +677,159 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
capturedStrategy := payoutRetryStrategy(0)
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
capturedStrategy = strategy
return time.Hour
}
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response when retry is scheduled")
}
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyDelayed {
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyDelayed)
}
}
func TestCardPayoutProcessor_Submit_StatusRefreshRetryUsesStatusRefreshStrategy(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := monetix.APIResponse{
Code: "3061",
Message: "Transaction not found",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
capturedStrategy := payoutRetryStrategy(0)
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
capturedStrategy = strategy
return time.Hour
}
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response when retry is scheduled")
}
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyStatusRefresh {
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyStatusRefresh)
}
}
func TestCardPayoutProcessor_Submit_TransportRetryUsesImmediateStrategy(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("transport timeout")
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
capturedStrategy := payoutRetryStrategy(0)
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
capturedStrategy = strategy
return time.Hour
}
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response when retry is scheduled")
}
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyImmediate {
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyImmediate)
}
}
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttention(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
var calls atomic.Int32
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
_ = calls.Add(1)
resp := monetix.APIResponse{
Code: "10101",
Message: "Decline due to amount or frequency limit",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return time.Millisecond }
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
@@ -631,14 +843,14 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
deadline := time.Now().Add(2 * time.Second)
for {
state, ok := repo.payouts.Get(req.GetPayoutId())
if ok && state != nil && state.Status == model.PayoutStatusFailed {
if !strings.Contains(state.FailureReason, providerCodeDeclineAmountOrFrequencyLimit) {
if ok && state != nil && state.Status == model.PayoutStatusNeedsAttention {
if !strings.Contains(state.FailureReason, "10101") {
t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for terminal failed status")
t.Fatalf("timeout waiting for terminal needs_attention status")
}
time.Sleep(10 * time.Millisecond)
}
@@ -647,6 +859,59 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
}
}
func TestCardPayoutProcessor_Submit_NonRetryProviderDeclineRemainsFailed(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := monetix.APIResponse{
Code: "10003",
Message: "Decline by anti-fraud policy",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if resp.GetAccepted() {
t.Fatalf("expected non-accepted response for non-retryable provider decline")
}
state, ok := repo.payouts.Get(req.GetPayoutId())
if !ok || state == nil {
t.Fatal("expected stored payout state")
}
if got, want := state.Status, model.PayoutStatusFailed; got != want {
t.Fatalf("unexpected payout status: got=%q want=%q", got, want)
}
}
func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
@@ -687,7 +952,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond }
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 5 * time.Millisecond }
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
@@ -702,7 +967,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
cb.Payment.ID = req.GetPayoutId()
cb.Payment.Status = "failed"
cb.Operation.Status = "failed"
cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit
cb.Operation.Code = "10101"
cb.Operation.Message = "Decline due to amount or frequency limit"
cb.Payment.Sum.Currency = "RUB"

View File

@@ -69,6 +69,9 @@ func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
case model.PayoutStatusFailed:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case model.PayoutStatusNeedsAttention:
// Connector/gateway proto does not expose needs_attention yet; map it to failed externally.
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case model.PayoutStatusCancelled:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
default:

View File

@@ -0,0 +1,14 @@
package gateway
import (
"testing"
"github.com/tech/sendico/gateway/mntx/storage/model"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func TestPayoutStatusToProto_NeedsAttentionMapsToFailed(t *testing.T) {
if got, want := payoutStatusToProto(model.PayoutStatusNeedsAttention), mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED; got != want {
t.Fatalf("unexpected proto status: got=%v want=%v", got, want)
}
}

View File

@@ -1,13 +1,11 @@
package gateway
import (
"sort"
"strconv"
"strings"
)
const (
providerCodeDeclineAmountOrFrequencyLimit = "10101"
)
type payoutFailureAction int
const (
@@ -15,46 +13,136 @@ const (
payoutFailureActionRetry
)
type payoutRetryStrategy int
const (
payoutRetryStrategyImmediate payoutRetryStrategy = iota + 1
payoutRetryStrategyDelayed
payoutRetryStrategyStatusRefresh
)
type payoutFailureDecision struct {
Action payoutFailureAction
Strategy payoutRetryStrategy
Reason string
}
type payoutFailurePolicy struct {
providerCodeActions map[string]payoutFailureAction
providerCodeStrategies map[string]payoutRetryStrategy
documentedProviderCodes map[string]struct{}
}
func defaultPayoutFailurePolicy() payoutFailurePolicy {
return payoutFailurePolicy{
providerCodeActions: map[string]payoutFailureAction{
providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry,
type retryCodeBucket struct {
strategy payoutRetryStrategy
retryable bool
codes []string
}
var providerRetryOnlyCodeBuckets = []retryCodeBucket{
// GTX "repeat request now / temporary issue" style codes.
{
strategy: payoutRetryStrategyImmediate,
retryable: true,
codes: []string{
// General codes.
"104", "108", "301", "320", "601", "602", "603", "3025", "3198",
// External card PS codes.
"10000", "10100", "10104", "10105", "10107", "10202", "102051", "10301", "105012", "10505", "10601", "10602", "10603",
// External alternate PS codes.
"20000", "20100", "20104", "20105", "20202", "20301", "20304", "20601", "20602", "20603",
},
},
// GTX "retry later / limits / period restrictions" style codes.
{
strategy: payoutRetryStrategyDelayed,
retryable: true,
codes: []string{
// General codes.
"312", "314", "315", "316", "325", "2466",
"3106", "3108", "3109", "3110", "3111", "3112",
"3285", "3297", "3298",
"3305", "3306", "3307", "3308", "3309", "3310", "3311", "3312", "3313", "3314", "3315", "3316", "3317", "3318", "3319", "3320", "3321", "3322", "3323", "3324", "3325", "3326", "3327", "3328", "3329", "3330", "3331", "3332", "3333", "3334", "3335", "3336", "3337", "3338", "3339", "3340",
"3342", "3343", "3344", "3345", "3346", "3347", "3348", "3349", "3350", "3351", "3352", "3353", "3355", "3357",
"3407", "3408", "3450", "3451", "3452", "3613",
// External card PS codes.
"10101", "10109", "10112", "10114", "101012", "101013", "101014",
// External alternate PS codes.
"20109", "20206", "20505", "201012", "201013", "201014",
},
},
// GTX status refresh/polling conditions.
{
strategy: payoutRetryStrategyStatusRefresh,
retryable: true,
codes: []string{
"3061", "3062",
"9999", "19999", "20802", "29999",
},
},
}
var providerDocumentedNonRetryCodes = buildDocumentedNonRetryCodes(providerDocumentedCodes, providerRetryOnlyCodeBuckets)
var providerRetryCodeBuckets = func() []retryCodeBucket {
buckets := make([]retryCodeBucket, 0, len(providerRetryOnlyCodeBuckets)+1)
buckets = append(buckets, providerRetryOnlyCodeBuckets...)
buckets = append(buckets, retryCodeBucket{
strategy: payoutRetryStrategyImmediate,
retryable: false,
codes: providerDocumentedNonRetryCodes,
})
return buckets
}()
func defaultPayoutFailurePolicy() payoutFailurePolicy {
strategies := map[string]payoutRetryStrategy{}
for _, bucket := range providerRetryCodeBuckets {
if !bucket.retryable {
continue
}
registerRetryStrategy(strategies, bucket.strategy, bucket.codes...)
}
return payoutFailurePolicy{
providerCodeStrategies: strategies,
documentedProviderCodes: newCodeSet(providerDocumentedCodes),
}
}
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
normalized := strings.TrimSpace(code)
normalized := normalizeProviderCode(code)
if normalized == "" {
return payoutFailureDecision{
Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_failure",
}
}
if action, ok := p.providerCodeActions[normalized]; ok {
if strategy, ok := p.providerCodeStrategies[normalized]; ok {
return payoutFailureDecision{
Action: action,
Action: payoutFailureActionRetry,
Strategy: strategy,
Reason: "provider_code_" + normalized,
}
}
if _, ok := p.documentedProviderCodes[normalized]; ok {
return payoutFailureDecision{
Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_code_" + normalized + "_documented_non_retry",
}
}
return payoutFailureDecision{
Action: payoutFailureActionFail,
Reason: "provider_code_" + normalized,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_code_" + normalized + "_unknown",
}
}
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
return payoutFailureDecision{
Action: payoutFailureActionRetry,
Strategy: payoutRetryStrategyImmediate,
Reason: "transport_failure",
}
}
@@ -72,8 +160,40 @@ func payoutFailureReason(code, message string) string {
}
}
func retryDelayForAttempt(attempt uint32) int {
// Backoff in seconds by attempt number (attempt starts at 1).
func retryDelayForAttempt(attempt uint32, strategy payoutRetryStrategy) int {
strategy = normalizeRetryStrategy(strategy)
// Backoff in seconds by strategy and attempt number (attempt starts at 1).
if strategy == payoutRetryStrategyStatusRefresh {
switch {
case attempt <= 1:
return 10
case attempt == 2:
return 20
case attempt == 3:
return 40
case attempt == 4:
return 80
default:
return 160
}
}
if strategy == payoutRetryStrategyDelayed {
switch {
case attempt <= 1:
return 30
case attempt == 2:
return 120
case attempt == 3:
return 600
case attempt == 4:
return 1800
default:
return 7200
}
}
switch {
case attempt <= 1:
return 5
@@ -85,3 +205,86 @@ func retryDelayForAttempt(attempt uint32) int {
return 60
}
}
func registerRetryStrategy(dst map[string]payoutRetryStrategy, strategy payoutRetryStrategy, codes ...string) {
if dst == nil || len(codes) == 0 {
return
}
strategy = normalizeRetryStrategy(strategy)
for _, code := range codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
dst[normalized] = strategy
}
}
func newCodeSet(codes []string) map[string]struct{} {
set := map[string]struct{}{}
for _, code := range codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
set[normalized] = struct{}{}
}
return set
}
func buildDocumentedNonRetryCodes(documented []string, retryBuckets []retryCodeBucket) []string {
documentedSet := newCodeSet(documented)
retrySet := map[string]struct{}{}
for _, bucket := range retryBuckets {
for _, code := range bucket.codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
retrySet[normalized] = struct{}{}
}
}
nonRetry := make([]string, 0, len(documentedSet))
for code := range documentedSet {
if _, ok := retrySet[code]; ok {
continue
}
nonRetry = append(nonRetry, code)
}
sort.Slice(nonRetry, func(i, j int) bool {
left, leftErr := strconv.Atoi(nonRetry[i])
right, rightErr := strconv.Atoi(nonRetry[j])
if leftErr != nil || rightErr != nil {
return nonRetry[i] < nonRetry[j]
}
return left < right
})
return nonRetry
}
func normalizeProviderCode(code string) string {
return strings.TrimSpace(code)
}
func normalizeRetryStrategy(strategy payoutRetryStrategy) payoutRetryStrategy {
switch strategy {
case payoutRetryStrategyDelayed, payoutRetryStrategyStatusRefresh:
return strategy
default:
return payoutRetryStrategyImmediate
}
}
func (s payoutRetryStrategy) String() string {
switch normalizeRetryStrategy(s) {
case payoutRetryStrategyDelayed:
return "delayed"
case payoutRetryStrategyStatusRefresh:
return "status_refresh"
default:
return "immediate"
}
}

View File

@@ -0,0 +1,402 @@
package gateway
// providerDocumentedCodes is the normalized list of numeric response codes documented in
// https://developers.gtxpoint.com/ru/ru_gate_statuses_and_response_codes.html
// (all response-code tables).
var providerDocumentedCodes = []string{
"0",
"100",
"104",
"108",
"109",
"301",
"303",
"309",
"310",
"311",
"312",
"313",
"314",
"315",
"316",
"320",
"325",
"402",
"501",
"502",
"504",
"601",
"602",
"603",
"702",
"903",
"904",
"1337",
"1401",
"1402",
"1403",
"1404",
"1405",
"1406",
"1407",
"1408",
"1409",
"1410",
"1411",
"1412",
"1413",
"1415",
"1416",
"1417",
"1418",
"1419",
"1420",
"1421",
"1422",
"1423",
"1424",
"1425",
"1426",
"1427",
"1428",
"1429",
"1430",
"1431",
"1432",
"1433",
"1434",
"1435",
"1436",
"1437",
"1438",
"1439",
"1441",
"1451",
"1452",
"1453",
"1454",
"1455",
"1456",
"1457",
"1461",
"1462",
"1463",
"1464",
"1499",
"2003",
"2004",
"2005",
"2008",
"2014",
"2061",
"2123",
"2124",
"2154",
"2164",
"2261",
"2426",
"2442",
"2466",
"2541",
"2606",
"2609",
"2610",
"2611",
"2641",
"2642",
"2701",
"2801",
"2881",
"2945",
"2949",
"3001",
"3002",
"3003",
"3004",
"3019",
"3020",
"3021",
"3022",
"3023",
"3024",
"3025",
"3026",
"3027",
"3028",
"3029",
"3030",
"3041",
"3059",
"3060",
"3061",
"3062",
"3081",
"3101",
"3102",
"3103",
"3104",
"3105",
"3106",
"3107",
"3108",
"3109",
"3110",
"3111",
"3112",
"3118",
"3119",
"3120",
"3121",
"3122",
"3123",
"3124",
"3141",
"3161",
"3181",
"3182",
"3183",
"3184",
"3191",
"3192",
"3193",
"3194",
"3195",
"3196",
"3197",
"3198",
"3199",
"3200",
"3201",
"3221",
"3230",
"3241",
"3242",
"3243",
"3244",
"3261",
"3262",
"3281",
"3283",
"3284",
"3285",
"3286",
"3287",
"3288",
"3289",
"3291",
"3292",
"3293",
"3297",
"3298",
"3299",
"3301",
"3303",
"3304",
"3305",
"3306",
"3307",
"3308",
"3309",
"3310",
"3311",
"3312",
"3313",
"3314",
"3315",
"3316",
"3317",
"3318",
"3319",
"3320",
"3321",
"3322",
"3323",
"3324",
"3325",
"3326",
"3327",
"3328",
"3329",
"3330",
"3331",
"3332",
"3333",
"3334",
"3335",
"3336",
"3337",
"3338",
"3339",
"3340",
"3341",
"3342",
"3343",
"3344",
"3345",
"3346",
"3347",
"3348",
"3349",
"3350",
"3351",
"3352",
"3353",
"3355",
"3356",
"3357",
"3358",
"3360",
"3400",
"3402",
"3403",
"3404",
"3405",
"3406",
"3407",
"3408",
"3409",
"3410",
"3411",
"3412",
"3413",
"3414",
"3415",
"3416",
"3417",
"3418",
"3419",
"3431",
"3432",
"3433",
"3434",
"3435",
"3436",
"3437",
"3438",
"3439",
"3450",
"3451",
"3452",
"3470",
"3471",
"3472",
"3480",
"3485",
"3490",
"3491",
"3609",
"3610",
"3611",
"3612",
"3613",
"9999",
"10000",
"10100",
"10101",
"10102",
"10103",
"10104",
"10105",
"10106",
"10107",
"10108",
"10109",
"10110",
"10111",
"10112",
"10113",
"10114",
"10201",
"10202",
"10203",
"10204",
"10205",
"10301",
"10401",
"10402",
"10403",
"10404",
"10405",
"10501",
"10502",
"10503",
"10504",
"10505",
"10601",
"10602",
"10603",
"10701",
"10702",
"10703",
"10704",
"10705",
"10706",
"10707",
"10708",
"10709",
"10722",
"10801",
"10805",
"10806",
"10807",
"10811",
"10812",
"19999",
"20000",
"20100",
"20101",
"20102",
"20103",
"20104",
"20105",
"20106",
"20107",
"20109",
"20201",
"20202",
"20203",
"20204",
"20205",
"20206",
"20301",
"20302",
"20303",
"20304",
"20401",
"20402",
"20501",
"20502",
"20503",
"20504",
"20505",
"20601",
"20602",
"20603",
"20604",
"20701",
"20702",
"20703",
"20705",
"20706",
"20801",
"20802",
"29999",
"30000",
"30100",
"30301",
"30302",
"30303",
"30401",
"101011",
"101012",
"101013",
"101014",
"101021",
"102051",
"105012",
"108010",
"201011",
"201012",
"201013",
"201014",
}

View File

@@ -2,6 +2,29 @@ package gateway
import "testing"
func retryBucketCodeSet() map[string]struct{} {
set := map[string]struct{}{}
for _, bucket := range providerRetryCodeBuckets {
if !bucket.retryable {
continue
}
for _, code := range bucket.codes {
set[normalizeProviderCode(code)] = struct{}{}
}
}
return set
}
func allBucketCodeSet() map[string]struct{} {
set := map[string]struct{}{}
for _, bucket := range providerRetryCodeBuckets {
for _, code := range bucket.codes {
set[normalizeProviderCode(code)] = struct{}{}
}
}
return set
}
func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
policy := defaultPayoutFailurePolicy()
@@ -9,21 +32,43 @@ func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
name string
code string
action payoutFailureAction
strategy payoutRetryStrategy
}{
{
name: "retryable provider limit code",
code: providerCodeDeclineAmountOrFrequencyLimit,
name: "immediate retry strategy code",
code: "10000",
action: payoutFailureActionRetry,
strategy: payoutRetryStrategyImmediate,
},
{
name: "delayed retry strategy code",
code: "10101",
action: payoutFailureActionRetry,
strategy: payoutRetryStrategyDelayed,
},
{
name: "status refresh retry strategy code",
code: "3061",
action: payoutFailureActionRetry,
strategy: payoutRetryStrategyStatusRefresh,
},
{
name: "status refresh retry strategy payment details missing code",
code: "3062",
action: payoutFailureActionRetry,
strategy: payoutRetryStrategyStatusRefresh,
},
{
name: "unknown provider code",
code: "99999",
action: payoutFailureActionFail,
strategy: payoutRetryStrategyImmediate,
},
{
name: "empty provider code",
code: "",
action: payoutFailureActionFail,
strategy: payoutRetryStrategyImmediate,
},
}
@@ -35,6 +80,204 @@ func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
if got.Action != tc.action {
t.Fatalf("action mismatch: got=%v want=%v", got.Action, tc.action)
}
if got.Strategy != tc.strategy {
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, tc.strategy)
}
})
}
}
func TestPayoutFailurePolicy_DocumentRetryCoverage(t *testing.T) {
policy := defaultPayoutFailurePolicy()
// Parsed from GTX response-code tables (General, RCS, external card PS, external alternate PS, merchant system):
// 32 immediate + 84 delayed + 6 status-refresh = 122 retryable codes.
if got, want := len(policy.providerCodeStrategies), 122; got != want {
t.Fatalf("retry catalog size mismatch: got=%d want=%d", got, want)
}
if got, want := len(policy.documentedProviderCodes), 395; got != want {
t.Fatalf("documented code catalog size mismatch: got=%d want=%d", got, want)
}
cases := []struct {
code string
strategy payoutRetryStrategy
}{
// Immediate retry examples.
{code: "3025", strategy: payoutRetryStrategyImmediate},
{code: "3198", strategy: payoutRetryStrategyImmediate},
{code: "105012", strategy: payoutRetryStrategyImmediate},
{code: "20603", strategy: payoutRetryStrategyImmediate},
// Delayed retry examples, including previously missed high-range limits.
{code: "3106", strategy: payoutRetryStrategyDelayed},
{code: "3337", strategy: payoutRetryStrategyDelayed},
{code: "3407", strategy: payoutRetryStrategyDelayed},
{code: "3613", strategy: payoutRetryStrategyDelayed},
{code: "201014", strategy: payoutRetryStrategyDelayed},
// Status refresh examples.
{code: "3061", strategy: payoutRetryStrategyStatusRefresh},
{code: "3062", strategy: payoutRetryStrategyStatusRefresh},
{code: "20802", strategy: payoutRetryStrategyStatusRefresh},
}
for _, tc := range cases {
tc := tc
t.Run(tc.code, func(t *testing.T) {
t.Helper()
got := policy.decideProviderFailure(tc.code)
if got.Action != payoutFailureActionRetry {
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionRetry)
}
if got.Strategy != tc.strategy {
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, tc.strategy)
}
})
}
}
func TestPayoutFailurePolicy_DocumentedCodeCoverageByPolicy(t *testing.T) {
policy := defaultPayoutFailurePolicy()
retrySet := retryBucketCodeSet()
if got, want := len(retrySet), len(policy.providerCodeStrategies); got != want {
t.Fatalf("retry set size mismatch: got=%d want=%d", got, want)
}
documentedNonRetry := 0
for _, code := range providerDocumentedCodes {
code := normalizeProviderCode(code)
decision := policy.decideProviderFailure(code)
if _, isRetry := retrySet[code]; isRetry {
if decision.Action != payoutFailureActionRetry {
t.Fatalf("documented retry code %s unexpectedly classified as non-retry", code)
}
continue
}
documentedNonRetry++
if decision.Action != payoutFailureActionFail {
t.Fatalf("documented non-retry code %s unexpectedly classified as retry", code)
}
if decision.Reason != "provider_code_"+code+"_documented_non_retry" {
t.Fatalf("documented non-retry code %s has unexpected reason: %q", code, decision.Reason)
}
}
if got, want := len(retrySet)+documentedNonRetry, len(providerDocumentedCodes); got != want {
t.Fatalf("coverage mismatch: retry(%d)+non_retry(%d) != documented(%d)", len(retrySet), documentedNonRetry, len(providerDocumentedCodes))
}
}
func TestProviderRetryCodeBuckets_DoNotOverlapAndCoverDocumentedCodes(t *testing.T) {
seen := map[string]int{}
for bucketIdx, bucket := range providerRetryCodeBuckets {
for _, rawCode := range bucket.codes {
code := normalizeProviderCode(rawCode)
if code == "" {
t.Fatalf("empty code in bucket #%d", bucketIdx)
}
if prevIdx, ok := seen[code]; ok {
t.Fatalf("overlap detected for code %s between bucket #%d and bucket #%d", code, prevIdx, bucketIdx)
}
seen[code] = bucketIdx
}
}
allBucketCodes := allBucketCodeSet()
documented := newCodeSet(providerDocumentedCodes)
if got, want := len(allBucketCodes), len(documented); got != want {
t.Fatalf("union size mismatch: buckets=%d documented=%d", got, want)
}
for code := range documented {
if _, ok := allBucketCodes[code]; !ok {
t.Fatalf("documented code %s is missing from providerRetryCodeBuckets union", code)
}
}
for code := range allBucketCodes {
if _, ok := documented[code]; !ok {
t.Fatalf("bucket code %s is not present in documented code list", code)
}
}
}
func TestPayoutFailurePolicy_DecideProviderFailure_DocumentedNonRetryCode(t *testing.T) {
policy := defaultPayoutFailurePolicy()
got := policy.decideProviderFailure("3059")
if got.Action != payoutFailureActionFail {
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionFail)
}
if got.Strategy != payoutRetryStrategyImmediate {
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
}
if got.Reason != "provider_code_3059_documented_non_retry" {
t.Fatalf("reason mismatch: got=%q", got.Reason)
}
}
func TestPayoutFailurePolicy_DecideProviderFailure_UnknownCode(t *testing.T) {
policy := defaultPayoutFailurePolicy()
got := policy.decideProviderFailure("99999")
if got.Action != payoutFailureActionFail {
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionFail)
}
if got.Strategy != payoutRetryStrategyImmediate {
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
}
if got.Reason != "provider_code_99999_unknown" {
t.Fatalf("reason mismatch: got=%q", got.Reason)
}
}
func TestPayoutFailurePolicy_DecideTransportFailure(t *testing.T) {
policy := defaultPayoutFailurePolicy()
got := policy.decideTransportFailure()
if got.Action != payoutFailureActionRetry {
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionRetry)
}
if got.Strategy != payoutRetryStrategyImmediate {
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
}
}
func TestRetryDelayForAttempt_ByStrategy(t *testing.T) {
cases := []struct {
name string
attempt uint32
strategy payoutRetryStrategy
wantDelay int
}{
{
name: "immediate first attempt",
attempt: 1,
strategy: payoutRetryStrategyImmediate,
wantDelay: 5,
},
{
name: "delayed second attempt",
attempt: 2,
strategy: payoutRetryStrategyDelayed,
wantDelay: 120,
},
{
name: "status refresh third attempt",
attempt: 3,
strategy: payoutRetryStrategyStatusRefresh,
wantDelay: 40,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Helper()
if got := retryDelayForAttempt(tc.attempt, tc.strategy); got != tc.wantDelay {
t.Fatalf("delay mismatch: got=%d want=%d", got, tc.wantDelay)
}
})
}
}

View File

@@ -24,7 +24,7 @@ func isFinalStatus(t *model.CardPayout) bool {
func isFinalPayoutStatus(status model.PayoutStatus) bool {
switch status {
case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
case model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
return true
default:
return false
@@ -35,6 +35,8 @@ func toOpStatus(t *model.CardPayout) (rail.OperationResult, error) {
switch t.Status {
case model.PayoutStatusFailed:
return rail.OperationResultFailed, nil
case model.PayoutStatusNeedsAttention:
return rail.OperationResultFailed, nil
case model.PayoutStatusSuccess:
return rail.OperationResultSuccess, nil
case model.PayoutStatusCancelled:

View File

@@ -0,0 +1,24 @@
package gateway
import (
"testing"
"github.com/tech/sendico/gateway/mntx/storage/model"
"github.com/tech/sendico/pkg/payments/rail"
)
func TestIsFinalPayoutStatus_NeedsAttentionIsFinal(t *testing.T) {
if !isFinalPayoutStatus(model.PayoutStatusNeedsAttention) {
t.Fatal("expected needs_attention to be final")
}
}
func TestToOpStatus_NeedsAttentionMapsToFailed(t *testing.T) {
status, err := toOpStatus(&model.CardPayout{Status: model.PayoutStatusNeedsAttention})
if err != nil {
t.Fatalf("toOpStatus returned error: %v", err)
}
if status != rail.OperationResultFailed {
t.Fatalf("unexpected operation result: got=%q want=%q", status, rail.OperationResultFailed)
}
}

View File

@@ -166,25 +166,16 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
}
}
if apiResp.Operation.RequestID != "" {
result.ProviderRequestID = apiResp.Operation.RequestID
} else if apiResp.RequestID != "" {
result.ProviderRequestID = apiResp.RequestID
}
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
if result.ProviderStatus == "" {
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
}
result.ProviderRequestID = providerRequestID(apiResp)
result.ProviderStatus = providerStatus(apiResp)
if !result.Accepted {
result.ErrorCode = apiResp.Code
if result.ErrorCode == "" {
errorCode, errorMessage := providerError(apiResp)
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
result.ErrorCode = errorCode
if !result.Accepted && result.ErrorCode == "" {
result.ErrorCode = http.StatusText(resp.StatusCode)
}
result.ErrorMessage = apiResp.Message
if result.ErrorMessage == "" {
result.ErrorMessage = apiResp.Operation.Message
}
result.ErrorMessage = errorMessage
}
c.logger.Info("Monetix tokenization response",
@@ -288,25 +279,16 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
}
}
if apiResp.Operation.RequestID != "" {
result.ProviderRequestID = apiResp.Operation.RequestID
} else if apiResp.RequestID != "" {
result.ProviderRequestID = apiResp.RequestID
}
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
if result.ProviderStatus == "" {
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
}
result.ProviderRequestID = providerRequestID(apiResp)
result.ProviderStatus = providerStatus(apiResp)
if !result.Accepted {
result.ErrorCode = apiResp.Code
if result.ErrorCode == "" {
errorCode, errorMessage := providerError(apiResp)
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
result.ErrorCode = errorCode
if !result.Accepted && result.ErrorCode == "" {
result.ErrorCode = http.StatusText(resp.StatusCode)
}
result.ErrorMessage = apiResp.Message
if result.ErrorMessage == "" {
result.ErrorMessage = apiResp.Operation.Message
}
result.ErrorMessage = errorMessage
}
if responseLog != nil {
@@ -324,6 +306,32 @@ func normalizeExpiryYear(year int) int {
return year
}
func providerRequestID(resp APIResponse) string {
return firstNonEmpty(resp.Operation.RequestID, resp.RequestID)
}
func providerStatus(resp APIResponse) string {
return firstNonEmpty(resp.Status, resp.Operation.Status)
}
func providerError(resp APIResponse) (code, message string) {
return firstNonEmpty(resp.Code, resp.Operation.Code), firstNonEmpty(resp.Message, resp.Operation.Message)
}
func isProviderStatusError(status string) bool {
return strings.EqualFold(strings.TrimSpace(status), "error")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func normalizeRequestExpiryYear(req any) {
switch r := req.(type) {
case *CardPayoutRequest:

View File

@@ -175,6 +175,99 @@ func TestSendCardPayout_HTTPError(t *testing.T) {
}
}
func TestSendCardPayout_HTTPAcceptedBodyErrorStillAccepted(t *testing.T) {
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
body := `{"status":"error","code":"3062","message":"Payment details not received"}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
cfg := Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
}
client := NewClient(cfg, httpClient, zap.NewNop())
req := CardPayoutRequest{
General: General{ProjectID: 1, PaymentID: "payout-1"},
Customer: Customer{
ID: "cust-1",
FirstName: "Jane",
LastName: "Doe",
IP: "203.0.113.10",
},
Payment: Payment{Amount: 1000, Currency: "RUB"},
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
}
result, err := client.CreateCardPayout(context.Background(), req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !result.Accepted {
t.Fatalf("expected accepted response")
}
if result.ProviderStatus != "error" {
t.Fatalf("expected provider status error, got %q", result.ProviderStatus)
}
if result.ErrorCode != "3062" {
t.Fatalf("expected error code %q, got %q", "3062", result.ErrorCode)
}
if result.ErrorMessage != "Payment details not received" {
t.Fatalf("expected error message, got %q", result.ErrorMessage)
}
}
func TestSendCardPayout_HTTPErrorFallsBackToOperationCode(t *testing.T) {
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
body := `{"operation":{"code":"3061","message":"Transaction not found"}}`
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
cfg := Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
}
client := NewClient(cfg, httpClient, zap.NewNop())
req := CardPayoutRequest{
General: General{ProjectID: 1, PaymentID: "payout-1"},
Customer: Customer{
ID: "cust-1",
FirstName: "Jane",
LastName: "Doe",
IP: "203.0.113.10",
},
Payment: Payment{Amount: 1000, Currency: "RUB"},
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
}
result, err := client.CreateCardPayout(context.Background(), req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Accepted {
t.Fatalf("expected rejected response")
}
if result.ErrorCode != "3061" {
t.Fatalf("expected error code %q, got %q", "3061", result.ErrorCode)
}
if result.ErrorMessage != "Transaction not found" {
t.Fatalf("expected error message, got %q", result.ErrorMessage)
}
}
type errorReadCloser struct {
err error
}

Some files were not shown because too many files have changed in this diff Show More