Compare commits

63 Commits

Author SHA1 Message Date
Arseni
0aceb2f441 added missed loc instead of hardcode 2026-03-06 18:05:04 +03:00
Arseni
281b3834d3 wallet card redesign 2026-03-06 17:48:36 +03:00
Arseni
2b0ada1541 Merge remote-tracking branch 'origin/main' into SEND066
merge fresh main into SEND66
2026-03-06 17:18:39 +03: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
Arseni
b67d199427 Merge remote-tracking branch 'origin/main' into SEND066
merge lastest updates in main
2026-03-06 01:14:40 +03: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
Arseni
97b16542c2 ledger top up functionality and few small fixes for project architechture and design 2026-03-05 21:49:23 +03:00
Arseni
39c04beb21 Merge remote-tracking branch 'origin/main' into SEND066
merge main into SEND066
2026-03-05 21:12:43 +03: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
0da6078468 Merge pull request 'fixed treasury messages' (#667) from tg-666 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #667
2026-03-05 12:59:57 +00:00
Stephan D
3b65a2dc3a fixed treasury messages 2026-03-05 13:59:38 +01:00
Arseni
d6a3a0cc5b solyanka iz fix for payout page design, ledger wallet now clickable 2026-03-05 15:48:52 +03:00
a9b00b6871 Merge pull request 'fixed fee direction' (#665) from po-664 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #665
2026-03-05 12:27:19 +00:00
d64ad89072 Merge pull request 'missing asset in billing' (#663) from SEND065 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
Reviewed-on: #663
2026-03-05 12:25:53 +00:00
Stephan D
4a5e26b03a fixed fee direction 2026-03-05 13:24:41 +01:00
Arseni
d61eee99bc missing asset in billing 2026-03-05 15:02:52 +03:00
1e376da719 Merge pull request 'fixed icon path in billing' (#659) from SEND064 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is 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 failed
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/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #659
2026-03-05 11:35:37 +00:00
a8b0c70b65 Merge pull request 'fixed succcess operation matching' (#661) from po-660 into main
All checks were successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #661
2026-03-05 11:24:54 +00:00
Stephan D
8981d296c8 fixed succcess operation matching 2026-03-05 12:23:58 +01:00
Arseni
7e5a98acd7 fixed icon path in billing 2026-03-05 13:59:17 +03:00
8577239dd6 Merge pull request 'improved tgsettle messages + storage fixes' (#658) from tg-657 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #658
2026-03-05 10:54:28 +00:00
Stephan D
5e59fea7e5 improved tgsettle messages + storage fixes 2026-03-05 11:54:07 +01:00
801f349aa8 Merge pull request 'Fixed bot verbosity' (#656) from tg-655 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #656
2026-03-05 10:02:54 +00:00
Stephan D
d1e47841cc Fixed bot verbosity 2026-03-05 11:02:30 +01:00
364731a8c7 Merge pull request 'added download for operation and included fixes for source of payments' (#639) from SEND063 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #639
Reviewed-by: tech <tech.sendico@proton.me>
2026-03-05 08:29:45 +00:00
Arseni
519a2b1304 few fixes and made sure ledger widget displays the name of ledger wallet 2026-03-05 01:48:53 +03:00
d027f2deda Merge pull request 'Fixed po sending comission' (#648) from po-647 into main
All checks were successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #648
2026-03-04 22:22:02 +00:00
Stephan D
ba5a3312b5 Fixed po sending comission 2026-03-04 23:21:35 +01:00
f2c9685eb1 Merge pull request '/start command' (#646) from tg-643 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #646
2026-03-04 22:01:45 +00:00
Stephan D
e80cb3eed1 /start command 2026-03-04 23:01:21 +01:00
5f647904d7 Merge pull request 'Treasury bot + ledger fix' (#644) from tg-643 into main
All checks were successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #644
2026-03-04 19:02:21 +00:00
Stephan D
b6f05f52dc Treasury bot + ledger fix 2026-03-04 20:01:37 +01:00
75555520f3 Merge pull request 'fixed ledger account name propagation when creating ledger account' (#642) from ledger-614 into main
All checks were successful
ci/woodpecker/push/ledger Pipeline was successful
Reviewed-on: #642
2026-03-04 17:53:00 +00:00
Stephan D
d666c4ce51 fixed ledger account name propagation when creating ledger account 2026-03-04 18:52:43 +01:00
706a57e860 Merge pull request 'op payment info added' (#641) from bff-640 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #641
2026-03-04 17:03:11 +00:00
Stephan D
f7b0915303 op payment info added 2026-03-04 18:02:36 +01:00
Arseni
c59538869b updated document upload according to fresh api 2026-03-04 18:07:08 +03:00
Arseni
aff804ec58 SEND063 2026-03-04 17:43:18 +03:00
2bab8371b8 Merge pull request 'billing-637' (#638) from billing-637 into main
All checks were successful
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_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #638
2026-03-04 14:42:40 +00:00
Stephan D
af8ab8238e removeod obsolete file 2026-03-04 15:41:56 +01:00
Stephan D
92a6191014 document generation for ops 2026-03-04 15:41:28 +01:00
80b25a8608 Merge pull request 'added gateway and operation references' (#635) from bff-634 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #635
2026-03-04 12:55:46 +00:00
Stephan D
349e8afdc5 fixed operation ref description 2026-03-04 13:54:56 +01:00
Stephan D
8a1e44c038 removed strict mode from mntx 2026-03-04 13:52:56 +01:00
Stephan D
3fcbbfb08a added gateway and operation references 2026-03-04 13:51:48 +01:00
282 changed files with 20178 additions and 2081 deletions

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-tgsettle-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, mntx, tgsettle)"
@echo " make build-gateways Build gateway services (chain, tron, aurora, tgsettle)"
@echo " make build-api Build API services (notification, callbacks, bff)"
@echo " make build-frontend Build Flutter web frontend"
@echo ""
@@ -222,13 +246,28 @@ services-up:
dev-payments-methods \
dev-chain-gateway \
dev-tron-gateway \
dev-mntx-gateway \
dev-aurora-gateway \
dev-tgsettle-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
@@ -252,7 +291,7 @@ list-services:
@echo " - dev-payments-methods :50066, :9416 (Payment Methods)"
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
@echo " - dev-mntx-gateway :50075, :9405, :8084 (Card Payouts)"
@echo " - dev-aurora-gateway :50075, :9405, :8084 (Card Payouts Simulator)"
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
@echo " - dev-notification :8081 (Notifications)"
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
@@ -283,7 +322,7 @@ build-payments:
build-gateways:
@echo "$(GREEN)Building gateway services...$(NC)"
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-mntx-gateway dev-tgsettle-gateway
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-tgsettle-gateway
build-api:
@echo "$(GREEN)Building API services...$(NC)"

View File

@@ -24,6 +24,7 @@ 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 MNTX | `api/gateway/mntx/` | Card payouts |
| Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
| Notification | `api/notification/` | Notifications |
@@ -31,6 +32,16 @@ Financial services platform providing payment orchestration, ledger accounting,
| 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 Aurora for card-payout flows (`chain`, `tron`, `aurora`, `tgsettle`). The MNTX gateway codebase is retained separately for Monetix-specific integration.
## 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 +65,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 +75,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 +86,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, tgsettle
make build-api # notification, callbacks, bff
make build-frontend # Flutter web UI
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -26,7 +26,7 @@ documents:
issuer:
legal_name: "Sendico Ltd"
legal_address: "12 Market Street, London, UK"
logo_path: "/assets/logo.png"
logo_path: "assets/logo.png"
templates:
acceptance_path: "templates/acceptance.tpl"
protection:

View File

@@ -26,9 +26,9 @@ documents:
issuer:
legal_name: "Sendico Ltd"
legal_address: "12 Market Street, London, UK"
logo_path: "/assets/logo.png"
logo_path: "/app/assets/logo.png"
templates:
acceptance_path: "templates/acceptance.tpl"
acceptance_path: "/app/templates/acceptance.tpl"
protection:
owner_password: "sendico-documents"
storage:

View File

@@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -148,18 +147,17 @@ func (s *Service) Shutdown() {
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
start := time.Now()
var paymentRefs []string
paymentRefs := 0
if req != nil {
paymentRefs = req.GetPaymentRefs()
paymentRefs = len(req.GetPaymentRefs())
}
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
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(len(paymentRefs))
observeBatchSize(paymentRefs)
itemsCount := 0
if resp != nil {
@@ -181,80 +179,16 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
logger.Info("BatchResolveDocuments finished", fields...)
}()
if len(paymentRefs) == 0 {
resp = &documentsv1.BatchResolveDocumentsResponse{}
_ = ctx
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
return resp, nil
}
if s.storage == nil {
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
return nil, err
}
refs := make([]string, 0, len(paymentRefs))
for _, ref := range paymentRefs {
clean := strings.TrimSpace(ref)
if clean == "" {
continue
}
refs = append(refs, clean)
}
if len(refs) == 0 {
resp = &documentsv1.BatchResolveDocumentsResponse{}
return resp, nil
}
records, err := s.storage.Documents().ListByPaymentRefs(ctx, refs)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
recordByRef := map[string]*model.DocumentRecord{}
for _, record := range records {
if record == nil {
continue
}
recordByRef[record.PaymentRef] = record
}
items := make([]*documentsv1.DocumentMeta, 0, len(refs))
for _, ref := range refs {
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
if record := recordByRef[ref]; record != nil {
record.Normalize()
available := []model.DocumentType{model.DocumentTypeAct}
ready := make([]model.DocumentType, 0, 1)
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
ready = append(ready, model.DocumentTypeAct)
}
meta.AvailableTypes = toProtoTypes(available)
meta.ReadyTypes = toProtoTypes(ready)
}
items = append(items, meta)
}
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
return resp, nil
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())
@@ -293,92 +227,94 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
logger.Info("GetDocument finished", fields...)
}()
if paymentRef == "" {
err = status.Error(codes.InvalidArgument, "payment_ref is required")
_ = ctx
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
return nil, err
return nil, err
}
func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
start := time.Now()
organizationRef := ""
gatewayService := ""
operationRef := ""
if req != nil {
organizationRef = strings.TrimSpace(req.GetOrganizationRef())
gatewayService = strings.TrimSpace(req.GetGatewayService())
operationRef = strings.TrimSpace(req.GetOperationRef())
}
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
err = status.Error(codes.InvalidArgument, "document type is required")
logger := s.logger.With(
zap.String("organization_ref", organizationRef),
zap.String("gateway_service", gatewayService),
zap.String("operation_ref", operationRef),
)
return nil, err
}
defer func() {
statusLabel := statusFromError(err)
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
if s.storage == nil {
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
return nil, err
}
if s.docStore == nil {
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
return nil, err
}
if s.template == nil {
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
return nil, err
}
record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef)
if err != nil {
if errors.Is(err, storage.ErrDocumentNotFound) {
return nil, status.Error(codes.NotFound, "document record not found")
if resp != nil {
observeDocumentBytes(docType, len(resp.GetContent()))
}
return nil, status.Error(codes.Internal, err.Error())
}
record.Normalize()
targetType := model.DocumentTypeFromProto(docType)
if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT {
return nil, status.Error(codes.Unimplemented, "document type not implemented")
}
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
content, loadErr := s.docStore.Load(ctx, path)
if loadErr != nil {
return nil, status.Error(codes.Internal, loadErr.Error())
contentBytes := 0
if resp != nil {
contentBytes = len(resp.GetContent())
}
return &documentsv1.GetDocumentResponse{
Content: content,
Filename: documentFilename(docType, paymentRef),
MimeType: "application/pdf",
}, nil
fields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Int("content_bytes", contentBytes),
}
if err != nil {
logger.Warn("GetOperationDocument failed", append(fields, zap.Error(err))...)
return
}
logger.Info("GetOperationDocument finished", fields...)
}()
if req == nil {
err = status.Error(codes.InvalidArgument, "request is required")
return nil, err
}
content, hash, genErr := s.generateActPDF(record.Snapshot)
if organizationRef == "" {
err = status.Error(codes.InvalidArgument, "organization_ref is required")
return nil, err
}
if gatewayService == "" {
err = status.Error(codes.InvalidArgument, "gateway_service is required")
return nil, err
}
if operationRef == "" {
err = status.Error(codes.InvalidArgument, "operation_ref is required")
return nil, err
}
snapshot := operationSnapshotFromRequest(req)
content, _, genErr := s.generateOperationPDF(snapshot)
if genErr != nil {
logger.Warn("Failed to generate document", zap.Error(genErr))
err = status.Error(codes.Internal, genErr.Error())
return nil, status.Error(codes.Internal, genErr.Error())
}
path := documentStoragePath(paymentRef, docType)
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
logger.Warn("Failed to store document", zap.Error(saveErr))
return nil, status.Error(codes.Internal, saveErr.Error())
}
record.StoragePaths[targetType] = path
record.Hashes[targetType] = hash
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
logger.Warn("Failed to update document record", zap.Error(updateErr))
return nil, status.Error(codes.Internal, updateErr.Error())
return nil, err
}
resp = &documentsv1.GetDocumentResponse{
Content: content,
Filename: documentFilename(docType, paymentRef),
Filename: operationDocumentFilename(operationRef),
MimeType: "application/pdf",
}
@@ -392,7 +328,7 @@ func (s *Service) startDiscoveryAnnouncer() {
announce := discovery.Announcement{
Service: mservice.BillingDocuments,
Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet},
Operations: []string{discovery.OperationDocumentsGet},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
@@ -418,10 +354,19 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
return nil, "", err
}
return s.renderPDFWithIntegrity(blocks)
}
func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, string, error) {
return s.renderPDFWithIntegrity(buildOperationBlocks(snapshot))
}
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
generated := renderer.Renderer{
Issuer: s.config.Issuer,
OwnerPassword: s.config.Protection.OwnerPassword,
}
placeholder := strings.Repeat("0", 64)
firstPass, err := generated.Render(blocks, placeholder)
@@ -440,6 +385,157 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
return finalBytes, footerHex, nil
}
type operationSnapshot struct {
OrganizationRef string
GatewayService string
OperationRef string
PaymentRef string
OperationCode string
OperationLabel string
OperationState string
FailureCode string
FailureReason string
Amount string
Currency string
StartedAt time.Time
CompletedAt time.Time
}
func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest) operationSnapshot {
snapshot := operationSnapshot{
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
GatewayService: strings.TrimSpace(req.GetGatewayService()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
OperationCode: strings.TrimSpace(req.GetOperationCode()),
OperationLabel: strings.TrimSpace(req.GetOperationLabel()),
OperationState: strings.TrimSpace(req.GetOperationState()),
FailureCode: strings.TrimSpace(req.GetFailureCode()),
FailureReason: strings.TrimSpace(req.GetFailureReason()),
Amount: strings.TrimSpace(req.GetAmount()),
Currency: strings.TrimSpace(req.GetCurrency()),
}
if ts := req.GetStartedAtUnixMs(); ts > 0 {
snapshot.StartedAt = time.UnixMilli(ts).UTC()
}
if ts := req.GetCompletedAtUnixMs(); ts > 0 {
snapshot.CompletedAt = time.UnixMilli(ts).UTC()
}
return snapshot
}
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
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)},
}
if snapshot.Amount != "" || snapshot.Currency != "" {
rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
}
blocks := []renderer.Block{
{
Tag: renderer.TagTitle,
Lines: []string{"OPERATION BILLING DOCUMENT"},
},
{
Tag: renderer.TagSubtitle,
Lines: []string{"Gateway operation statement"},
},
{
Tag: renderer.TagMeta,
Lines: []string{
"Document Type: Operation",
},
},
{
Tag: renderer.TagSection,
Lines: []string{"OPERATION DETAILS"},
},
{
Tag: renderer.TagKV,
Rows: rows,
},
}
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
blocks = append(blocks,
renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}},
renderer.Block{
Tag: renderer.TagKV,
Rows: [][]string{
{"Failure Code", safeValue(snapshot.FailureCode)},
{"Failure Reason", safeValue(snapshot.FailureReason)},
},
},
)
}
return blocks
}
func formatSnapshotTime(value time.Time) string {
if value.IsZero() {
return "n/a"
}
return value.UTC().Format(time.RFC3339)
}
func safeValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "n/a"
}
return trimmed
}
func operationDocumentFilename(operationRef string) string {
clean := sanitizeFilenameComponent(operationRef)
if clean == "" {
clean = "operation"
}
return fmt.Sprintf("operation_%s.pdf", clean)
}
func sanitizeFilenameComponent(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
var b strings.Builder
b.Grow(len(trimmed))
for _, r := range trimmed {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return strings.Trim(b.String(), "_")
}
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
if len(types) == 0 {
return nil

View File

@@ -12,6 +12,8 @@ import (
"github.com/tech/sendico/billing/documents/storage/model"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type stubRepo struct {
@@ -94,9 +96,7 @@ func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
return s.blocks, nil
}
func TestGetDocument_IdempotentAndHashed(t *testing.T) {
ctx := context.Background()
func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
snapshot := model.ActSnapshot{
PaymentID: "PAY-123",
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
@@ -105,14 +105,6 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
Currency: "USD",
}
record := &model.DocumentRecord{
PaymentRef: "PAY-123",
Snapshot: snapshot,
}
documentsStore := &stubDocumentsStore{record: record}
repo := &stubRepo{store: documentsStore}
store := newMemDocStore()
tmpl := &stubTemplate{
blocks: []renderer.Block{
{Tag: renderer.TagTitle, Lines: []string{"ACT"}},
@@ -127,62 +119,47 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
},
}
svc := NewService(zap.NewNop(), repo, nil,
svc := NewService(zap.NewNop(), nil, nil,
WithConfig(cfg),
WithDocumentStore(store),
WithTemplateRenderer(tmpl),
)
resp1, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
PaymentRef: "PAY-123",
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
pdf1, hash1, err := svc.generateActPDF(snapshot)
if err != nil {
t.Fatalf("GetDocument first call: %v", err)
t.Fatalf("generateActPDF first call: %v", err)
}
if len(resp1.GetContent()) == 0 {
if len(pdf1) == 0 {
t.Fatalf("expected content on first call")
}
stored := record.Hashes[model.DocumentTypeAct]
if stored == "" {
t.Fatalf("expected stored hash")
if hash1 == "" {
t.Fatalf("expected non-empty hash on first call")
}
footerHash := extractFooterHash(resp1.GetContent())
footerHash := extractFooterHash(pdf1)
if footerHash == "" {
t.Fatalf("expected footer hash in PDF")
}
if stored != footerHash {
t.Fatalf("stored hash mismatch: got %s", stored)
if hash1 != footerHash {
t.Fatalf("stored hash mismatch: got %s", hash1)
}
resp2, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
PaymentRef: "PAY-123",
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
pdf2, hash2, err := svc.generateActPDF(snapshot)
if err != nil {
t.Fatalf("GetDocument second call: %v", err)
t.Fatalf("generateActPDF second call: %v", err)
}
if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) {
t.Fatalf("expected identical PDF bytes on second call")
if hash2 == "" {
t.Fatalf("expected non-empty hash on second call")
}
if tmpl.calls != 1 {
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
footerHash2 := extractFooterHash(pdf2)
if footerHash2 == "" {
t.Fatalf("expected footer hash in second PDF")
}
if store.saveCount != 1 {
t.Fatalf("expected document save once, got %d", store.saveCount)
}
if store.loadCount == 0 {
t.Fatalf("expected document load on second call")
if footerHash2 != hash2 {
t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
}
}
@@ -212,3 +189,48 @@ func extractFooterHash(pdf []byte) string {
func isHexDigit(b byte) bool {
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')
}
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
Issuer: renderer.Issuer{
LegalName: "Sendico Ltd",
},
}))
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
OrganizationRef: "org-1",
GatewayService: "chain_gateway",
OperationRef: "pay-1:step-1",
PaymentRef: "pay-1",
OperationCode: "crypto.transfer",
OperationLabel: "Outbound transfer",
OperationState: "completed",
Amount: "100.50",
Currency: "USDT",
StartedAtUnixMs: time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC).UnixMilli(),
})
if err != nil {
t.Fatalf("GetOperationDocument failed: %v", err)
}
if len(resp.GetContent()) == 0 {
t.Fatalf("expected non-empty PDF content")
}
if got, want := resp.GetMimeType(), "application/pdf"; got != want {
t.Fatalf("mime_type mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetFilename(), "operation_pay-1_step-1.pdf"; got != want {
t.Fatalf("filename mismatch: got=%q want=%q", got, want)
}
}
func TestGetOperationDocument_RequiresOperationRef(t *testing.T) {
svc := NewService(zap.NewNop(), nil, nil)
_, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
OrganizationRef: "org-1",
GatewayService: "chain_gateway",
})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got=%v err=%v", status.Code(err), err)
}
}

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

@@ -8,8 +8,10 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
@@ -68,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"`
@@ -77,14 +80,18 @@ type Payment struct {
}
type PaymentOperation struct {
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
Amount *paymenttypes.Money `json:"amount,omitempty"`
ConvertedAmount *paymenttypes.Money `json:"convertedAmount,omitempty"`
OperationRef string `json:"operationRef,omitempty"`
Gateway string `json:"gateway,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type paymentQuoteResponse struct {
@@ -283,11 +290,12 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil {
return nil
}
operations := toUserVisibleOperations(p.GetStepExecutions())
operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
failureCode, failureReason := firstFailure(operations)
return &Payment{
PaymentRef: p.GetPaymentRef(),
State: enumJSONName(p.GetState().String()),
Comment: strings.TrimSpace(p.GetIntentSnapshot().GetComment()),
FailureCode: failureCode,
FailureReason: failureReason,
Operations: operations,
@@ -308,7 +316,7 @@ func firstFailure(operations []PaymentOperation) (string, string) {
return "", ""
}
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation {
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) []PaymentOperation {
if len(steps) == 0 {
return nil
}
@@ -317,7 +325,7 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
continue
}
ops = append(ops, toPaymentOperation(step))
ops = append(ops, toPaymentOperation(step, quote))
}
if len(ops) == 0 {
return nil
@@ -325,14 +333,20 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
return ops
}
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
func toPaymentOperation(step *orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) PaymentOperation {
operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs())
amount, convertedAmount := operationAmounts(step.GetStepCode(), quote)
op := PaymentOperation{
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
Amount: amount,
ConvertedAmount: convertedAmount,
OperationRef: operationRef,
Gateway: string(gateway),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
}
failure := step.GetFailure()
if failure == nil {
@@ -346,6 +360,165 @@ func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
return op
}
func operationAmounts(stepCode string, quote *quotationv2.PaymentQuote) (*paymenttypes.Money, *paymenttypes.Money) {
if quote == nil {
return nil, nil
}
operation := stepOperationToken(stepCode)
primary := firstValidMoney(
toMoney(quote.GetDestinationAmount()),
toMoney(quote.GetTransferPrincipalAmount()),
toMoney(quote.GetPayerTotalDebitAmount()),
)
if operation != "fx_convert" {
return primary, nil
}
base := firstValidMoney(
toMoney(quote.GetTransferPrincipalAmount()),
toMoney(quote.GetPayerTotalDebitAmount()),
toMoney(quote.GetFxQuote().GetBaseAmount()),
)
quoteAmount := firstValidMoney(
toMoney(quote.GetDestinationAmount()),
toMoney(quote.GetFxQuote().GetQuoteAmount()),
)
return base, quoteAmount
}
func stepOperationToken(stepCode string) string {
parts := strings.Split(strings.ToLower(strings.TrimSpace(stepCode)), ".")
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[len(parts)-1])
}
func firstValidMoney(values ...*paymenttypes.Money) *paymenttypes.Money {
for _, value := range values {
if value == nil {
continue
}
if strings.TrimSpace(value.GetAmount()) == "" || strings.TrimSpace(value.GetCurrency()) == "" {
continue
}
return value
}
return nil
}
const (
externalRefKindOperation = "operation_ref"
)
func operationRefAndGateway(stepCode string, refs []*orchestrationv2.ExternalReference) (string, mservice.Type) {
var (
operationRef string
gateway mservice.Type
)
for _, ref := range refs {
if ref == nil {
continue
}
kind := strings.ToLower(strings.TrimSpace(ref.GetKind()))
value := strings.TrimSpace(ref.GetRef())
candidateGateway := inferGatewayType(ref.GetGatewayInstanceId(), ref.GetRail(), stepCode)
if kind == externalRefKindOperation && operationRef == "" && value != "" {
operationRef = value
}
if gateway == "" && candidateGateway != "" {
gateway = candidateGateway
}
}
if gateway == "" {
gateway = inferGatewayType("", gatewayv1.Rail_RAIL_UNSPECIFIED, stepCode)
}
return operationRef, gateway
}
func inferGatewayType(gatewayInstanceID string, rail gatewayv1.Rail, stepCode string) mservice.Type {
if gateway := gatewayTypeFromInstanceID(gatewayInstanceID); gateway != "" {
return gateway
}
if gateway := gatewayTypeFromRail(rail); gateway != "" {
return gateway
}
return gatewayTypeFromStepCode(stepCode)
}
func gatewayTypeFromInstanceID(raw string) mservice.Type {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
switch mservice.Type(value) {
case mservice.ChainGateway, mservice.TronGateway, mservice.MntxGateway, mservice.PaymentGateway, mservice.TgSettle, mservice.Ledger:
return mservice.Type(value)
}
switch {
case strings.Contains(value, "ledger"):
return mservice.Ledger
case strings.Contains(value, "tgsettle"):
return mservice.TgSettle
case strings.Contains(value, "payment_gateway"),
strings.Contains(value, "settlement"),
strings.Contains(value, "onramp"),
strings.Contains(value, "offramp"):
return mservice.PaymentGateway
case strings.Contains(value, "mntx"), strings.Contains(value, "mcards"):
return mservice.MntxGateway
case strings.Contains(value, "tron"):
return mservice.TronGateway
case strings.Contains(value, "chain"), strings.Contains(value, "crypto"):
return mservice.ChainGateway
case strings.Contains(value, "card"):
return mservice.MntxGateway
default:
return ""
}
}
func gatewayTypeFromRail(rail gatewayv1.Rail) mservice.Type {
switch rail {
case gatewayv1.Rail_RAIL_LEDGER:
return mservice.Ledger
case gatewayv1.Rail_RAIL_CARD:
return mservice.MntxGateway
case gatewayv1.Rail_RAIL_SETTLEMENT, gatewayv1.Rail_RAIL_ONRAMP, gatewayv1.Rail_RAIL_OFFRAMP:
return mservice.PaymentGateway
case gatewayv1.Rail_RAIL_CRYPTO:
return mservice.ChainGateway
default:
return ""
}
}
func gatewayTypeFromStepCode(stepCode string) mservice.Type {
code := strings.ToLower(strings.TrimSpace(stepCode))
switch {
case strings.Contains(code, "ledger"):
return mservice.Ledger
case strings.Contains(code, "card_payout"), strings.Contains(code, ".card."):
return mservice.MntxGateway
case strings.Contains(code, "provider_settlement"),
strings.Contains(code, "settlement"),
strings.Contains(code, "fx_convert"),
strings.Contains(code, "onramp"),
strings.Contains(code, "offramp"):
return mservice.PaymentGateway
case strings.Contains(code, "crypto"), strings.Contains(code, "chain"):
return mservice.ChainGateway
default:
return ""
}
}
func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
switch visibility {
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,

View File

@@ -3,6 +3,8 @@ package sresponse
import (
"testing"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
@@ -32,7 +34,7 @@ func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
},
}
ops := toUserVisibleOperations(steps)
ops := toUserVisibleOperations(steps, nil)
if len(ops) != 2 {
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
}
@@ -119,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",
@@ -134,3 +152,118 @@ func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_MapsOperationRefAndGateway(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-1",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Refs: []*orchestrationv2.ExternalReference{
{
Rail: gatewayv1.Rail_RAIL_CARD,
GatewayInstanceId: "mcards",
Kind: "operation_ref",
Ref: "op-123",
},
},
}, nil)
if got, want := op.OperationRef, "op-123"; got != want {
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := op.Gateway, "mntx_gateway"; got != want {
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_InfersGatewayFromStepCode(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-2",
StepCode: "edge.1_2.ledger.debit",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, nil)
if got := op.OperationRef; got != "" {
t.Fatalf("expected empty operation_ref, got=%q", got)
}
if got, want := op.Gateway, "ledger"; got != want {
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_DoesNotFallbackToCardPayoutRef(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-3",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Refs: []*orchestrationv2.ExternalReference{
{
Rail: gatewayv1.Rail_RAIL_CARD,
GatewayInstanceId: "mcards",
Kind: "card_payout_ref",
Ref: "payout-123",
},
},
}, nil)
if got := op.OperationRef; got != "" {
t.Fatalf("expected empty operation_ref, got=%q", got)
}
if got, want := op.Gateway, "mntx_gateway"; got != want {
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_MapsAmount(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-4",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, &quotationv2.PaymentQuote{
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
})
if op.Amount == nil {
t.Fatal("expected amount to be mapped")
}
if got, want := op.Amount.Amount, "100.00"; got != want {
t.Fatalf("amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "EUR"; got != want {
t.Fatalf("amount.currency mismatch: got=%q want=%q", got, want)
}
if got := op.ConvertedAmount; got != nil {
t.Fatalf("expected no converted_amount for non-fx operation, got=%+v", got)
}
}
func TestToPaymentOperation_MapsFxTwoAmounts(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-5",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, &quotationv2.PaymentQuote{
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
})
if op.Amount == nil {
t.Fatal("expected fx base amount to be mapped")
}
if got, want := op.Amount.Amount, "110.00"; got != want {
t.Fatalf("base amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "USDT"; got != want {
t.Fatalf("base amount.currency mismatch: got=%q want=%q", got, want)
}
if op.ConvertedAmount == nil {
t.Fatal("expected fx converted amount to be mapped")
}
if got, want := op.ConvertedAmount.Amount, "100.00"; got != want {
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.ConvertedAmount.Currency, "EUR"; got != want {
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
}
}

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,26 +354,47 @@ func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organi
}
}
resp, err := a.ledgerClient.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: org.ID.Hex(),
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: currency,
Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE,
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,
},
Describable: describable,
})
if err != nil {
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org))
return err
}
if resp == nil || resp.GetAccount() == nil || strings.TrimSpace(resp.GetAccount().GetLedgerAccountRef()) == "" {
return merrors.Internal("ledger returned empty account reference")
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,
Currency: currency,
Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE,
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,
},
Describable: describable,
})
if err != nil {
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("currency", currency),
zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
}
a.logger.Info("Ledger account created for organization", mzap.StorableRef(org), 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,22 +65,26 @@ 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())
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())
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",
}, 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)
}
})

View File

@@ -15,6 +15,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -23,43 +24,90 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
const (
documentsServiceName = "BILLING_DOCUMENTS"
documentsOperationGet = discovery.OperationDocumentsGet
documentsDialTimeout = 5 * time.Second
documentsCallTimeout = 10 * time.Second
gatewayCallTimeout = 10 * time.Second
)
func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
var allowedOperationGatewayServices = map[mservice.Type]struct{}{
mservice.ChainGateway: {},
mservice.TronGateway: {},
mservice.MntxGateway: {},
mservice.PaymentGateway: {},
mservice.TgSettle: {},
}
func (a *PaymentAPI) getOperationDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
orgRef, denied := a.authorizeDocumentDownload(r, account)
if denied != nil {
return denied
}
query := r.URL.Query()
gatewayService := normalizeGatewayService(query.Get("gateway_service"))
if gatewayService == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "gateway_service is required")
}
if _, ok := allowedOperationGatewayServices[gatewayService]; !ok {
return response.BadRequest(a.logger, a.Name(), "invalid_parameter", "unsupported gateway_service")
}
operationRef := strings.TrimSpace(query.Get("operation_ref"))
if operationRef == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "operation_ref is required")
}
service, gateway, h := a.resolveOperationDocumentDeps(r.Context(), gatewayService)
if h != nil {
return h
}
op, err := a.fetchGatewayOperation(r.Context(), gateway.InvokeURI, operationRef)
if err != nil {
a.logger.Warn("Failed to fetch gateway operation for document generation", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
return documentErrorResponse(a.logger, a.Name(), err)
}
req := operationDocumentRequest(orgRef.Hex(), gatewayService, operationRef, op)
docResp, err := a.fetchOperationDocument(r.Context(), service.InvokeURI, req)
if err != nil {
a.logger.Warn("Failed to fetch operation document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
return documentErrorResponse(a.logger, a.Name(), err)
}
return operationDocumentResponse(a.logger, a.Name(), docResp, fmt.Sprintf("operation_%s.pdf", sanitizeFilenameComponent(operationRef)))
}
func (a *PaymentAPI) authorizeDocumentDownload(r *http.Request, account *model.Account) (bson.ObjectID, http.HandlerFunc) {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
return bson.NilObjectID, response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
return bson.NilObjectID, response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when downloading act", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
a.logger.Debug("Access denied when downloading document", mutil.PLog(a.oph, r))
return bson.NilObjectID, response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
}
paymentRef := strings.TrimSpace(r.URL.Query().Get("payment_ref"))
if paymentRef == "" {
paymentRef = strings.TrimSpace(r.URL.Query().Get("paymentRef"))
}
if paymentRef == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "payment_ref is required")
}
return orgRef, nil
}
func (a *PaymentAPI) resolveOperationDocumentDeps(ctx context.Context, gatewayService mservice.Type) (*discovery.ServiceSummary, *discovery.GatewaySummary, http.HandlerFunc) {
if a.discovery == nil {
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
}
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
@@ -68,27 +116,35 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *
lookupResp, err := a.discovery.Lookup(lookupCtx)
if err != nil {
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
return nil, nil, response.Auto(a.logger, a.Name(), err)
}
service := findDocumentsService(lookupResp.Services)
if service == nil {
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
}
docResp, err := a.fetchActDocument(ctx, service.InvokeURI, paymentRef)
if err != nil {
a.logger.Warn("Failed to fetch act document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return documentErrorResponse(a.logger, a.Name(), err)
gateway := findGatewayForService(lookupResp.Gateways, gatewayService)
if gateway == nil {
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "gateway service unavailable")
}
if len(docResp.GetContent()) == 0 {
return response.Error(a.logger, a.Name(), http.StatusInternalServerError, "empty_document", "document service returned empty payload")
return service, gateway, nil
}
func operationDocumentResponse(logger mlogger.Logger, source mservice.Type, docResp *documentsv1.GetDocumentResponse, fallbackFilename string) http.HandlerFunc {
if docResp == nil || len(docResp.GetContent()) == 0 {
return response.Error(logger, source, http.StatusInternalServerError, "empty_document", "document service returned empty payload")
}
filename := strings.TrimSpace(docResp.GetFilename())
if filename == "" {
filename = fmt.Sprintf("act_%s.pdf", paymentRef)
filename = strings.TrimSpace(fallbackFilename)
}
if filename == "" {
filename = "document.pdf"
}
mimeType := strings.TrimSpace(docResp.GetMimeType())
if mimeType == "" {
mimeType = "application/pdf"
@@ -98,13 +154,67 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
if _, writeErr := w.Write(docResp.GetContent()); writeErr != nil {
a.logger.Warn("Failed to write document response", zap.Error(writeErr))
if _, err := w.Write(docResp.GetContent()); err != nil {
logger.Warn("Failed to write document response", zap.Error(err))
}
}
}
func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) {
func normalizeGatewayService(raw string) mservice.Type {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
switch value {
case string(mservice.ChainGateway):
return mservice.ChainGateway
case string(mservice.TronGateway):
return mservice.TronGateway
case string(mservice.MntxGateway):
return mservice.MntxGateway
case string(mservice.PaymentGateway):
return mservice.PaymentGateway
case string(mservice.TgSettle):
return mservice.TgSettle
default:
return ""
}
}
func sanitizeFilenameComponent(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
var b strings.Builder
b.Grow(len(trimmed))
for _, r := range trimmed {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
clean := strings.Trim(b.String(), "_")
if clean == "" {
return "operation"
}
return clean
}
func (a *PaymentAPI) fetchOperationDocument(ctx context.Context, invokeURI string, req *documentsv1.GetOperationDocumentRequest) (*documentsv1.GetDocumentResponse, error) {
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, merrors.InternalWrap(err, "dial billing documents")
@@ -116,10 +226,160 @@ func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef
callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout)
defer callCancel()
return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{
PaymentRef: paymentRef,
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
return client.GetOperationDocument(callCtx, req)
}
func (a *PaymentAPI) fetchGatewayOperation(ctx context.Context, invokeURI, operationRef string) (*connectorv1.Operation, error) {
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, merrors.InternalWrap(err, "dial gateway connector")
}
defer conn.Close()
client := connectorv1.NewConnectorServiceClient(conn)
callCtx, callCancel := context.WithTimeout(ctx, gatewayCallTimeout)
defer callCancel()
resp, err := client.GetOperation(callCtx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(operationRef)})
if err != nil {
return nil, err
}
op := resp.GetOperation()
if op == nil {
return nil, merrors.NoData("gateway returned empty operation payload")
}
return op, nil
}
func findGatewayForService(gateways []discovery.GatewaySummary, gatewayService mservice.Type) *discovery.GatewaySummary {
candidates := make([]discovery.GatewaySummary, 0, len(gateways))
for _, gw := range gateways {
if !gw.Healthy || strings.TrimSpace(gw.InvokeURI) == "" {
continue
}
rail := discovery.NormalizeRail(gw.Rail)
network := strings.ToLower(strings.TrimSpace(gw.Network))
switch gatewayService {
case mservice.MntxGateway:
if rail == discovery.NormalizeRail(discovery.RailCardPayout) {
candidates = append(candidates, gw)
}
case mservice.PaymentGateway, mservice.TgSettle:
if rail == discovery.NormalizeRail(discovery.RailProviderSettlement) {
candidates = append(candidates, gw)
}
case mservice.TronGateway:
if rail == discovery.NormalizeRail(discovery.RailCrypto) && strings.Contains(network, "tron") {
candidates = append(candidates, gw)
}
case mservice.ChainGateway:
if rail == discovery.NormalizeRail(discovery.RailCrypto) && !strings.Contains(network, "tron") {
candidates = append(candidates, gw)
}
}
}
if len(candidates) == 0 && gatewayService == mservice.ChainGateway {
for _, gw := range gateways {
if gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" && discovery.NormalizeRail(gw.Rail) == discovery.NormalizeRail(discovery.RailCrypto) {
candidates = append(candidates, gw)
}
}
}
if len(candidates) == 0 {
return nil
}
best := candidates[0]
for _, candidate := range candidates[1:] {
if candidate.RoutingPriority > best.RoutingPriority {
best = candidate
}
}
return &best
}
func operationDocumentRequest(organizationRef string, gatewayService mservice.Type, requestedOperationRef string, op *connectorv1.Operation) *documentsv1.GetOperationDocumentRequest {
req := &documentsv1.GetOperationDocumentRequest{
OrganizationRef: strings.TrimSpace(organizationRef),
GatewayService: string(gatewayService),
OperationRef: firstNonEmpty(strings.TrimSpace(op.GetOperationRef()), strings.TrimSpace(requestedOperationRef)),
OperationCode: strings.TrimSpace(op.GetType().String()),
OperationLabel: operationLabel(op.GetType()),
OperationState: strings.TrimSpace(op.GetStatus().String()),
Amount: strings.TrimSpace(op.GetMoney().GetAmount()),
Currency: strings.TrimSpace(op.GetMoney().GetCurrency()),
}
if ts := op.GetCreatedAt(); ts != nil {
req.StartedAtUnixMs = ts.AsTime().UnixMilli()
}
if ts := op.GetUpdatedAt(); ts != nil {
req.CompletedAtUnixMs = ts.AsTime().UnixMilli()
}
req.PaymentRef = operationParamValue(op.GetParams(), "payment_ref", "parent_payment_ref", "paymentRef", "parentPaymentRef")
req.FailureCode = firstNonEmpty(
operationParamValue(op.GetParams(), "failure_code", "provider_code", "error_code"),
failureCodeFromStatus(op.GetStatus()),
)
req.FailureReason = operationParamValue(op.GetParams(), "failure_reason", "provider_message", "error", "message")
return req
}
func operationLabel(opType connectorv1.OperationType) string {
switch opType {
case connectorv1.OperationType_CREDIT:
return "Credit"
case connectorv1.OperationType_DEBIT:
return "Debit"
case connectorv1.OperationType_TRANSFER:
return "Transfer"
case connectorv1.OperationType_PAYOUT:
return "Payout"
case connectorv1.OperationType_FEE_ESTIMATE:
return "Fee Estimate"
case connectorv1.OperationType_FX:
return "FX"
case connectorv1.OperationType_GAS_TOPUP:
return "Gas Top Up"
default:
return strings.TrimSpace(opType.String())
}
}
func failureCodeFromStatus(status connectorv1.OperationStatus) string {
switch status {
case connectorv1.OperationStatus_OPERATION_FAILED, connectorv1.OperationStatus_OPERATION_CANCELLED:
return strings.TrimSpace(status.String())
default:
return ""
}
}
func operationParamValue(params *structpb.Struct, keys ...string) string {
if params == nil {
return ""
}
values := params.AsMap()
for _, key := range keys {
raw, ok := values[key]
if !ok {
continue
}
if text := strings.TrimSpace(fmt.Sprint(raw)); text != "" && text != "<nil>" {
return text
}
}
return ""
}
func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary {

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

@@ -106,7 +106,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/act"), api.Get, p.getActDocument)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/operation"), api.Get, p.getOperationDocument)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry"), api.Get, p.listDiscoveryRegistry)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
@@ -207,7 +207,7 @@ type grpcQuotationClient struct {
callTimeout time.Duration
}
func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
func newQuotationClient(_ context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("payment quotation: address is required")

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

5
api/gateway/aurora/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,28 @@
# Aurora Gateway Simulated Card Payouts
Aurora is a dev/test-only card payout gateway with the same gRPC contract as `mntx`, but it never sends real funds.
## Runtime entry points
- gRPC: `MntxGatewayService.CreateCardPayout`, `CreateCardTokenPayout`, `CreateCardToken`, `GetCardPayoutStatus`, `ListGatewayInstances`
- Callback HTTP server (optional): `:8084/aurora/callback`
- Metrics: Prometheus on `:9405/metrics`
## Behavior
- Card payouts are resolved locally by PAN scenario mapping.
- Token payouts resolve the scenario from the tokenized PAN (or fallback to masked PAN last4).
- No outbound payout/tokenization HTTP calls are made.
## Built-in test cards
- `2200001111111111`: approved instantly (`success`, code `00`)
- `2200002222222222`: pending issuer review (`waiting`, code `P01`)
- `2200003333333333`: insufficient funds (`failed`, code `51`)
- `2200004444444444`: issuer unavailable retryable (`failed`, code `10101`)
- `2200005555555555`: stolen card (`failed`, code `43`)
- `2200006666666666`: do not honor (`failed`, code `05`)
- `2200007777777777`: expired card (`failed`, code `54`)
- any other PAN: default queued processing (`waiting`, code `P00`)
## Notes
- PAN is masked in logs.
- Provider settings should be configured under `aurora:` (legacy `mcards:` key is still accepted for backward compatibility).
- `gateway.id` defaults to `mcards` to preserve orchestrator compatibility.

View File

@@ -0,0 +1,37 @@
# Aurora Test Card Scenarios
Aurora is a simulated card payout gateway for dev/test.
It does not move real funds; results are determined by PAN scenario mapping.
## Status/response fields
- `accepted`: whether submit is accepted by the gateway workflow
- `status`: payout state returned/stored (`SUCCESS`, `WAITING`, `FAILED`)
- `code`: simulated provider code
- `message`: simulated provider message
## PAN scenarios
| PAN | Scenario | accepted | status | code | message |
|---|---|---:|---|---|---|
| `2200001111111111` | approved_instant | `true` | `SUCCESS` | `00` | Approved by issuer |
| `2200002222222222` | pending_issuer_review | `true` | `WAITING` | `P01` | Pending issuer review |
| `2200003333333333` | insufficient_funds | `false` | `FAILED` | `51` | Insufficient funds |
| `2200004444444444` | issuer_unavailable_retryable | `false` on provider response, but submit is retried | starts `WAITING`, may end `FAILED` after retries | `10101` | Issuer temporary unavailable, retry later |
| `2200005555555555` | stolen_card | `false` | `FAILED` | `43` | Stolen card, pickup |
| `2200006666666666` | do_not_honor | `false` | `FAILED` | `05` | Do not honor |
| `2200007777777777` | expired_card | `false` | `FAILED` | `54` | Expired card |
| `2200008888888888` | provider_timeout_transport | transport failure (no provider acceptance) | starts `WAITING` (retry scheduled), may end `FAILED` | n/a (transport error path) | provider timeout while calling payout endpoint |
| `2200009999999998` | provider_unreachable_transport | transport failure (no provider acceptance) | starts `WAITING` (retry scheduled), may end `FAILED` | n/a (transport error path) | provider host unreachable |
| `2200009999999997` | provider_maintenance | `false` | `FAILED` | `91` | Issuer or switch is inoperative |
| `2200009999999996` | provider_system_malfunction | `false` | `FAILED` | `96` | System malfunction |
## Default behavior
- Any PAN not listed above -> `default_processing`
- `accepted=true`
- `status=WAITING`
- `code=P00`
- `message=Queued for provider processing`
## Token payout behavior
- If payout uses a known Aurora token, scenario is resolved from the PAN used during tokenization.
- If token is unknown, Aurora falls back to `masked_pan` last4 matching when available.

View File

@@ -0,0 +1,403 @@
package client
import (
"context"
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model/account_role"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
)
// Client wraps the Aurora gateway gRPC API.
type Client interface {
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
Close() error
}
type grpcConnectorClient interface {
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
}
type gatewayClient struct {
conn *grpc.ClientConn
client grpcConnectorClient
cfg Config
logger mlogger.Logger
}
// New dials the Aurora gateway.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("aurora: address is required")
}
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
dialOpts = append(dialOpts, opts...)
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.Internal("aurora: dial failed: " + err.Error())
}
return &gatewayClient{
conn: conn,
client: connectorv1.NewConnectorServiceClient(conn),
cfg: cfg,
logger: cfg.Logger,
}, nil
}
func (g *gatewayClient) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
timeout := g.cfg.CallTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
if g.logger != nil {
fields := []zap.Field{
zap.String("method", method),
zap.Duration("timeout", timeout),
}
if deadline, ok := ctx.Deadline(); ok {
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
}
g.logger.Info("Aurora gateway client call timeout applied", fields...)
}
return context.WithTimeout(ctx, timeout)
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx, "CreateCardPayout")
defer cancel()
operation, err := operationFromCardPayout(req)
if err != nil {
return nil, err
}
resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil
}
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
defer cancel()
operation, err := operationFromTokenPayout(req)
if err != nil {
return nil, err
}
resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil
}
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
defer cancel()
if req == nil || strings.TrimSpace(req.GetPayoutId()) == "" {
return nil, merrors.InvalidArgument("aurora: payout_id is required")
}
resp, err := g.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetPayoutId())})
if err != nil {
return nil, err
}
return &mntxv1.GetCardPayoutStatusResponse{Payout: payoutFromOperation(resp.GetOperation())}, nil
}
func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
return nil, merrors.NotImplemented("aurora: ListGatewayInstances not supported via connector")
}
func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("aurora: request is required")
}
params := payoutParamsFromCard(req)
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId())
idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef)
op := &connectorv1.Operation{
Type: connectorv1.OperationType_PAYOUT,
IdempotencyKey: idempotencyKey,
OperationRef: operationRef,
IntentRef: strings.TrimSpace(req.GetIntentRef()),
Money: money,
Params: structFromMap(params),
}
setOperationRolesFromMetadata(op, req.GetMetadata())
return op, nil
}
func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("aurora: request is required")
}
params := payoutParamsFromToken(req)
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId())
idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef)
op := &connectorv1.Operation{
Type: connectorv1.OperationType_PAYOUT,
IdempotencyKey: idempotencyKey,
OperationRef: operationRef,
IntentRef: strings.TrimSpace(req.GetIntentRef()),
Money: money,
Params: structFromMap(params),
}
setOperationRolesFromMetadata(op, req.GetMetadata())
return op, nil
}
func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[string]string) {
if op == nil || len(metadata) == 0 {
return
}
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyFromRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.FromRole = account_role.ToProto(role)
}
}
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyToRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.ToRole = account_role.ToProto(role)
}
}
}
func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} {
metadata := sanitizeMetadata(req.GetMetadata())
params := map[string]interface{}{
"project_id": req.GetProjectId(),
"parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()),
"customer_id": strings.TrimSpace(req.GetCustomerId()),
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
"customer_last_name": strings.TrimSpace(req.GetCustomerLastName()),
"customer_ip": strings.TrimSpace(req.GetCustomerIp()),
"customer_zip": strings.TrimSpace(req.GetCustomerZip()),
"customer_country": strings.TrimSpace(req.GetCustomerCountry()),
"customer_state": strings.TrimSpace(req.GetCustomerState()),
"customer_city": strings.TrimSpace(req.GetCustomerCity()),
"customer_address": strings.TrimSpace(req.GetCustomerAddress()),
"amount_minor": req.GetAmountMinor(),
"currency": strings.TrimSpace(req.GetCurrency()),
"card_pan": strings.TrimSpace(req.GetCardPan()),
"card_exp_year": req.GetCardExpYear(),
"card_exp_month": req.GetCardExpMonth(),
"card_holder": strings.TrimSpace(req.GetCardHolder()),
}
if len(metadata) > 0 {
params["metadata"] = mapStringToInterface(metadata)
}
return params
}
func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} {
metadata := sanitizeMetadata(req.GetMetadata())
params := map[string]interface{}{
"project_id": req.GetProjectId(),
"parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()),
"customer_id": strings.TrimSpace(req.GetCustomerId()),
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
"customer_last_name": strings.TrimSpace(req.GetCustomerLastName()),
"customer_ip": strings.TrimSpace(req.GetCustomerIp()),
"customer_zip": strings.TrimSpace(req.GetCustomerZip()),
"customer_country": strings.TrimSpace(req.GetCustomerCountry()),
"customer_state": strings.TrimSpace(req.GetCustomerState()),
"customer_city": strings.TrimSpace(req.GetCustomerCity()),
"customer_address": strings.TrimSpace(req.GetCustomerAddress()),
"amount_minor": req.GetAmountMinor(),
"currency": strings.TrimSpace(req.GetCurrency()),
"card_token": strings.TrimSpace(req.GetCardToken()),
"card_holder": strings.TrimSpace(req.GetCardHolder()),
"masked_pan": strings.TrimSpace(req.GetMaskedPan()),
}
if len(metadata) > 0 {
params["metadata"] = mapStringToInterface(metadata)
}
return params
}
func moneyFromMinor(amount int64, currency string) *moneyv1.Money {
if amount <= 0 {
return nil
}
dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100))
return &moneyv1.Money{
Amount: dec.StringFixed(2),
Currency: strings.ToUpper(strings.TrimSpace(currency)),
}
}
func payoutFromReceipt(payoutID, operationRef, parentPaymentRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState {
state := &mntxv1.CardPayoutState{
PayoutId: fallbackNonEmpty(operationRef, payoutID),
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
}
if receipt == nil {
return state
}
if opID := strings.TrimSpace(receipt.GetOperationId()); opID != "" {
state.PayoutId = opID
}
state.Status = payoutStatusFromOperation(receipt.GetStatus())
state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef())
return state
}
func fallbackNonEmpty(values ...string) string {
for _, value := range values {
clean := strings.TrimSpace(value)
if clean != "" {
return clean
}
}
return ""
}
func sanitizeMetadata(source map[string]string) map[string]string {
if len(source) == 0 {
return nil
}
out := map[string]string{}
for key, value := range source {
k := strings.TrimSpace(key)
if k == "" {
continue
}
out[k] = strings.TrimSpace(value)
}
if len(out) == 0 {
return nil
}
return out
}
func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState {
if op == nil {
return nil
}
state := &mntxv1.CardPayoutState{
PayoutId: strings.TrimSpace(op.GetOperationId()),
Status: payoutStatusFromOperation(op.GetStatus()),
ProviderPaymentId: strings.TrimSpace(op.GetProviderRef()),
}
if money := op.GetMoney(); money != nil {
state.Currency = strings.TrimSpace(money.GetCurrency())
state.AmountMinor = minorFromMoney(money)
}
return state
}
func minorFromMoney(m *moneyv1.Money) int64 {
if m == nil {
return 0
}
amount := strings.TrimSpace(m.GetAmount())
if amount == "" {
return 0
}
dec, err := decimal.NewFromString(amount)
if err != nil {
return 0
}
return dec.Mul(decimal.NewFromInt(100)).IntPart()
}
func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus {
switch status {
case connectorv1.OperationStatus_OPERATION_CREATED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
case connectorv1.OperationStatus_OPERATION_WAITING:
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
case connectorv1.OperationStatus_OPERATION_SUCCESS:
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
case connectorv1.OperationStatus_OPERATION_FAILED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case connectorv1.OperationStatus_OPERATION_CANCELLED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
default:
return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED
}
}
func connectorError(err *connectorv1.ConnectorError) error {
if err == nil {
return nil
}
msg := strings.TrimSpace(err.GetMessage())
switch err.GetCode() {
case connectorv1.ErrorCode_INVALID_PARAMS:
return merrors.InvalidArgument(msg)
case connectorv1.ErrorCode_NOT_FOUND:
return merrors.NoData(msg)
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
return merrors.NotImplemented(msg)
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
return merrors.Internal(msg)
default:
return merrors.Internal(msg)
}
}
func structFromMap(data map[string]interface{}) *structpb.Struct {
if len(data) == 0 {
return nil
}
result, err := structpb.NewStruct(data)
if err != nil {
return nil
}
return result
}
func mapStringToInterface(input map[string]string) map[string]interface{} {
if len(input) == 0 {
return nil
}
out := make(map[string]interface{}, len(input))
for k, v := range input {
out[k] = v
}
return out
}

View File

@@ -0,0 +1,28 @@
package client
import (
"time"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Config holds Aurora gateway client settings.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
Logger mlogger.Logger
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 10 * time.Second
}
if c.Logger == nil {
c.Logger = zap.NewNop()
}
}

View File

@@ -0,0 +1,45 @@
package client
import (
"context"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
ListGatewayInstancesFn func(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
}
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if f.CreateCardPayoutFn != nil {
return f.CreateCardPayoutFn(ctx, req)
}
return &mntxv1.CardPayoutResponse{}, nil
}
func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
if f.CreateCardTokenPayoutFn != nil {
return f.CreateCardTokenPayoutFn(ctx, req)
}
return &mntxv1.CardTokenPayoutResponse{}, nil
}
func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
if f.GetCardPayoutStatusFn != nil {
return f.GetCardPayoutStatusFn(ctx, req)
}
return &mntxv1.GetCardPayoutStatusResponse{}, nil
}
func (f *Fake) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
if f.ListGatewayInstancesFn != nil {
return f.ListGatewayInstancesFn(ctx, req)
}
return &mntxv1.ListGatewayInstancesResponse{}, nil
}
func (f *Fake) Close() error { return nil }

View File

@@ -0,0 +1,62 @@
runtime:
shutdown_timeout_seconds: 15
grpc:
network: tcp
address: ":50075"
advertise_host: "dev-aurora-gateway"
enable_reflection: true
enable_health: true
metrics:
address: ":9405"
database:
driver: mongodb
settings:
host_env: AURORA_GATEWAY_MONGO_HOST
port_env: AURORA_GATEWAY_MONGO_PORT
database_env: AURORA_GATEWAY_MONGO_DATABASE
user_env: AURORA_GATEWAY_MONGO_USER
password_env: AURORA_GATEWAY_MONGO_PASSWORD
auth_source_env: AURORA_GATEWAY_MONGO_AUTH_SOURCE
replica_set_env: AURORA_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: Aurora Gateway Service
max_reconnects: 10
reconnect_wait: 5
buffer_size: 1024
aurora:
base_url: "http://aurora-sim.local"
project_id: 1001
secret_key: "aurora-dev-simulated"
allowed_currencies: ["RUB"]
require_customer_address: false
request_timeout_seconds: 15
status_success: "success"
status_processing: "processing"
strict_operation_mode: false
gateway:
id: "mcards"
is_enabled: true
network: "MIR"
currencies: ["RUB"]
limits:
per_tx_min_amount: "0"
http:
callback:
address: ":8084"
path: "/aurora/callback"
allowed_cidrs: []
max_body_bytes: 1048576

View File

@@ -0,0 +1,62 @@
runtime:
shutdown_timeout_seconds: 15
grpc:
network: tcp
address: ":50075"
advertise_host: "sendico_aurora_gateway"
enable_reflection: true
enable_health: true
metrics:
address: ":9404"
database:
driver: mongodb
settings:
host_env: AURORA_GATEWAY_MONGO_HOST
port_env: AURORA_GATEWAY_MONGO_PORT
database_env: AURORA_GATEWAY_MONGO_DATABASE
user_env: AURORA_GATEWAY_MONGO_USER
password_env: AURORA_GATEWAY_MONGO_PASSWORD
auth_source_env: AURORA_GATEWAY_MONGO_AUTH_SOURCE
replica_set_env: AURORA_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: Aurora Gateway Service
max_reconnects: 10
reconnect_wait: 5
buffer_size: 1024
aurora:
base_url: "http://aurora-sim.local"
project_id: 1001
secret_key: "aurora-dev-simulated"
allowed_currencies: ["RUB"]
require_customer_address: false
request_timeout_seconds: 15
status_success: "success"
status_processing: "processing"
strict_operation_mode: false
gateway:
id: "mcards"
is_enabled: true
network: "MIR"
currencies: ["RUB"]
limits:
per_tx_min_amount: "0.00"
http:
callback:
address: ":8084"
path: "/aurora/callback"
allowed_cidrs: []
max_body_bytes: 1048576

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
exec /app/aurora-gateway "$@"

55
api/gateway/aurora/go.mod Normal file
View File

@@ -0,0 +1,55 @@
module github.com/tech/sendico/gateway/aurora
go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
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/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/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/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_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
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.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
)

223
api/gateway/aurora/go.sum Normal file
View File

@@ -0,0 +1,223 @@
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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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 Aurora Gateway Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -0,0 +1,604 @@
package serverimp
import (
"context"
"errors"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/gateway/aurora/internal/appversion"
auroraservice "github.com/tech/sendico/gateway/aurora/internal/service/gateway"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/gateway/aurora/storage"
gatewaymongo "github.com/tech/sendico/gateway/aurora/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
"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]
http *http.Server
service *auroraservice.Service
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Provider gatewayProviderConfig `yaml:"aurora"`
LegacyProvider gatewayProviderConfig `yaml:"mcards"`
Gateway gatewayConfig `yaml:"gateway"`
HTTP httpConfig `yaml:"http"`
}
type gatewayProviderConfig struct {
BaseURL string `yaml:"base_url"`
BaseURLEnv string `yaml:"base_url_env"`
ProjectID int64 `yaml:"project_id"`
ProjectIDEnv string `yaml:"project_id_env"`
SecretKey string `yaml:"secret_key"`
SecretKeyEnv string `yaml:"secret_key_env"`
AllowedCurrencies []string `yaml:"allowed_currencies"`
RequireCustomerAddress bool `yaml:"require_customer_address"`
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
StatusSuccess string `yaml:"status_success"`
StatusProcessing string `yaml:"status_processing"`
StrictOperationMode bool `yaml:"strict_operation_mode"`
}
type gatewayConfig struct {
ID string `yaml:"id"`
Network string `yaml:"network"`
Currencies []string `yaml:"currencies"`
IsEnabled *bool `yaml:"is_enabled"`
Limits limitsConfig `yaml:"limits"`
}
type limitsConfig struct {
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
PerTxMaxFee string `yaml:"per_tx_max_fee"`
PerTxMinAmount string `yaml:"per_tx_min_amount"`
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
VolumeLimit map[string]string `yaml:"volume_limit"`
VelocityLimit map[string]int `yaml:"velocity_limit"`
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
}
type limitsOverrideCfg struct {
MaxVolume string `yaml:"max_volume"`
MinAmount string `yaml:"min_amount"`
MaxAmount string `yaml:"max_amount"`
MaxFee string `yaml:"max_fee"`
MaxOps int `yaml:"max_ops"`
}
type httpConfig struct {
Callback callbackConfig `yaml:"callback"`
}
type callbackConfig struct {
Address string `yaml:"address"`
Path string `yaml:"path"`
AllowedCIDRs []string `yaml:"allowed_cidrs"`
MaxBodyBytes int64 `yaml:"max_body_bytes"`
}
// Create initialises the Aurora gateway server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if i.service != nil {
i.service.Shutdown()
}
if i.http != nil {
_ = i.http.Shutdown(ctx)
i.http = nil
}
i.app.Shutdown(ctx)
}
func (i *Imp) Start() error {
i.logger.Info("Starting Aurora gateway", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
i.logger.Info("Configuration loaded",
zap.String("grpc_address", cfg.GRPC.Address),
zap.String("metrics_address", cfg.Metrics.Address),
)
providerSection := effectiveProviderConfig(cfg.Provider, cfg.LegacyProvider)
providerCfg, err := i.resolveProviderConfig(providerSection)
if err != nil {
i.logger.Error("Failed to resolve provider configuration", zap.Error(err))
return err
}
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
if err != nil {
i.logger.Error("Failed to resolve callback configuration", zap.Error(err))
return err
}
i.logger.Info("Provider configuration resolved",
zap.Bool("base_url_set", strings.TrimSpace(providerCfg.BaseURL) != ""),
zap.Int64("project_id", providerCfg.ProjectID),
zap.Bool("secret_key_set", strings.TrimSpace(providerCfg.SecretKey) != ""),
zap.Int("allowed_currencies", len(providerCfg.AllowedCurrencies)),
zap.Bool("require_customer_address", providerCfg.RequireCustomerAddress),
zap.Duration("request_timeout", providerCfg.RequestTimeout),
zap.String("status_success", providerCfg.SuccessStatus()),
zap.String("status_processing", providerCfg.ProcessingStatus()),
zap.Bool("strict_operation_mode", providerSection.StrictOperationMode),
)
gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, providerCfg)
if gatewayDescriptor != nil {
i.logger.Info("Gateway descriptor resolved",
zap.String("id", gatewayDescriptor.GetId()),
zap.String("rail", gatewayDescriptor.GetRail().String()),
zap.String("network", gatewayDescriptor.GetNetwork()),
zap.Int("currencies", len(gatewayDescriptor.GetCurrencies())),
zap.Bool("enabled", gatewayDescriptor.GetIsEnabled()),
)
}
i.logger.Info("Callback configuration resolved",
zap.String("address", callbackCfg.Address),
zap.String("path", callbackCfg.Path),
zap.Int("allowed_cidrs", len(callbackCfg.AllowedCIDRs)),
zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes),
)
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
invokeURI := ""
if cfg.GRPC != nil {
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
}
opts := []auroraservice.Option{
auroraservice.WithDiscoveryInvokeURI(invokeURI),
auroraservice.WithProducer(producer),
auroraservice.WithProviderConfig(providerCfg),
auroraservice.WithStrictOperationIsolation(providerSection.StrictOperationMode),
auroraservice.WithGatewayDescriptor(gatewayDescriptor),
auroraservice.WithHTTPClient(&http.Client{Timeout: providerCfg.Timeout()}),
auroraservice.WithStorage(repo),
}
if cfg.Messaging != nil {
opts = append(opts, auroraservice.WithMessagingSettings(cfg.Messaging.Settings))
}
svc := auroraservice.NewService(logger, opts...)
i.service = svc
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
return nil, err
}
return svc, nil
}
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return gatewaymongo.New(logger, conn)
}
app, err := grpcapp.NewApp(i.logger, paymenttypes.DefaultCardsGatewayID, cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{
Config: &grpcapp.Config{},
}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50075",
EnableReflection: true,
EnableHealth: true,
}
}
if cfg.Metrics == nil {
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9405"}
}
return cfg, nil
}
func effectiveProviderConfig(primary, legacy gatewayProviderConfig) gatewayProviderConfig {
if hasProviderConfig(primary) {
return primary
}
return legacy
}
func hasProviderConfig(cfg gatewayProviderConfig) bool {
return strings.TrimSpace(cfg.BaseURL) != "" ||
strings.TrimSpace(cfg.BaseURLEnv) != "" ||
cfg.ProjectID != 0 ||
strings.TrimSpace(cfg.ProjectIDEnv) != "" ||
strings.TrimSpace(cfg.SecretKey) != "" ||
strings.TrimSpace(cfg.SecretKeyEnv) != "" ||
len(cfg.AllowedCurrencies) > 0 ||
cfg.RequireCustomerAddress ||
cfg.RequestTimeoutSeconds != 0 ||
strings.TrimSpace(cfg.StatusSuccess) != "" ||
strings.TrimSpace(cfg.StatusProcessing) != "" ||
cfg.StrictOperationMode
}
func (i *Imp) resolveProviderConfig(cfg gatewayProviderConfig) (provider.Config, error) {
baseURL := strings.TrimSpace(cfg.BaseURL)
if env := strings.TrimSpace(cfg.BaseURLEnv); env != "" {
if val := strings.TrimSpace(os.Getenv(env)); val != "" {
baseURL = val
}
}
projectID := cfg.ProjectID
if projectID == 0 && strings.TrimSpace(cfg.ProjectIDEnv) != "" {
raw := strings.TrimSpace(os.Getenv(cfg.ProjectIDEnv))
if raw != "" {
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
projectID = id
} else {
return provider.Config{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "aurora.project_id")
}
}
}
secret := strings.TrimSpace(cfg.SecretKey)
if env := strings.TrimSpace(cfg.SecretKeyEnv); env != "" {
if val := strings.TrimSpace(os.Getenv(env)); val != "" {
secret = val
}
}
timeout := time.Duration(cfg.RequestTimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 15 * time.Second
}
statusSuccess := strings.TrimSpace(cfg.StatusSuccess)
statusProcessing := strings.TrimSpace(cfg.StatusProcessing)
return provider.Config{
BaseURL: baseURL,
ProjectID: projectID,
SecretKey: secret,
AllowedCurrencies: cfg.AllowedCurrencies,
RequireCustomerAddress: cfg.RequireCustomerAddress,
RequestTimeout: timeout,
StatusSuccess: statusSuccess,
StatusProcessing: statusProcessing,
}, nil
}
func resolveGatewayDescriptor(cfg gatewayConfig, providerCfg provider.Config) *gatewayv1.GatewayInstanceDescriptor {
id := strings.TrimSpace(cfg.ID)
if id == "" {
id = paymenttypes.DefaultCardsGatewayID
}
network := strings.ToUpper(strings.TrimSpace(cfg.Network))
currencies := normalizeCurrencies(cfg.Currencies)
if len(currencies) == 0 {
currencies = normalizeCurrencies(providerCfg.AllowedCurrencies)
}
enabled := true
if cfg.IsEnabled != nil {
enabled = *cfg.IsEnabled
}
limits := buildGatewayLimits(cfg.Limits)
if limits == nil {
limits = &gatewayv1.Limits{MinAmount: "0"}
}
version := strings.TrimSpace(appversion.Version)
return &gatewayv1.GatewayInstanceDescriptor{
Id: id,
Rail: gatewayv1.Rail_RAIL_CARD,
Network: network,
Currencies: currencies,
Capabilities: &gatewayv1.RailCapabilities{
CanPayOut: true,
CanPayIn: false,
CanReadBalance: false,
CanSendFee: false,
RequiresObserveConfirm: true,
},
Limits: limits,
Version: version,
IsEnabled: enabled,
}
}
func normalizeCurrencies(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" || seen[clean] {
continue
}
seen[clean] = true
result = append(result, clean)
}
return result
}
func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits {
hasValue := strings.TrimSpace(cfg.MinAmount) != "" ||
strings.TrimSpace(cfg.MaxAmount) != "" ||
strings.TrimSpace(cfg.PerTxMaxFee) != "" ||
strings.TrimSpace(cfg.PerTxMinAmount) != "" ||
strings.TrimSpace(cfg.PerTxMaxAmount) != "" ||
len(cfg.VolumeLimit) > 0 ||
len(cfg.VelocityLimit) > 0 ||
len(cfg.CurrencyLimits) > 0
if !hasValue {
return nil
}
limits := &gatewayv1.Limits{
MinAmount: strings.TrimSpace(cfg.MinAmount),
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
}
if len(cfg.VolumeLimit) > 0 {
limits.VolumeLimit = map[string]string{}
for key, value := range cfg.VolumeLimit {
bucket := strings.TrimSpace(key)
amount := strings.TrimSpace(value)
if bucket == "" || amount == "" {
continue
}
limits.VolumeLimit[bucket] = amount
}
}
if len(cfg.VelocityLimit) > 0 {
limits.VelocityLimit = map[string]int32{}
for key, value := range cfg.VelocityLimit {
bucket := strings.TrimSpace(key)
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = int32(value)
}
}
if len(cfg.CurrencyLimits) > 0 {
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
for key, override := range cfg.CurrencyLimits {
currency := strings.ToUpper(strings.TrimSpace(key))
if currency == "" {
continue
}
limits.CurrencyLimits[currency] = &gatewayv1.LimitsOverride{
MaxVolume: strings.TrimSpace(override.MaxVolume),
MinAmount: strings.TrimSpace(override.MinAmount),
MaxAmount: strings.TrimSpace(override.MaxAmount),
MaxFee: strings.TrimSpace(override.MaxFee),
MaxOps: int32(override.MaxOps),
}
}
}
return limits
}
type callbackRuntimeConfig struct {
Address string
Path string
AllowedCIDRs []*net.IPNet
MaxBodyBytes int64
}
func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, error) {
addr := strings.TrimSpace(cfg.Address)
if addr == "" {
addr = ":8084"
}
path := strings.TrimSpace(cfg.Path)
if path == "" {
path = "/" + paymenttypes.DefaultCardsGatewayID + "/callback"
}
maxBody := cfg.MaxBodyBytes
if maxBody <= 0 {
maxBody = 1 << 20 // 1MB
}
var cidrs []*net.IPNet
for _, raw := range cfg.AllowedCIDRs {
clean := strings.TrimSpace(raw)
if clean == "" {
continue
}
_, block, err := net.ParseCIDR(clean)
if err != nil {
i.logger.Warn("Invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
continue
}
cidrs = append(cidrs, block)
}
return callbackRuntimeConfig{
Address: addr,
Path: path,
AllowedCIDRs: cidrs,
MaxBodyBytes: maxBody,
}, nil
}
func (i *Imp) startHTTPCallbackServer(svc *auroraservice.Service, cfg callbackRuntimeConfig) error {
if svc == nil {
return merrors.InvalidArgument("nil service provided for callback server")
}
if strings.TrimSpace(cfg.Address) == "" {
i.logger.Info("Aurora callback server disabled: address is empty")
return nil
}
router := chi.NewRouter()
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
log := i.logger.Named("callback_http")
log.Debug("Callback request received",
zap.String("remote_addr", strings.TrimSpace(r.RemoteAddr)),
zap.String("path", r.URL.Path),
zap.String("method", r.Method),
)
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
ip := clientIPFromRequest(r)
remoteIP := ""
if ip != nil {
remoteIP = ip.String()
}
log.Warn("Callback rejected by CIDR allowlist", zap.String("remote_ip", remoteIP))
http.Error(w, "forbidden", http.StatusForbidden)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
if err != nil {
log.Warn("Callback body read failed", zap.Error(err))
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
status, err := svc.ProcessProviderCallback(r.Context(), body)
if err != nil {
log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status))
http.Error(w, err.Error(), status)
return
}
log.Debug("Callback processed", zap.Int("status", status))
w.WriteHeader(status)
})
server := &http.Server{
Addr: cfg.Address,
Handler: router,
}
ln, err := net.Listen("tcp", cfg.Address)
if err != nil {
return err
}
i.http = server
go func() {
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Aurora callback server stopped with error", zap.Error(err))
}
}()
i.logger.Info("Aurora callback server listening", zap.String("address", cfg.Address), zap.String("path", cfg.Path))
return nil
}
func clientAllowed(r *http.Request, cidrs []*net.IPNet) bool {
if len(cidrs) == 0 {
return true
}
host := clientIPFromRequest(r)
if host == nil {
return false
}
for _, block := range cidrs {
if block.Contains(host) {
return true
}
}
return false
}
func clientIPFromRequest(r *http.Request) net.IP {
if r == nil {
return nil
}
if xfwd := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xfwd != "" {
parts := strings.Split(xfwd, ",")
if len(parts) > 0 {
if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil {
return ip
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return nil
}
return net.ParseIP(host)
}

View File

@@ -0,0 +1,87 @@
package serverimp
import (
"net"
"net/http"
"testing"
)
func TestEffectiveProviderConfig(t *testing.T) {
primary := gatewayProviderConfig{
BaseURL: "https://aurora.local",
StrictOperationMode: true,
}
legacy := gatewayProviderConfig{
BaseURL: "https://legacy.local",
StrictOperationMode: false,
}
got := effectiveProviderConfig(primary, legacy)
if got.BaseURL != primary.BaseURL {
t.Fatalf("expected primary provider config to be selected, got %q", got.BaseURL)
}
if !got.StrictOperationMode {
t.Fatalf("expected strict operation mode from primary config")
}
}
func TestEffectiveProviderConfig_FallsBackToLegacy(t *testing.T) {
primary := gatewayProviderConfig{}
legacy := gatewayProviderConfig{
BaseURL: "https://legacy.local",
StrictOperationMode: true,
}
got := effectiveProviderConfig(primary, legacy)
if got.BaseURL != legacy.BaseURL {
t.Fatalf("expected legacy provider config to be selected, got %q", got.BaseURL)
}
if !got.StrictOperationMode {
t.Fatalf("expected strict operation mode from legacy config")
}
}
func TestClientIPFromRequest(t *testing.T) {
req := &http.Request{
Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}},
RemoteAddr: "9.8.7.6:1234",
}
ip := clientIPFromRequest(req)
if ip == nil || ip.String() != "1.2.3.4" {
t.Fatalf("expected forwarded ip, got %v", ip)
}
req = &http.Request{RemoteAddr: "9.8.7.6:1234"}
ip = clientIPFromRequest(req)
if ip == nil || ip.String() != "9.8.7.6" {
t.Fatalf("expected remote addr ip, got %v", ip)
}
req = &http.Request{RemoteAddr: "invalid"}
ip = clientIPFromRequest(req)
if ip != nil {
t.Fatalf("expected nil ip, got %v", ip)
}
}
func TestClientAllowed(t *testing.T) {
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
if err != nil {
t.Fatalf("failed to parse cidr: %v", err)
}
allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"}
if !clientAllowed(allowedReq, []*net.IPNet{cidr}) {
t.Fatalf("expected allowed request")
}
deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
if clientAllowed(deniedReq, []*net.IPNet{cidr}) {
t.Fatalf("expected denied request")
}
openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
if !clientAllowed(openReq, nil) {
t.Fatalf("expected allow when no cidrs are configured")
}
}

View File

@@ -0,0 +1,12 @@
package server
import (
serverimp "github.com/tech/sendico/gateway/aurora/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
// Create constructs the Aurora gateway server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,321 @@
package gateway
import (
"context"
"strings"
"testing"
"time"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/gateway/aurora/storage/model"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
)
func TestAuroraCardPayoutScenarios(t *testing.T) {
cfg := provider.Config{
ProjectID: 1001,
AllowedCurrencies: []string{"RUB"},
}
now := time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)
repo := newMockRepository()
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, nil, nil)
processor.dispatchThrottleInterval = 0
tests := []struct {
name string
pan string
wantAccepted bool
wantStatus mntxv1.PayoutStatus
wantErrorCode string
wantProviderCode string
wantProviderMatch string
}{
{
name: "approved_instant",
pan: "2200001111111111",
wantAccepted: true,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS,
wantErrorCode: "00",
wantProviderCode: "00",
wantProviderMatch: "Approved",
},
{
name: "pending_issuer_review",
pan: "2200002222222222",
wantAccepted: true,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING,
wantErrorCode: "P01",
wantProviderCode: "P01",
wantProviderMatch: "Pending issuer review",
},
{
name: "insufficient_funds",
pan: "2200003333333333",
wantAccepted: false,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
wantErrorCode: "51",
wantProviderCode: "51",
wantProviderMatch: "Insufficient funds",
},
{
name: "unknown_card_default_queue",
pan: "2200009999999999",
wantAccepted: true,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING,
wantErrorCode: "P00",
wantProviderCode: "P00",
wantProviderMatch: "Queued for provider processing",
},
{
name: "provider_maintenance",
pan: "2200009999999997",
wantAccepted: false,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
wantErrorCode: "91",
wantProviderCode: "91",
wantProviderMatch: "inoperative",
},
{
name: "provider_system_malfunction",
pan: "2200009999999996",
wantAccepted: false,
wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
wantErrorCode: "96",
wantProviderCode: "96",
wantProviderMatch: "System malfunction",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := validCardPayoutRequest()
req.PayoutId = ""
req.OperationRef = "op-" + tc.name
req.ParentPaymentRef = "parent-" + tc.name
req.IdempotencyKey = "idem-" + tc.name
req.CardPan = tc.pan
resp, err := processor.Submit(context.Background(), req)
if err != nil {
t.Fatalf("submit failed: %v", err)
}
if got, want := resp.GetAccepted(), tc.wantAccepted; got != want {
t.Fatalf("accepted mismatch: got=%v want=%v", got, want)
}
if got, want := resp.GetPayout().GetStatus(), tc.wantStatus; got != want {
t.Fatalf("status mismatch: got=%v want=%v", got, want)
}
if got, want := strings.TrimSpace(resp.GetErrorCode()), tc.wantErrorCode; got != want {
t.Fatalf("error_code mismatch: got=%q want=%q", got, want)
}
state, ok := repo.payouts.Get(req.GetOperationRef())
if !ok || state == nil {
t.Fatalf("expected persisted payout state")
}
if got, want := strings.TrimSpace(state.ProviderCode), tc.wantProviderCode; got != want {
t.Fatalf("provider_code mismatch: got=%q want=%q", got, want)
}
if tc.wantProviderMatch != "" && !strings.Contains(state.ProviderMessage, tc.wantProviderMatch) {
t.Fatalf("provider_message mismatch: got=%q expected to contain %q", state.ProviderMessage, tc.wantProviderMatch)
}
})
}
}
func TestAuroraTransportFailureScenarioEventuallyFails(t *testing.T) {
cfg := provider.Config{
ProjectID: 1001,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)},
repo,
nil,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.dispatchMaxAttempts = 2
processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
req := validCardPayoutRequest()
req.PayoutId = ""
req.OperationRef = "op-transport-timeout"
req.ParentPaymentRef = "parent-transport-timeout"
req.IdempotencyKey = "idem-transport-timeout"
req.CardPan = "2200008888888888"
resp, err := processor.Submit(context.Background(), req)
if err != nil {
t.Fatalf("submit failed: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response while transport retry is scheduled")
}
if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING; got != want {
t.Fatalf("initial status mismatch: got=%v want=%v", got, want)
}
deadline := time.Now().Add(2 * time.Second)
for {
state, ok := repo.payouts.Get(req.GetOperationRef())
if ok && state != nil && state.Status == model.PayoutStatusFailed {
if !strings.Contains(strings.ToLower(state.FailureReason), "transport error") {
t.Fatalf("expected transport failure reason, got=%q", state.FailureReason)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for transport failure terminal state")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestAuroraRetryableScenarioEventuallyFails(t *testing.T) {
cfg := provider.Config{
ProjectID: 1001,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)},
repo,
nil,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.dispatchMaxAttempts = 2
processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
req := validCardPayoutRequest()
req.PayoutId = ""
req.OperationRef = "op-retryable-issuer-unavailable"
req.ParentPaymentRef = "parent-retryable-issuer-unavailable"
req.IdempotencyKey = "idem-retryable-issuer-unavailable"
req.CardPan = "2200004444444444"
resp, err := processor.Submit(context.Background(), req)
if err != nil {
t.Fatalf("submit failed: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response while retry is scheduled")
}
if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING; got != want {
t.Fatalf("initial status mismatch: got=%v want=%v", got, want)
}
deadline := time.Now().Add(2 * time.Second)
for {
state, ok := repo.payouts.Get(req.GetOperationRef())
if ok && state != nil && state.Status == model.PayoutStatusFailed {
if !strings.Contains(state.FailureReason, "10101") {
t.Fatalf("expected retryable provider code in failure_reason, got=%q", state.FailureReason)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for retryable scenario terminal failure")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestAuroraTokenPayoutUsesTokenizedPANScenario(t *testing.T) {
cfg := provider.Config{
ProjectID: 1001,
AllowedCurrencies: []string{"RUB", "USD"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)},
repo,
nil,
nil,
)
processor.dispatchThrottleInterval = 0
tokenizeReq := validCardTokenizeRequest()
tokenizeReq.RequestId = "tok-req-insufficient"
tokenizeReq.CardPan = "2200003333333333"
tokenizeResp, err := processor.Tokenize(context.Background(), tokenizeReq)
if err != nil {
t.Fatalf("tokenize failed: %v", err)
}
if tokenizeResp.GetToken() == "" {
t.Fatalf("expected non-empty token")
}
payoutReq := validCardTokenPayoutRequest()
payoutReq.PayoutId = ""
payoutReq.OperationRef = "op-token-insufficient"
payoutReq.ParentPaymentRef = "parent-token-insufficient"
payoutReq.IdempotencyKey = "idem-token-insufficient"
payoutReq.CardToken = tokenizeResp.GetToken()
payoutReq.MaskedPan = tokenizeResp.GetMaskedPan()
resp, err := processor.SubmitToken(context.Background(), payoutReq)
if err != nil {
t.Fatalf("submit token payout failed: %v", err)
}
if resp.GetAccepted() {
t.Fatalf("expected declined payout for insufficient funds token scenario")
}
if got, want := resp.GetErrorCode(), "51"; got != want {
t.Fatalf("error_code mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED; got != want {
t.Fatalf("status mismatch: got=%v want=%v", got, want)
}
}
func TestAuroraTokenPayoutFallsBackToMaskedPANScenario(t *testing.T) {
cfg := provider.Config{
ProjectID: 1001,
AllowedCurrencies: []string{"RUB", "USD"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)},
repo,
nil,
nil,
)
processor.dispatchThrottleInterval = 0
req := validCardTokenPayoutRequest()
req.PayoutId = ""
req.OperationRef = "op-token-masked-fallback"
req.ParentPaymentRef = "parent-token-masked-fallback"
req.IdempotencyKey = "idem-token-masked-fallback"
req.CardToken = "unknown-token"
req.MaskedPan = "220000******6666"
resp, err := processor.SubmitToken(context.Background(), req)
if err != nil {
t.Fatalf("submit token payout failed: %v", err)
}
if resp.GetAccepted() {
t.Fatalf("expected declined payout for masked-pan fallback scenario")
}
if got, want := resp.GetErrorCode(), "05"; got != want {
t.Fatalf("error_code mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED; got != want {
t.Fatalf("status mismatch: got=%v want=%v", got, want)
}
}

View File

@@ -0,0 +1,177 @@
package gateway
import (
"bytes"
"context"
"crypto/hmac"
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
type callbackPayment struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Date string `json:"date"`
Method string `json:"method"`
Description string `json:"description"`
Sum struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
} `json:"sum"`
}
type callbackOperation struct {
ID int64 `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Date string `json:"date"`
CreatedDate string `json:"created_date"`
RequestID string `json:"request_id"`
SumInitial struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
} `json:"sum_initial"`
SumConverted struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
} `json:"sum_converted"`
Provider struct {
ID int64 `json:"id"`
PaymentID string `json:"payment_id"`
AuthCode string `json:"auth_code"`
EndpointID int64 `json:"endpoint_id"`
Date string `json:"date"`
} `json:"provider"`
Code string `json:"code"`
Message string `json:"message"`
}
type providerCallback struct {
ProjectID int64 `json:"project_id"`
Payment callbackPayment `json:"payment"`
Account struct {
Number string `json:"number"`
Type string `json:"type"`
CardHolder string `json:"card_holder"`
ExpiryMonth string `json:"expiry_month"`
ExpiryYear string `json:"expiry_year"`
} `json:"account"`
Customer struct {
ID string `json:"id"`
} `json:"customer"`
Operation callbackOperation `json:"operation"`
Signature string `json:"signature"`
}
// ProcessProviderCallback ingests provider callbacks and updates payout state.
func (s *Service) ProcessProviderCallback(ctx context.Context, payload []byte) (int, error) {
log := s.logger.Named("callback")
if s.card == nil {
log.Warn("Card payout processor not initialised")
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
}
log.Debug("Callback processing requested", zap.Int("payload_bytes", len(payload)))
return s.card.ProcessCallback(ctx, payload)
}
func mapCallbackToState(clock clockpkg.Clock, cfg provider.Config, cb providerCallback) (*mntxv1.CardPayoutState, string) {
status := strings.ToLower(strings.TrimSpace(cb.Payment.Status))
opStatus := strings.ToLower(strings.TrimSpace(cb.Operation.Status))
code := strings.TrimSpace(cb.Operation.Code)
outcome := provider.OutcomeDecline
internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") {
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
outcome = provider.OutcomeSuccess
} else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() {
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
outcome = provider.OutcomeProcessing
}
now := timestamppb.New(clock.Now())
state := &mntxv1.CardPayoutState{
PayoutId: cb.Payment.ID,
ProjectId: cb.ProjectID,
CustomerId: cb.Customer.ID,
AmountMinor: cb.Payment.Sum.Amount,
Currency: strings.ToUpper(strings.TrimSpace(cb.Payment.Sum.Currency)),
Status: internalStatus,
ProviderCode: cb.Operation.Code,
ProviderMessage: cb.Operation.Message,
ProviderPaymentId: fallbackProviderPaymentID(cb),
OperationRef: strings.TrimSpace(cb.Payment.ID),
UpdatedAt: now,
CreatedAt: now,
}
return state, outcome
}
func fallbackProviderPaymentID(cb providerCallback) string {
if cb.Operation.Provider.PaymentID != "" {
return cb.Operation.Provider.PaymentID
}
if cb.Operation.RequestID != "" {
return cb.Operation.RequestID
}
return cb.Payment.ID
}
func verifyCallbackSignature(payload []byte, secret string) (string, error) {
root, err := decodeCallbackPayload(payload)
if err != nil {
return "", err
}
signature, ok := signatureFromPayload(root)
if !ok || strings.TrimSpace(signature) == "" {
return "", merrors.InvalidArgument("signature is missing")
}
calculated, err := provider.SignPayload(root, secret)
if err != nil {
return signature, err
}
if subtleConstantTimeCompare(signature, calculated) {
return signature, nil
}
return signature, merrors.DataConflict("signature mismatch")
}
func decodeCallbackPayload(payload []byte) (any, error) {
var root any
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.UseNumber()
if err := decoder.Decode(&root); err != nil {
return nil, err
}
return root, nil
}
func signatureFromPayload(root any) (string, bool) {
payload, ok := root.(map[string]any)
if !ok {
return "", false
}
for key, value := range payload {
if !strings.EqualFold(key, "signature") {
continue
}
signature, ok := value.(string)
return signature, ok
}
return "", false
}
func subtleConstantTimeCompare(a, b string) bool {
return hmac.Equal([]byte(strings.TrimSpace(a)), []byte(strings.TrimSpace(b)))
}

View File

@@ -0,0 +1,139 @@
package gateway
import (
"encoding/json"
"testing"
"time"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
type fixedClock struct {
now time.Time
}
func (f fixedClock) Now() time.Time {
return f.now
}
func baseCallback() providerCallback {
cb := providerCallback{
ProjectID: 42,
}
cb.Payment.ID = "payout-1"
cb.Payment.Status = "success"
cb.Payment.Sum.Amount = 5000
cb.Payment.Sum.Currency = "usd"
cb.Customer.ID = "cust-1"
cb.Operation.Status = "success"
cb.Operation.Code = ""
cb.Operation.Message = "ok"
cb.Operation.RequestID = "req-1"
cb.Operation.Provider.PaymentID = "prov-1"
return cb
}
func TestMapCallbackToState_StatusMapping(t *testing.T) {
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
cfg := provider.DefaultConfig()
cases := []struct {
name string
paymentStatus string
operationStatus string
code string
expectedStatus mntxv1.PayoutStatus
expectedOutcome string
}{
{
name: "success",
paymentStatus: "success",
operationStatus: "success",
code: "0",
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS,
expectedOutcome: provider.OutcomeSuccess,
},
{
name: "processing",
paymentStatus: "processing",
operationStatus: "success",
code: "",
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING,
expectedOutcome: provider.OutcomeProcessing,
},
{
name: "decline",
paymentStatus: "failed",
operationStatus: "failed",
code: "1",
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
expectedOutcome: provider.OutcomeDecline,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cb := baseCallback()
cb.Payment.Status = tc.paymentStatus
cb.Operation.Status = tc.operationStatus
cb.Operation.Code = tc.code
state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb)
if state.Status != tc.expectedStatus {
t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status)
}
if outcome != tc.expectedOutcome {
t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome)
}
if state.Currency != "USD" {
t.Fatalf("expected currency USD, got %q", state.Currency)
}
if !state.UpdatedAt.AsTime().Equal(now) {
t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime())
}
})
}
}
func TestFallbackProviderPaymentID(t *testing.T) {
cb := baseCallback()
if got := fallbackProviderPaymentID(cb); got != "prov-1" {
t.Fatalf("expected provider payment id, got %q", got)
}
cb.Operation.Provider.PaymentID = ""
if got := fallbackProviderPaymentID(cb); got != "req-1" {
t.Fatalf("expected request id fallback, got %q", got)
}
cb.Operation.RequestID = ""
if got := fallbackProviderPaymentID(cb); got != "payout-1" {
t.Fatalf("expected payment id fallback, got %q", got)
}
}
func TestVerifyCallbackSignature(t *testing.T) {
secret := "secret"
cb := baseCallback()
sig, err := provider.SignPayload(cb, secret)
if err != nil {
t.Fatalf("failed to sign payload: %v", err)
}
cb.Signature = sig
payload, err := json.Marshal(cb)
if err != nil {
t.Fatalf("failed to marshal callback: %v", err)
}
if _, err := verifyCallbackSignature(payload, secret); err != nil {
t.Fatalf("expected valid signature, got %v", err)
}
cb.Signature = "invalid"
payload, err = json.Marshal(cb)
if err != nil {
t.Fatalf("failed to marshal callback: %v", err)
}
if _, err := verifyCallbackSignature(payload, secret); err == nil {
t.Fatalf("expected signature mismatch error")
}
}

View File

@@ -0,0 +1,207 @@
package gateway
import (
"context"
"strings"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
)
func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
return executeUnary(ctx, s, "CreateCardPayout", s.handleCreateCardPayout, req)
}
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
log := s.logger.Named("card_payout")
log.Info("Create card payout request received",
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())),
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
)
if s.card == nil {
log.Warn("Card payout processor not initialised")
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
}
resp, err := s.card.Submit(ctx, req)
if err != nil {
log.Warn("Card payout submission failed", zap.Error(err))
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
}
log.Info("Card payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
return gsresponse.Success(resp)
}
func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
return executeUnary(ctx, s, "CreateCardTokenPayout", s.handleCreateCardTokenPayout, req)
}
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
log := s.logger.Named("card_token_payout")
log.Info("Create card token payout request received",
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())),
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
)
if s.card == nil {
log.Warn("Card payout processor not initialised")
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
}
resp, err := s.card.SubmitToken(ctx, req)
if err != nil {
log.Warn("Card token payout submission failed", zap.Error(err))
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
}
log.Info("Card token payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
return gsresponse.Success(resp)
}
func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) {
return executeUnary(ctx, s, "CreateCardToken", s.handleCreateCardToken, req)
}
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
log := s.logger.Named("card_tokenize")
log.Info("Create card token request received",
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
)
if s.card == nil {
log.Warn("Card payout processor not initialised")
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
}
resp, err := s.card.Tokenize(ctx, req)
if err != nil {
log.Warn("Card tokenization failed", zap.Error(err))
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
}
log.Info("Card tokenization completed", zap.String("request_id", resp.GetRequestId()), zap.Bool("success", resp.GetSuccess()))
return gsresponse.Success(resp)
}
func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
return executeUnary(ctx, s, "GetCardPayoutStatus", s.handleGetCardPayoutStatus, req)
}
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
log := s.logger.Named("card_payout_status")
log.Info("Get card payout status request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())))
if s.card == nil {
log.Warn("Card payout processor not initialised")
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
}
state, err := s.card.Status(context.Background(), req.GetPayoutId())
if err != nil {
log.Warn("Card payout status lookup failed", zap.Error(err))
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
}
log.Info("Card payout status retrieved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
}
func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayoutRequest {
if req == nil {
return nil
}
clean := proto.Clone(req)
r, ok := clean.(*mntxv1.CardPayoutRequest)
if !ok {
return req
}
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef())
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency()))
r.CardPan = strings.TrimSpace(r.GetCardPan())
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
r.OperationRef = strings.TrimSpace(r.GetOperationRef())
r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey())
r.IntentRef = strings.TrimSpace(r.GetIntentRef())
return r
}
func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.CardTokenPayoutRequest {
if req == nil {
return nil
}
clean := proto.Clone(req)
r, ok := clean.(*mntxv1.CardTokenPayoutRequest)
if !ok {
return req
}
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef())
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency()))
r.CardToken = strings.TrimSpace(r.GetCardToken())
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
r.MaskedPan = strings.TrimSpace(r.GetMaskedPan())
r.OperationRef = strings.TrimSpace(r.GetOperationRef())
r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey())
r.IntentRef = strings.TrimSpace(r.GetIntentRef())
return r
}
func sanitizeCardTokenizeRequest(req *mntxv1.CardTokenizeRequest) *mntxv1.CardTokenizeRequest {
if req == nil {
return nil
}
clean := proto.Clone(req)
r, ok := clean.(*mntxv1.CardTokenizeRequest)
if !ok {
return req
}
r.RequestId = strings.TrimSpace(r.GetRequestId())
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName())
r.CustomerIp = strings.TrimSpace(r.GetCustomerIp())
r.CustomerZip = strings.TrimSpace(r.GetCustomerZip())
r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry())
r.CustomerState = strings.TrimSpace(r.GetCustomerState())
r.CustomerCity = strings.TrimSpace(r.GetCustomerCity())
r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress())
r.CardPan = strings.TrimSpace(r.GetCardPan())
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
r.CardCvv = strings.TrimSpace(r.GetCardCvv())
if card := r.GetCard(); card != nil {
card.Pan = strings.TrimSpace(card.GetPan())
card.CardHolder = strings.TrimSpace(card.GetCardHolder())
card.Cvv = strings.TrimSpace(card.GetCvv())
r.Card = card
}
return r
}

View File

@@ -0,0 +1,108 @@
package gateway
import (
"context"
"sync"
"github.com/tech/sendico/gateway/aurora/storage"
"github.com/tech/sendico/gateway/aurora/storage/model"
)
// mockRepository implements storage.Repository for tests.
type mockRepository struct {
payouts *cardPayoutStore
}
func newMockRepository() *mockRepository {
return &mockRepository{
payouts: newCardPayoutStore(),
}
}
func (r *mockRepository) Payouts() storage.PayoutsStore {
return r.payouts
}
// cardPayoutStore implements storage.PayoutsStore for tests.
type cardPayoutStore struct {
mu sync.RWMutex
data map[string]*model.CardPayout
}
func payoutStoreKey(state *model.CardPayout) string {
if state == nil {
return ""
}
if ref := state.OperationRef; ref != "" {
return ref
}
return state.PaymentRef
}
func newCardPayoutStore() *cardPayoutStore {
return &cardPayoutStore{
data: make(map[string]*model.CardPayout),
}
}
func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*model.CardPayout, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, v := range s.data {
if v.IdempotencyKey == key {
return v, nil
}
}
return nil, nil
}
func (s *cardPayoutStore) FindByOperationRef(_ context.Context, ref string) (*model.CardPayout, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, v := range s.data {
if v.OperationRef == ref {
return v, nil
}
}
return nil, nil
}
func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, v := range s.data {
if v.PaymentRef == id {
return v, nil
}
}
return nil, nil
}
func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data[payoutStoreKey(record)] = record
return nil
}
// Save is a helper for tests to pre-populate data.
func (s *cardPayoutStore) Save(state *model.CardPayout) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[payoutStoreKey(state)] = state
}
// Get is a helper for tests to retrieve data.
func (s *cardPayoutStore) Get(id string) (*model.CardPayout, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
if v, ok := s.data[id]; ok {
return v, true
}
for _, v := range s.data {
if v.PaymentRef == id || v.OperationRef == id {
return v, true
}
}
return nil, false
}

View File

@@ -0,0 +1,99 @@
package gateway
import (
"strconv"
"strings"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg provider.Config) error {
if req == nil {
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil {
return err
}
if strings.TrimSpace(req.GetParentPaymentRef()) == "" {
return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" {
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
}
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
}
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
}
if strings.TrimSpace(req.GetCustomerIp()) == "" {
return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
}
if req.GetAmountMinor() <= 0 {
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor"))
}
currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency()))
if currency == "" {
return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency"))
}
if !cfg.CurrencyAllowed(currency) {
return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency"))
}
pan := strings.TrimSpace(req.GetCardPan())
if pan == "" {
return newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card_pan"))
}
if strings.TrimSpace(req.GetCardHolder()) == "" {
return newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card_holder"))
}
if err := validateCardExpiryFields(req.GetCardExpMonth(), req.GetCardExpYear()); err != nil {
return err
}
if cfg.RequireCustomerAddress {
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
}
if strings.TrimSpace(req.GetCustomerCity()) == "" {
return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
}
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
}
if strings.TrimSpace(req.GetCustomerZip()) == "" {
return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
}
}
return nil
}
func validateCardExpiryFields(month uint32, year uint32) error {
if month == 0 || month > 12 {
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card_exp_month"))
}
yearStr := strconv.Itoa(int(year))
if len(yearStr) < 2 || year == 0 {
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card_exp_year"))
}
return nil
}
func validateOperationIdentity(payoutID, operationRef string) error {
payoutID = strings.TrimSpace(payoutID)
operationRef = strings.TrimSpace(operationRef)
switch {
case payoutID == "" && operationRef == "":
return newPayoutError("missing_operation_ref", merrors.InvalidArgument("operation_ref or payout_id is required", "operation_ref"))
case payoutID != "" && operationRef != "":
return newPayoutError("ambiguous_operation_ref", merrors.InvalidArgument("provide either operation_ref or payout_id, not both"))
default:
return nil
}
}

View File

@@ -0,0 +1,116 @@
package gateway
import (
"testing"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func TestValidateCardPayoutRequest_Valid(t *testing.T) {
cfg := testProviderConfig()
req := validCardPayoutRequest()
if err := validateCardPayoutRequest(req, cfg); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestValidateCardPayoutRequest_Errors(t *testing.T) {
baseCfg := testProviderConfig()
cases := []struct {
name string
mutate func(*mntxv1.CardPayoutRequest)
config func(provider.Config) provider.Config
expected string
}{
{
name: "missing_operation_identity",
mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" },
expected: "missing_operation_ref",
},
{
name: "missing_parent_payment_ref",
mutate: func(r *mntxv1.CardPayoutRequest) { r.ParentPaymentRef = "" },
expected: "missing_parent_payment_ref",
},
{
name: "both_operation_and_payout_identity",
mutate: func(r *mntxv1.CardPayoutRequest) {
r.PayoutId = "parent-1"
r.OperationRef = "parent-1:hop_1_card_payout_send"
},
expected: "ambiguous_operation_ref",
},
{
name: "missing_customer_id",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerId = "" },
expected: "missing_customer_id",
},
{
name: "missing_customer_ip",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerIp = "" },
expected: "missing_customer_ip",
},
{
name: "invalid_amount",
mutate: func(r *mntxv1.CardPayoutRequest) { r.AmountMinor = 0 },
expected: "invalid_amount",
},
{
name: "missing_currency",
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "" },
expected: "missing_currency",
},
{
name: "unsupported_currency",
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "EUR" },
config: func(cfg provider.Config) provider.Config {
cfg.AllowedCurrencies = []string{"USD"}
return cfg
},
expected: "unsupported_currency",
},
{
name: "missing_card_pan",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" },
expected: "missing_card_pan",
},
{
name: "missing_card_holder",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" },
expected: "missing_card_holder",
},
{
name: "invalid_expiry_month",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpMonth = 13 },
expected: "invalid_expiry_month",
},
{
name: "invalid_expiry_year",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpYear = 0 },
expected: "invalid_expiry_year",
},
{
name: "missing_customer_country_when_required",
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerCountry = "" },
config: func(cfg provider.Config) provider.Config {
cfg.RequireCustomerAddress = true
return cfg
},
expected: "missing_customer_country",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := validCardPayoutRequest()
tc.mutate(req)
cfg := baseCfg
if tc.config != nil {
cfg = tc.config(cfg)
}
err := validateCardPayoutRequest(req, cfg)
requireReason(t, err, tc.expected)
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,763 @@
package gateway
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/gateway/aurora/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
)
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
type staticClock struct {
now time.Time
}
func (s staticClock) Now() time.Time {
return s.now
}
type apiResponse struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Message string `json:"message"`
Code string `json:"code"`
Operation struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"operation"`
}
func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
cfg := provider.Config{
BaseURL: "https://provider.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
existingCreated := time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC)
repo := newMockRepository()
repo.payouts.Save(&model.CardPayout{
PaymentRef: "payment-parent-1",
OperationRef: "payout-1",
CreatedAt: existingCreated,
})
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := apiResponse{}
resp.Operation.RequestID = "req-123"
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()
req.ProjectId = 0
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().GetProjectId() != cfg.ProjectID {
t.Fatalf("expected project id %d, got %d", cfg.ProjectID, resp.GetPayout().GetProjectId())
}
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING {
t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus())
}
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated) {
t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime())
}
stored, ok := repo.payouts.Get(req.GetPayoutId())
if !ok || stored == nil {
t.Fatalf("expected payout state stored")
}
if stored.ProviderPaymentID == "" {
t.Fatalf("expected provider payment id")
}
if !stored.CreatedAt.Equal(existingCreated) {
t.Fatalf("expected created_at preserved in model, got %v", stored.CreatedAt)
}
}
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
cfg := provider.Config{
AllowedCurrencies: []string{"RUB"},
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
clockpkg.NewSystem(),
newMockRepository(),
&http.Client{},
nil,
)
_, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err == nil {
t.Fatalf("expected error")
}
if !errors.Is(err, merrors.ErrInternal) {
t.Fatalf("expected internal error, got %v", err)
}
}
func TestCardPayoutProcessor_Submit_RejectsAmountBelowConfiguredMinimum(t *testing.T) {
cfg := provider.Config{
BaseURL: "https://provider.test",
SecretKey: "secret",
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)},
repo,
&http.Client{},
nil,
)
processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{
Limits: &gatewayv1.Limits{
PerTxMinAmount: "20.00",
},
})
req := validCardPayoutRequest() // 15.00 RUB
_, err := processor.Submit(context.Background(), req)
requireReason(t, err, "amount_below_minimum")
}
func TestCardPayoutProcessor_SubmitToken_RejectsAmountBelowCurrencyMinimum(t *testing.T) {
cfg := provider.Config{
BaseURL: "https://provider.test",
SecretKey: "secret",
AllowedCurrencies: []string{"USD"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)},
repo,
&http.Client{},
nil,
)
processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{
Limits: &gatewayv1.Limits{
PerTxMinAmount: "20.00",
CurrencyLimits: map[string]*gatewayv1.LimitsOverride{
"USD": {MinAmount: "30.00"},
},
},
})
req := validCardTokenPayoutRequest() // 25.00 USD
_, err := processor.SubmitToken(context.Background(), req)
requireReason(t, err, "amount_below_minimum")
}
func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
cfg := provider.Config{
SecretKey: "secret",
StatusSuccess: "success",
StatusProcessing: "processing",
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)},
repo,
&http.Client{},
nil,
)
cb := baseCallback()
cb.Payment.Sum.Currency = "RUB"
sig, err := provider.SignPayload(cb, cfg.SecretKey)
if err != nil {
t.Fatalf("failed to sign callback: %v", err)
}
cb.Signature = sig
payload, err := json.Marshal(cb)
if err != nil {
t.Fatalf("failed to marshal callback: %v", err)
}
status, err := processor.ProcessCallback(context.Background(), payload)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status ok, got %d", status)
}
state, ok := repo.payouts.Get(cb.Payment.ID)
if !ok || state == nil {
t.Fatalf("expected payout state stored")
}
if state.Status != model.PayoutStatusSuccess {
t.Fatalf("expected success status in model, got %v", state.Status)
}
}
func TestCardPayoutProcessor_Submit_SameParentDifferentOperationsStoredSeparately(t *testing.T) {
cfg := provider.Config{
BaseURL: "https://provider.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
var callN int
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
callN++
resp := apiResponse{}
resp.Operation.RequestID = fmt.Sprintf("req-%d", callN)
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
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
parentPaymentRef := "payment-parent-1"
op1 := parentPaymentRef + ":hop_4_card_payout_send"
op2 := parentPaymentRef + ":hop_4_card_payout_send_2"
req1 := validCardPayoutRequest()
req1.PayoutId = ""
req1.OperationRef = op1
req1.IdempotencyKey = "idem-1"
req1.ParentPaymentRef = parentPaymentRef
req1.CardPan = "2204310000002456"
req2 := validCardPayoutRequest()
req2.PayoutId = ""
req2.OperationRef = op2
req2.IdempotencyKey = "idem-2"
req2.ParentPaymentRef = parentPaymentRef
req2.CardPan = "2204320000009754"
if _, err := processor.Submit(context.Background(), req1); err != nil {
t.Fatalf("first submit failed: %v", err)
}
if _, err := processor.Submit(context.Background(), req2); err != nil {
t.Fatalf("second submit failed: %v", err)
}
first, err := repo.payouts.FindByOperationRef(context.Background(), op1)
if err != nil || first == nil {
t.Fatalf("expected first operation stored, err=%v", err)
}
second, err := repo.payouts.FindByOperationRef(context.Background(), op2)
if err != nil || second == nil {
t.Fatalf("expected second operation stored, err=%v", err)
}
if got, want := first.PaymentRef, parentPaymentRef; got != want {
t.Fatalf("first parent payment ref mismatch: got=%q want=%q", got, want)
}
if got, want := second.PaymentRef, parentPaymentRef; got != want {
t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want)
}
if got, want := first.OperationRef, op1; got != want {
t.Fatalf("first operation ref mismatch: got=%q want=%q", got, want)
}
if got, want := second.OperationRef, op2; got != want {
t.Fatalf("second operation ref mismatch: got=%q want=%q", got, want)
}
if first.ProviderPaymentID == "" || second.ProviderPaymentID == "" {
t.Fatalf("expected provider payment ids for both operations")
}
if first.ProviderPaymentID == second.ProviderPaymentID {
t.Fatalf("expected different provider payment ids, got=%q", first.ProviderPaymentID)
}
}
func TestCardPayoutProcessor_StrictMode_BlocksSecondOperationUntilFirstFinalCallback(t *testing.T) {
t.Skip("aurora simulator has no external provider transport call counting")
cfg := provider.Config{
BaseURL: "https://provider.test",
SecretKey: "secret",
ProjectID: 99,
StatusSuccess: "success",
StatusProcessing: "processing",
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
var callN atomic.Int32
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
n := callN.Add(1)
resp := apiResponse{}
resp.Operation.RequestID = fmt.Sprintf("req-%d", n)
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
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.setExecutionMode(newStrictIsolatedPayoutExecutionMode())
req1 := validCardPayoutRequest()
req1.PayoutId = ""
req1.OperationRef = "op-strict-1"
req1.ParentPaymentRef = "payment-strict-1"
req1.IdempotencyKey = "idem-strict-1"
req1.CardPan = "2204310000002456"
req2 := validCardPayoutRequest()
req2.PayoutId = ""
req2.OperationRef = "op-strict-2"
req2.ParentPaymentRef = "payment-strict-2"
req2.IdempotencyKey = "idem-strict-2"
req2.CardPan = "2204320000009754"
if _, err := processor.Submit(context.Background(), req1); err != nil {
t.Fatalf("first submit failed: %v", err)
}
secondDone := make(chan error, 1)
go func() {
_, err := processor.Submit(context.Background(), req2)
secondDone <- err
}()
select {
case err := <-secondDone:
t.Fatalf("second submit should block before first operation is final, err=%v", err)
case <-time.After(120 * time.Millisecond):
}
cb := baseCallback()
cb.Payment.ID = req1.GetOperationRef()
cb.Payment.Status = "success"
cb.Operation.Status = "success"
cb.Operation.Code = "0"
cb.Operation.Message = "Success"
cb.Payment.Sum.Currency = "RUB"
sig, err := provider.SignPayload(cb, cfg.SecretKey)
if err != nil {
t.Fatalf("failed to sign callback: %v", err)
}
cb.Signature = sig
payload, err := json.Marshal(cb)
if err != nil {
t.Fatalf("failed to marshal callback: %v", err)
}
status, err := processor.ProcessCallback(context.Background(), payload)
if err != nil {
t.Fatalf("callback failed: %v", err)
}
if status != http.StatusOK {
t.Fatalf("unexpected callback status: %d", status)
}
select {
case err := <-secondDone:
if err != nil {
t.Fatalf("second submit returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for second submit to unblock")
}
if got, want := callN.Load(), int32(2); got != want {
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
}
}
func TestCardPayoutProcessor_ProcessCallback_UpdatesMatchingOperationWithinSameParent(t *testing.T) {
cfg := provider.Config{
SecretKey: "secret",
StatusSuccess: "success",
StatusProcessing: "processing",
AllowedCurrencies: []string{"RUB"},
}
parentPaymentRef := "payment-parent-1"
op1 := parentPaymentRef + ":hop_4_card_payout_send"
op2 := parentPaymentRef + ":hop_4_card_payout_send_2"
now := time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC)
repo := newMockRepository()
repo.payouts.Save(&model.CardPayout{
PaymentRef: parentPaymentRef,
OperationRef: op1,
Status: model.PayoutStatusWaiting,
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now.Add(-time.Minute),
})
repo.payouts.Save(&model.CardPayout{
PaymentRef: parentPaymentRef,
OperationRef: op2,
Status: model.PayoutStatusWaiting,
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now.Add(-time.Minute),
})
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: now},
repo,
&http.Client{},
nil,
)
cb := baseCallback()
cb.Payment.ID = op2
cb.Payment.Status = "success"
cb.Operation.Status = "success"
cb.Operation.Code = "0"
cb.Operation.Provider.PaymentID = "provider-op-2"
cb.Payment.Sum.Currency = "RUB"
sig, err := provider.SignPayload(cb, cfg.SecretKey)
if err != nil {
t.Fatalf("failed to sign callback: %v", err)
}
cb.Signature = sig
payload, err := json.Marshal(cb)
if err != nil {
t.Fatalf("failed to marshal callback: %v", err)
}
status, err := processor.ProcessCallback(context.Background(), payload)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status ok, got %d", status)
}
first, err := repo.payouts.FindByOperationRef(context.Background(), op1)
if err != nil || first == nil {
t.Fatalf("expected first operation present, err=%v", err)
}
second, err := repo.payouts.FindByOperationRef(context.Background(), op2)
if err != nil || second == nil {
t.Fatalf("expected second operation present, err=%v", err)
}
if got, want := first.Status, model.PayoutStatusWaiting; got != want {
t.Fatalf("first operation status mismatch: got=%v want=%v", got, want)
}
if got, want := second.Status, model.PayoutStatusSuccess; got != want {
t.Fatalf("second operation status mismatch: got=%v want=%v", got, want)
}
if got, want := second.PaymentRef, parentPaymentRef; got != want {
t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want)
}
}
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *testing.T) {
t.Skip("aurora simulator uses deterministic scenario dispatch instead of mocked provider HTTP")
cfg := provider.Config{
BaseURL: "https://provider.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) {
n := calls.Add(1)
resp := apiResponse{}
if n == 1 {
resp.Code = providerCodeDeclineAmountOrFrequencyLimit
resp.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
}
resp.Operation.RequestID = "req-retry-success"
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
}),
}
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) time.Duration { return 10 * time.Millisecond }
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 accepted response when retry is scheduled")
}
deadline := time.Now().Add(2 * time.Second)
for {
state, ok := repo.payouts.Get(req.GetPayoutId())
if ok && state != nil && state.Status == model.PayoutStatusWaiting && state.ProviderPaymentID == "req-retry-success" {
break
}
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for successful retry result")
}
time.Sleep(20 * time.Millisecond)
}
if got, want := calls.Load(), int32(2); got != want {
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
}
}
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *testing.T) {
t.Skip("aurora simulator uses deterministic scenario dispatch instead of mocked provider HTTP")
cfg := provider.Config{
BaseURL: "https://provider.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 := apiResponse{
Code: providerCodeDeclineAmountOrFrequencyLimit,
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) time.Duration { return time.Millisecond }
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 accepted response when retry is scheduled")
}
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) {
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")
}
time.Sleep(10 * time.Millisecond)
}
if got, want := calls.Load(), int32(defaultMaxDispatchAttempts); got != want {
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
}
}
func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
t.Skip("aurora simulator does not run provider HTTP retry flow used by legacy transport tests")
cfg := provider.Config{
BaseURL: "https://provider.test",
SecretKey: "secret",
ProjectID: 99,
StatusSuccess: "success",
StatusProcessing: "processing",
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
var calls atomic.Int32
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
n := calls.Add(1)
resp := apiResponse{}
if n == 1 {
resp.Operation.RequestID = "req-initial"
} else {
resp.Operation.RequestID = "req-after-callback-retry"
}
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
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 2, 0, 0, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond }
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 accepted submit response")
}
cb := baseCallback()
cb.Payment.ID = req.GetPayoutId()
cb.Payment.Status = "failed"
cb.Operation.Status = "failed"
cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit
cb.Operation.Message = "Decline due to amount or frequency limit"
cb.Payment.Sum.Currency = "RUB"
sig, err := provider.SignPayload(cb, cfg.SecretKey)
if err != nil {
t.Fatalf("failed to sign callback: %v", err)
}
cb.Signature = sig
payload, err := json.Marshal(cb)
if err != nil {
t.Fatalf("failed to marshal callback: %v", err)
}
status, err := processor.ProcessCallback(context.Background(), payload)
if err != nil {
t.Fatalf("process callback returned error: %v", err)
}
if status != http.StatusOK {
t.Fatalf("unexpected callback status: %d", status)
}
deadline := time.Now().Add(2 * time.Second)
for {
state, ok := repo.payouts.Get(req.GetPayoutId())
if ok && state != nil && state.Status == model.PayoutStatusWaiting && state.ProviderPaymentID == "req-after-callback-retry" {
break
}
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for callback-scheduled retry result")
}
time.Sleep(10 * time.Millisecond)
}
if got, want := calls.Load(), int32(2); got != want {
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
}
}

View File

@@ -0,0 +1,66 @@
package gateway
import (
"strings"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg provider.Config) error {
if req == nil {
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil {
return err
}
if strings.TrimSpace(req.GetParentPaymentRef()) == "" {
return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" {
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
}
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
}
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
}
if strings.TrimSpace(req.GetCustomerIp()) == "" {
return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
}
if req.GetAmountMinor() <= 0 {
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor"))
}
currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency()))
if currency == "" {
return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency"))
}
if !cfg.CurrencyAllowed(currency) {
return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency"))
}
if strings.TrimSpace(req.GetCardToken()) == "" {
return newPayoutError("missing_card_token", merrors.InvalidArgument("card_token is required", "card_token"))
}
if cfg.RequireCustomerAddress {
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
}
if strings.TrimSpace(req.GetCustomerCity()) == "" {
return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
}
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
}
if strings.TrimSpace(req.GetCustomerZip()) == "" {
return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
}
}
return nil
}

View File

@@ -0,0 +1,106 @@
package gateway
import (
"testing"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func TestValidateCardTokenPayoutRequest_Valid(t *testing.T) {
cfg := testProviderConfig()
req := validCardTokenPayoutRequest()
if err := validateCardTokenPayoutRequest(req, cfg); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) {
baseCfg := testProviderConfig()
cases := []struct {
name string
mutate func(*mntxv1.CardTokenPayoutRequest)
config func(provider.Config) provider.Config
expected string
}{
{
name: "missing_operation_identity",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" },
expected: "missing_operation_ref",
},
{
name: "missing_parent_payment_ref",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.ParentPaymentRef = "" },
expected: "missing_parent_payment_ref",
},
{
name: "both_operation_and_payout_identity",
mutate: func(r *mntxv1.CardTokenPayoutRequest) {
r.PayoutId = "parent-1"
r.OperationRef = "parent-1:hop_1_card_payout_send"
},
expected: "ambiguous_operation_ref",
},
{
name: "missing_customer_id",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerId = "" },
expected: "missing_customer_id",
},
{
name: "missing_customer_ip",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerIp = "" },
expected: "missing_customer_ip",
},
{
name: "invalid_amount",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.AmountMinor = 0 },
expected: "invalid_amount",
},
{
name: "missing_currency",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "" },
expected: "missing_currency",
},
{
name: "unsupported_currency",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "EUR" },
config: func(cfg provider.Config) provider.Config {
cfg.AllowedCurrencies = []string{"USD"}
return cfg
},
expected: "unsupported_currency",
},
{
name: "missing_card_token",
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CardToken = "" },
expected: "missing_card_token",
},
{
name: "missing_customer_city_when_required",
mutate: func(r *mntxv1.CardTokenPayoutRequest) {
r.CustomerCountry = "US"
r.CustomerCity = ""
r.CustomerAddress = "Main St"
r.CustomerZip = "12345"
},
config: func(cfg provider.Config) provider.Config {
cfg.RequireCustomerAddress = true
return cfg
},
expected: "missing_customer_city",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := validCardTokenPayoutRequest()
tc.mutate(req)
cfg := baseCfg
if tc.config != nil {
cfg = tc.config(cfg)
}
err := validateCardTokenPayoutRequest(req, cfg)
requireReason(t, err, tc.expected)
})
}
}

View File

@@ -0,0 +1,108 @@
package gateway
import (
"strings"
"time"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
type tokenizeCardInput struct {
pan string
month uint32
year uint32
holder string
cvv string
}
func validateCardTokenizeRequest(req *mntxv1.CardTokenizeRequest, cfg provider.Config) (*tokenizeCardInput, error) {
if req == nil {
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
}
if strings.TrimSpace(req.GetRequestId()) == "" {
return nil, newPayoutError("missing_request_id", merrors.InvalidArgument("request_id is required", "request_id"))
}
if strings.TrimSpace(req.GetCustomerId()) == "" {
return nil, newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
}
if strings.TrimSpace(req.GetCustomerFirstName()) == "" {
return nil, newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name"))
}
if strings.TrimSpace(req.GetCustomerLastName()) == "" {
return nil, newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name"))
}
if strings.TrimSpace(req.GetCustomerIp()) == "" {
return nil, newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip"))
}
card := extractTokenizeCard(req)
if card.pan == "" {
return nil, newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card.pan"))
}
if card.holder == "" {
return nil, newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card.holder"))
}
if card.month == 0 || card.month > 12 {
return nil, newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card.exp_month"))
}
if card.year == 0 {
return nil, newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card.exp_year"))
}
if card.cvv == "" {
return nil, newPayoutError("missing_cvv", merrors.InvalidArgument("card_cvv is required", "card.cvv"))
}
if expired(card.month, card.year) {
return nil, newPayoutError("expired_card", merrors.InvalidArgument("card expiry is in the past", "card.expiry"))
}
if cfg.RequireCustomerAddress {
if strings.TrimSpace(req.GetCustomerCountry()) == "" {
return nil, newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country"))
}
if strings.TrimSpace(req.GetCustomerCity()) == "" {
return nil, newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city"))
}
if strings.TrimSpace(req.GetCustomerAddress()) == "" {
return nil, newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address"))
}
if strings.TrimSpace(req.GetCustomerZip()) == "" {
return nil, newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip"))
}
}
return card, nil
}
func extractTokenizeCard(req *mntxv1.CardTokenizeRequest) *tokenizeCardInput {
card := req.GetCard()
if card != nil {
return &tokenizeCardInput{
pan: strings.TrimSpace(card.GetPan()),
month: card.GetExpMonth(),
year: card.GetExpYear(),
holder: strings.TrimSpace(card.GetCardHolder()),
cvv: strings.TrimSpace(card.GetCvv()),
}
}
return &tokenizeCardInput{
pan: strings.TrimSpace(req.GetCardPan()),
month: req.GetCardExpMonth(),
year: req.GetCardExpYear(),
holder: strings.TrimSpace(req.GetCardHolder()),
cvv: strings.TrimSpace(req.GetCardCvv()),
}
}
func expired(month uint32, year uint32) bool {
now := time.Now()
y := int(year)
m := time.Month(month)
// Normalize 2-digit years: assume 2000-2099.
if y < 100 {
y += 2000
}
expiry := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, -1)
return now.After(expiry)
}

View File

@@ -0,0 +1,76 @@
package gateway
import (
"testing"
"time"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func TestValidateCardTokenizeRequest_ValidTopLevel(t *testing.T) {
cfg := testProviderConfig()
req := validCardTokenizeRequest()
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestValidateCardTokenizeRequest_ValidNestedCard(t *testing.T) {
cfg := testProviderConfig()
req := validCardTokenizeRequest()
req.Card = &mntxv1.CardDetails{
Pan: "4111111111111111",
ExpMonth: req.CardExpMonth,
ExpYear: req.CardExpYear,
CardHolder: req.CardHolder,
Cvv: req.CardCvv,
}
req.CardPan = ""
req.CardExpMonth = 0
req.CardExpYear = 0
req.CardHolder = ""
req.CardCvv = ""
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestValidateCardTokenizeRequest_Expired(t *testing.T) {
cfg := testProviderConfig()
req := validCardTokenizeRequest()
now := time.Now().UTC()
req.CardExpMonth = uint32(now.Month())
req.CardExpYear = uint32(now.Year() - 1)
_, err := validateCardTokenizeRequest(req, cfg)
requireReason(t, err, "expired_card")
}
func TestValidateCardTokenizeRequest_MissingCvv(t *testing.T) {
cfg := testProviderConfig()
req := validCardTokenizeRequest()
req.CardCvv = ""
_, err := validateCardTokenizeRequest(req, cfg)
requireReason(t, err, "missing_cvv")
}
func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) {
cfg := testProviderConfig()
req := validCardTokenizeRequest()
req.CardPan = ""
_, err := validateCardTokenizeRequest(req, cfg)
requireReason(t, err, "missing_card_pan")
}
func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) {
cfg := testProviderConfig()
cfg.RequireCustomerAddress = true
req := validCardTokenizeRequest()
req.CustomerCountry = ""
_, err := validateCardTokenizeRequest(req, cfg)
requireReason(t, err, "missing_customer_country")
}

View File

@@ -0,0 +1,388 @@
package gateway
import (
"context"
"errors"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/aurora/internal/appversion"
"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"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/types/known/structpb"
)
const connectorTypeID = "mntx"
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: connectorTypeID,
Version: appversion.Create().Short(),
SupportedAccountKinds: nil,
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT},
OperationParams: connectorOperationParams(),
},
}, 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 {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
}
op := req.GetOperation()
idempotencyKey := strings.TrimSpace(op.GetIdempotencyKey())
if idempotencyKey == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
}
operationRef := strings.TrimSpace(op.GetOperationRef())
if operationRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation_ref is required", op, "")}}, nil
}
intentRef := strings.TrimSpace(op.GetIntentRef())
if intentRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: intent_ref is required", op, "")}}, nil
}
if op.GetType() != connectorv1.OperationType_PAYOUT {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
}
reader := params.New(op.GetParams())
amountMinor, currency, err := payoutAmount(op, reader)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
parentPaymentRef := strings.TrimSpace(reader.String("parent_payment_ref"))
payoutID := operationIDForRequest(operationRef)
if strings.TrimSpace(reader.String("card_token")) != "" {
resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency))
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
}
cr := buildCardPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency)
resp, err := s.CreateCardPayout(ctx, cr)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
}
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
operationRef := strings.TrimSpace(req.GetOperationId())
if s.storage == nil || s.storage.Payouts() == nil {
return nil, merrors.Internal("get_operation: storage is not configured")
}
payout, err := s.storage.Payouts().FindByOperationRef(ctx, operationRef)
if err != nil {
return nil, err
}
if payout == nil {
return nil, merrors.NoData("payout not found")
}
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(StateToProto(payout))}, nil
}
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
return nil, merrors.NotImplemented("list_operations: unsupported")
}
func connectorOperationParams() []*connectorv1.OperationParamSpec {
return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_PAYOUT, Params: []*connectorv1.ParamSpec{
{Key: "customer_id", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_first_name", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_last_name", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_ip", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "card_token", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "card_pan", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "card_exp_year", Type: connectorv1.ParamType_INT, Required: false},
{Key: "card_exp_month", Type: connectorv1.ParamType_INT, Required: false},
{Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false},
{Key: "project_id", Type: connectorv1.ParamType_INT, Required: false},
{Key: "parent_payment_ref", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_city", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_address", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_zip", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "masked_pan", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
}},
}
}
func payoutAmount(op *connectorv1.Operation, reader params.Reader) (int64, string, error) {
if op == nil {
return 0, "", merrors.InvalidArgument("payout: operation is required")
}
currency := currencyFromOperation(op)
if currency == "" {
return 0, "", merrors.InvalidArgument("payout: currency is required")
}
if minor, ok := reader.Int64("amount_minor"); ok && minor > 0 {
return minor, currency, nil
}
money := op.GetMoney()
if money == nil {
return 0, "", merrors.InvalidArgument("payout: money is required")
}
amount := strings.TrimSpace(money.GetAmount())
if amount == "" {
return 0, "", merrors.InvalidArgument("payout: amount is required")
}
dec, err := decimal.NewFromString(amount)
if err != nil {
return 0, "", merrors.InvalidArgument("payout: invalid amount")
}
minor := dec.Mul(decimal.NewFromInt(100)).IntPart()
return minor, currency, nil
}
func currencyFromOperation(op *connectorv1.Operation) string {
if op == nil || op.GetMoney() == nil {
return ""
}
currency := strings.TrimSpace(op.GetMoney().GetCurrency())
if idx := strings.Index(currency, "-"); idx > 0 {
currency = currency[:idx]
}
return strings.ToUpper(currency)
}
func operationIDForRequest(operationRef string) string {
return strings.TrimSpace(operationRef)
}
func metadataFromReader(reader params.Reader) map[string]string {
metadata := reader.StringMap("metadata")
if len(metadata) == 0 {
return nil
}
return metadata
}
func buildCardTokenPayoutRequestFromParams(reader params.Reader,
payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string,
amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest {
operationRef = strings.TrimSpace(operationRef)
payoutID = strings.TrimSpace(payoutID)
if operationRef != "" {
payoutID = ""
}
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
CustomerState: strings.TrimSpace(reader.String("customer_state")),
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
AmountMinor: amountMinor,
Currency: currency,
CardToken: strings.TrimSpace(reader.String("card_token")),
CardHolder: strings.TrimSpace(reader.String("card_holder")),
MaskedPan: strings.TrimSpace(reader.String("masked_pan")),
Metadata: metadataFromReader(reader),
OperationRef: operationRef,
IdempotencyKey: strings.TrimSpace(idempotencyKey),
IntentRef: strings.TrimSpace(intentRef),
}
return req
}
func buildCardPayoutRequestFromParams(reader params.Reader,
payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string,
amountMinor int64, currency string) *mntxv1.CardPayoutRequest {
operationRef = strings.TrimSpace(operationRef)
payoutID = strings.TrimSpace(payoutID)
if operationRef != "" {
payoutID = ""
}
return &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
CustomerState: strings.TrimSpace(reader.String("customer_state")),
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
AmountMinor: amountMinor,
Currency: currency,
CardPan: strings.TrimSpace(reader.String("card_pan")),
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
CardHolder: strings.TrimSpace(reader.String("card_holder")),
Metadata: metadataFromReader(reader),
OperationRef: operationRef,
IdempotencyKey: strings.TrimSpace(idempotencyKey),
IntentRef: strings.TrimSpace(intentRef),
}
}
func readerInt64(reader params.Reader, key string) int64 {
if v, ok := reader.Int64(key); ok {
return v
}
return 0
}
func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt {
if state == nil {
return &connectorv1.OperationReceipt{
Status: connectorv1.OperationStatus_OPERATION_PROCESSING,
}
}
return &connectorv1.OperationReceipt{
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
Status: payoutStatusToOperation(state.GetStatus()),
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
}
}
func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
if state == nil {
return nil
}
op := &connectorv1.Operation{
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
Type: connectorv1.OperationType_PAYOUT,
Status: payoutStatusToOperation(state.GetStatus()),
Money: &moneyv1.Money{
Amount: minorToDecimal(state.GetAmountMinor()),
Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())),
},
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
IntentRef: strings.TrimSpace(state.GetIntentRef()),
OperationRef: strings.TrimSpace(state.GetOperationRef()),
CreatedAt: state.GetCreatedAt(),
UpdatedAt: state.GetUpdatedAt(),
}
params := map[string]interface{}{}
if paymentRef := strings.TrimSpace(state.GetParentPaymentRef()); paymentRef != "" {
params["payment_ref"] = paymentRef
params["parent_payment_ref"] = paymentRef
}
if providerCode := strings.TrimSpace(state.GetProviderCode()); providerCode != "" {
params["provider_code"] = providerCode
}
if providerMessage := strings.TrimSpace(state.GetProviderMessage()); providerMessage != "" {
params["provider_message"] = providerMessage
params["failure_reason"] = providerMessage
}
if len(params) > 0 {
op.Params = structFromMap(params)
}
return op
}
func minorToDecimal(amount int64) string {
dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100))
return dec.StringFixed(2)
}
func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return connectorv1.OperationStatus_OPERATION_FAILED
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
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 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,99 @@
package gateway
import (
"strings"
"time"
"github.com/tech/sendico/gateway/aurora/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func tsOrNow(clock clockpkg.Clock, ts *timestamppb.Timestamp) time.Time {
if ts == nil {
return clock.Now()
}
return ts.AsTime()
}
func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *model.CardPayout {
if p == nil {
return nil
}
return &model.CardPayout{
PaymentRef: strings.TrimSpace(p.GetParentPaymentRef()),
OperationRef: p.GetOperationRef(),
IntentRef: p.GetIntentRef(),
IdempotencyKey: p.GetIdempotencyKey(),
ProjectID: p.ProjectId,
CustomerID: p.CustomerId,
AmountMinor: p.AmountMinor,
Currency: p.Currency,
Status: payoutStatusFromProto(p.Status),
ProviderCode: p.ProviderCode,
ProviderMessage: p.ProviderMessage,
ProviderPaymentID: p.ProviderPaymentId,
CreatedAt: tsOrNow(clock, p.CreatedAt),
UpdatedAt: tsOrNow(clock, p.UpdatedAt),
}
}
func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState {
return &mntxv1.CardPayoutState{
PayoutId: firstNonEmpty(m.OperationRef, m.PaymentRef),
ParentPaymentRef: m.PaymentRef,
ProjectId: m.ProjectID,
CustomerId: m.CustomerID,
AmountMinor: m.AmountMinor,
Currency: m.Currency,
Status: payoutStatusToProto(m.Status),
ProviderCode: m.ProviderCode,
ProviderMessage: m.ProviderMessage,
ProviderPaymentId: m.ProviderPaymentID,
CreatedAt: timestamppb.New(m.CreatedAt),
UpdatedAt: timestamppb.New(m.UpdatedAt),
}
}
func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
switch s {
case model.PayoutStatusCreated:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
case model.PayoutStatusProcessing:
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
case model.PayoutStatusWaiting:
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
case model.PayoutStatusSuccess:
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
case model.PayoutStatusFailed:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case model.PayoutStatusCancelled:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
default:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
}
}
func payoutStatusFromProto(s mntxv1.PayoutStatus) model.PayoutStatus {
switch s {
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
return model.PayoutStatusCreated
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
return model.PayoutStatusWaiting
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
return model.PayoutStatusSuccess
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PayoutStatusFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
return model.PayoutStatusCancelled
default:
return model.PayoutStatusCreated
}
}

View File

@@ -0,0 +1,62 @@
package gateway
import (
"context"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/proto"
)
// ListGatewayInstances exposes the Aurora gateway instance descriptors.
func (s *Service) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
return executeUnary(ctx, s, "ListGatewayInstances", s.handleListGatewayInstances, req)
}
func (s *Service) handleListGatewayInstances(_ context.Context, _ *mntxv1.ListGatewayInstancesRequest) gsresponse.Responder[mntxv1.ListGatewayInstancesResponse] {
items := make([]*gatewayv1.GatewayInstanceDescriptor, 0, 1)
if s.gatewayDescriptor != nil {
items = append(items, cloneGatewayDescriptor(s.gatewayDescriptor))
}
return gsresponse.Success(&mntxv1.ListGatewayInstancesResponse{Items: items})
}
func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1.GatewayInstanceDescriptor {
if src == nil {
return nil
}
cp := proto.Clone(src).(*gatewayv1.GatewayInstanceDescriptor)
if src.Currencies != nil {
cp.Currencies = append([]string(nil), src.Currencies...)
}
if src.Capabilities != nil {
cp.Capabilities = proto.Clone(src.Capabilities).(*gatewayv1.RailCapabilities)
}
if src.Limits != nil {
limits := &gatewayv1.Limits{}
if src.Limits.VolumeLimit != nil {
limits.VolumeLimit = map[string]string{}
for key, value := range src.Limits.VolumeLimit {
limits.VolumeLimit[key] = value
}
}
if src.Limits.VelocityLimit != nil {
limits.VelocityLimit = map[string]int32{}
for key, value := range src.Limits.VelocityLimit {
limits.VelocityLimit[key] = value
}
}
if src.Limits.CurrencyLimits != nil {
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
for key, value := range src.Limits.CurrencyLimits {
if value == nil {
continue
}
limits.CurrencyLimits[key] = proto.Clone(value).(*gatewayv1.LimitsOverride)
}
}
cp.Limits = limits
}
return cp
}

View File

@@ -0,0 +1,66 @@
package gateway
import (
"errors"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/tech/sendico/pkg/merrors"
)
var (
metricsOnce sync.Once
rpcLatency *prometheus.HistogramVec
rpcStatus *prometheus.CounterVec
)
func initMetrics() {
metricsOnce.Do(func() {
rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "sendico",
Subsystem: "aurora_gateway",
Name: "rpc_latency_seconds",
Help: "Latency distribution for Aurora gateway RPC handlers.",
Buckets: prometheus.DefBuckets,
}, []string{"method"})
rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "aurora_gateway",
Name: "rpc_requests_total",
Help: "Total number of RPC invocations grouped by method and status.",
}, []string{"method", "status"})
})
}
func observeRPC(method string, err error, duration time.Duration) {
if rpcLatency != nil {
rpcLatency.WithLabelValues(method).Observe(duration.Seconds())
}
if rpcStatus != nil {
rpcStatus.WithLabelValues(method, statusLabel(err)).Inc()
}
}
func statusLabel(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, merrors.ErrInvalidArg):
return "invalid_argument"
case errors.Is(err, merrors.ErrNoData):
return "not_found"
case errors.Is(err, merrors.ErrDataConflict):
return "conflict"
case errors.Is(err, merrors.ErrAccessDenied):
return "denied"
case errors.Is(err, merrors.ErrInternal):
return "internal"
default:
return "error"
}
}

View File

@@ -0,0 +1,86 @@
package gateway
import (
"net/http"
"strings"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/gateway/aurora/storage"
"github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
pmodel "github.com/tech/sendico/pkg/model"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
)
// Option configures optional service dependencies.
type Option func(*Service)
// WithClock injects a custom clock (useful for tests).
func WithClock(c clock.Clock) Option {
return func(s *Service) {
if c != nil {
s.clock = c
}
}
}
// WithProducer attaches a messaging producer to the service.
func WithProducer(p msg.Producer) Option {
return func(s *Service) {
s.producer = p
}
}
func WithStorage(storage storage.Repository) Option {
return func(s *Service) {
s.storage = storage
}
}
// WithHTTPClient injects a custom HTTP client (useful for tests).
func WithHTTPClient(client *http.Client) Option {
return func(s *Service) {
if client != nil {
s.httpClient = client
}
}
}
// WithProviderConfig sets provider integration options.
func WithProviderConfig(cfg provider.Config) Option {
return func(s *Service) {
s.config = cfg
}
}
// WithGatewayDescriptor sets the self-declared gateway instance descriptor.
func WithGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) Option {
return func(s *Service) {
if descriptor != nil {
s.gatewayDescriptor = descriptor
}
}
}
// WithDiscoveryInvokeURI sets the invoke URI used when announcing the gateway.
func WithDiscoveryInvokeURI(invokeURI string) Option {
return func(s *Service) {
s.invokeURI = strings.TrimSpace(invokeURI)
}
}
// WithMessagingSettings applies messaging driver settings.
func WithMessagingSettings(settings pmodel.SettingsT) Option {
return func(s *Service) {
if settings != nil {
s.msgCfg = settings
}
}
}
// WithStrictOperationIsolation serialises payout processing to one unresolved operation at a time.
func WithStrictOperationIsolation(enabled bool) Option {
return func(s *Service) {
s.strictIsolation = enabled
}
}

View File

@@ -0,0 +1,50 @@
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 outboxProvider interface {
Outbox() gatewayoutbox.Store
}
type transactionProvider interface {
TransactionFactory() transaction.Factory
}
func (p *cardPayoutProcessor) outboxStore() gatewayoutbox.Store {
provider, ok := p.store.(outboxProvider)
if !ok || provider == nil {
return nil
}
return provider.Outbox()
}
func (p *cardPayoutProcessor) startOutboxReliableProducer() error {
if p == nil || p.outbox == nil {
return nil
}
return p.outbox.Start(p.logger, p.producer, p.outboxStore(), p.msgCfg)
}
func (p *cardPayoutProcessor) sendWithOutbox(ctx context.Context, env me.Envelope) error {
if err := p.startOutboxReliableProducer(); err != nil {
return err
}
if p.outbox == nil {
return nil
}
return p.outbox.Send(ctx, env)
}
func (p *cardPayoutProcessor) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
provider, ok := p.store.(transactionProvider)
if !ok || provider == nil || provider.TransactionFactory() == nil {
return cb(ctx)
}
return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb)
}

View File

@@ -0,0 +1,168 @@
package gateway
import (
"context"
"errors"
"strings"
"sync"
"github.com/tech/sendico/gateway/aurora/storage/model"
)
const (
payoutExecutionModeDefaultName = "default"
payoutExecutionModeStrictIsolatedName = "strict_isolated"
)
var errPayoutExecutionModeStopped = errors.New("payout execution mode stopped")
type payoutExecutionMode interface {
Name() string
BeforeDispatch(ctx context.Context, operationRef string) error
OnPersistedState(operationRef string, status model.PayoutStatus)
Shutdown()
}
type defaultPayoutExecutionMode struct{}
func newDefaultPayoutExecutionMode() payoutExecutionMode {
return &defaultPayoutExecutionMode{}
}
func (m *defaultPayoutExecutionMode) Name() string {
return payoutExecutionModeDefaultName
}
func (m *defaultPayoutExecutionMode) BeforeDispatch(_ context.Context, _ string) error {
return nil
}
func (m *defaultPayoutExecutionMode) OnPersistedState(_ string, _ model.PayoutStatus) {}
func (m *defaultPayoutExecutionMode) Shutdown() {}
type strictIsolatedPayoutExecutionMode struct {
mu sync.Mutex
activeOperation string
waitCh chan struct{}
stopped bool
}
func newStrictIsolatedPayoutExecutionMode() payoutExecutionMode {
return &strictIsolatedPayoutExecutionMode{
waitCh: make(chan struct{}),
}
}
func (m *strictIsolatedPayoutExecutionMode) Name() string {
return payoutExecutionModeStrictIsolatedName
}
func (m *strictIsolatedPayoutExecutionMode) BeforeDispatch(ctx context.Context, operationRef string) error {
opRef := strings.TrimSpace(operationRef)
if opRef == "" {
return nil
}
if ctx == nil {
ctx = context.Background()
}
for {
waitCh, allowed, err := m.tryAcquire(opRef)
if allowed {
return nil
}
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-waitCh:
}
}
}
func (m *strictIsolatedPayoutExecutionMode) OnPersistedState(operationRef string, status model.PayoutStatus) {
opRef := strings.TrimSpace(operationRef)
if opRef == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.stopped {
return
}
if isFinalPayoutStatus(status) {
if m.activeOperation == opRef {
m.activeOperation = ""
m.signalLocked()
}
return
}
if m.activeOperation == "" {
m.activeOperation = opRef
m.signalLocked()
}
}
func (m *strictIsolatedPayoutExecutionMode) Shutdown() {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopped {
return
}
m.stopped = true
m.activeOperation = ""
m.signalLocked()
}
func (m *strictIsolatedPayoutExecutionMode) tryAcquire(operationRef string) (<-chan struct{}, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopped {
return nil, false, errPayoutExecutionModeStopped
}
switch owner := strings.TrimSpace(m.activeOperation); {
case owner == "":
m.activeOperation = operationRef
m.signalLocked()
return nil, true, nil
case owner == operationRef:
return nil, true, nil
default:
return m.waitCh, false, nil
}
}
func (m *strictIsolatedPayoutExecutionMode) signalLocked() {
if m.waitCh == nil {
m.waitCh = make(chan struct{})
return
}
close(m.waitCh)
m.waitCh = make(chan struct{})
}
func normalizePayoutExecutionMode(mode payoutExecutionMode) payoutExecutionMode {
if mode == nil {
return newDefaultPayoutExecutionMode()
}
return mode
}
func payoutExecutionModeName(mode payoutExecutionMode) string {
if mode == nil {
return payoutExecutionModeDefaultName
}
name := strings.TrimSpace(mode.Name())
if name == "" {
return payoutExecutionModeDefaultName
}
return name
}

View File

@@ -0,0 +1,58 @@
package gateway
import (
"context"
"testing"
"time"
"github.com/tech/sendico/gateway/aurora/storage/model"
)
func TestStrictIsolatedPayoutExecutionMode_BlocksOtherOperationUntilFinalStatus(t *testing.T) {
mode := newStrictIsolatedPayoutExecutionMode()
if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil {
t.Fatalf("first acquire failed: %v", err)
}
waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
defer waitCancel()
secondDone := make(chan error, 1)
go func() {
secondDone <- mode.BeforeDispatch(waitCtx, "op-2")
}()
select {
case err := <-secondDone:
t.Fatalf("second operation should be blocked before final status, got err=%v", err)
case <-time.After(80 * time.Millisecond):
}
mode.OnPersistedState("op-1", model.PayoutStatusWaiting)
select {
case err := <-secondDone:
t.Fatalf("second operation should remain blocked on non-final status, got err=%v", err)
case <-time.After(80 * time.Millisecond):
}
mode.OnPersistedState("op-1", model.PayoutStatusSuccess)
select {
case err := <-secondDone:
if err != nil {
t.Fatalf("second operation should proceed after final status, got err=%v", err)
}
case <-time.After(time.Second):
t.Fatalf("timeout waiting for second operation to proceed")
}
}
func TestStrictIsolatedPayoutExecutionMode_AllowsSameOperationReentry(t *testing.T) {
mode := newStrictIsolatedPayoutExecutionMode()
if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil {
t.Fatalf("first acquire failed: %v", err)
}
if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil {
t.Fatalf("same operation should be re-entrant, got err=%v", err)
}
}

View File

@@ -0,0 +1,87 @@
package gateway
import (
"strings"
)
const (
providerCodeDeclineAmountOrFrequencyLimit = "10101"
)
type payoutFailureAction int
const (
payoutFailureActionFail payoutFailureAction = iota + 1
payoutFailureActionRetry
)
type payoutFailureDecision struct {
Action payoutFailureAction
Reason string
}
type payoutFailurePolicy struct {
providerCodeActions map[string]payoutFailureAction
}
func defaultPayoutFailurePolicy() payoutFailurePolicy {
return payoutFailurePolicy{
providerCodeActions: map[string]payoutFailureAction{
providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry,
},
}
}
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
normalized := strings.TrimSpace(code)
if normalized == "" {
return payoutFailureDecision{
Action: payoutFailureActionFail,
Reason: "provider_failure",
}
}
if action, ok := p.providerCodeActions[normalized]; ok {
return payoutFailureDecision{
Action: action,
Reason: "provider_code_" + normalized,
}
}
return payoutFailureDecision{
Action: payoutFailureActionFail,
Reason: "provider_code_" + normalized,
}
}
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
return payoutFailureDecision{
Action: payoutFailureActionRetry,
Reason: "transport_failure",
}
}
func payoutFailureReason(code, message string) string {
cleanCode := strings.TrimSpace(code)
cleanMessage := strings.TrimSpace(message)
switch {
case cleanCode != "" && cleanMessage != "":
return cleanCode + ": " + cleanMessage
case cleanCode != "":
return cleanCode
default:
return cleanMessage
}
}
func retryDelayForAttempt(attempt uint32) int {
// Backoff in seconds by attempt number (attempt starts at 1).
switch {
case attempt <= 1:
return 5
case attempt == 2:
return 15
case attempt == 3:
return 30
default:
return 60
}
}

View File

@@ -0,0 +1,52 @@
package gateway
import "testing"
func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
policy := defaultPayoutFailurePolicy()
cases := []struct {
name string
code string
action payoutFailureAction
}{
{
name: "retryable provider limit code",
code: providerCodeDeclineAmountOrFrequencyLimit,
action: payoutFailureActionRetry,
},
{
name: "unknown provider code",
code: "99999",
action: payoutFailureActionFail,
},
{
name: "empty provider code",
code: "",
action: payoutFailureActionFail,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Helper()
got := policy.decideProviderFailure(tc.code)
if got.Action != tc.action {
t.Fatalf("action mismatch: got=%v want=%v", got.Action, tc.action)
}
})
}
}
func TestPayoutFailureReason(t *testing.T) {
if got, want := payoutFailureReason("10101", "Decline due to amount or frequency limit"), "10101: Decline due to amount or frequency limit"; got != want {
t.Fatalf("failure reason mismatch: got=%q want=%q", got, want)
}
if got, want := payoutFailureReason("", "network error"), "network error"; got != want {
t.Fatalf("failure reason mismatch: got=%q want=%q", got, want)
}
if got, want := payoutFailureReason("10101", ""), "10101"; got != want {
t.Fatalf("failure reason mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -0,0 +1,243 @@
package gateway
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"strings"
"sync/atomic"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/pkg/merrors"
)
type simulatedCardScenario struct {
Name string
CardNumbers []string
CardLast4 []string
Accepted bool
ProviderStatus string
ErrorCode string
ErrorMessage string
DispatchError string
}
type payoutSimulator struct {
scenarios []simulatedCardScenario
defaultScenario simulatedCardScenario
seq atomic.Uint64
}
func newPayoutSimulator() *payoutSimulator {
return &payoutSimulator{
scenarios: []simulatedCardScenario{
{
Name: "approved_instant",
CardNumbers: []string{"2200001111111111"},
Accepted: true,
ProviderStatus: "success",
ErrorCode: "00",
ErrorMessage: "Approved by issuer",
},
{
Name: "pending_issuer_review",
CardNumbers: []string{"2200002222222222"},
Accepted: true,
ProviderStatus: "processing",
ErrorCode: "P01",
ErrorMessage: "Pending issuer review",
},
{
Name: "insufficient_funds",
CardNumbers: []string{"2200003333333333"},
CardLast4: []string{"3333"},
Accepted: false,
ErrorCode: "51",
ErrorMessage: "Insufficient funds",
},
{
Name: "issuer_unavailable_retryable",
CardNumbers: []string{"2200004444444444"},
CardLast4: []string{"4444"},
Accepted: false,
ErrorCode: "10101",
ErrorMessage: "Issuer temporary unavailable, retry later",
},
{
Name: "stolen_card",
CardNumbers: []string{"2200005555555555"},
CardLast4: []string{"5555"},
Accepted: false,
ErrorCode: "43",
ErrorMessage: "Stolen card, pickup",
},
{
Name: "do_not_honor",
CardNumbers: []string{"2200006666666666"},
CardLast4: []string{"6666"},
Accepted: false,
ErrorCode: "05",
ErrorMessage: "Do not honor",
},
{
Name: "expired_card",
CardNumbers: []string{"2200007777777777"},
CardLast4: []string{"7777"},
Accepted: false,
ErrorCode: "54",
ErrorMessage: "Expired card",
},
{
Name: "provider_timeout_transport",
CardNumbers: []string{"2200008888888888"},
CardLast4: []string{"8888"},
DispatchError: "provider timeout while calling payout endpoint",
},
{
Name: "provider_unreachable_transport",
CardNumbers: []string{"2200009999999998"},
CardLast4: []string{"9998"},
DispatchError: "provider host unreachable",
},
{
Name: "provider_maintenance",
CardNumbers: []string{"2200009999999997"},
CardLast4: []string{"9997"},
Accepted: false,
ErrorCode: "91",
ErrorMessage: "Issuer or switch is inoperative",
},
{
Name: "provider_system_malfunction",
CardNumbers: []string{"2200009999999996"},
CardLast4: []string{"9996"},
Accepted: false,
ErrorCode: "96",
ErrorMessage: "System malfunction",
},
},
defaultScenario: simulatedCardScenario{
Name: "default_processing",
Accepted: true,
ProviderStatus: "processing",
ErrorCode: "P00",
ErrorMessage: "Queued for provider processing",
},
}
}
func (s *payoutSimulator) resolveByPAN(pan string) simulatedCardScenario {
return s.resolve(normalizeCardNumber(pan), "")
}
func (s *payoutSimulator) resolveByMaskedPAN(masked string) simulatedCardScenario {
digits := normalizeCardNumber(masked)
last4 := ""
if len(digits) >= 4 {
last4 = digits[len(digits)-4:]
}
return s.resolve("", last4)
}
func (s *payoutSimulator) resolve(pan, last4 string) simulatedCardScenario {
if s == nil {
return simulatedCardScenario{}
}
for _, scenario := range s.scenarios {
for _, value := range scenario.CardNumbers {
if pan != "" && normalizeCardNumber(value) == pan {
return scenario
}
}
}
if strings.TrimSpace(last4) != "" {
for _, scenario := range s.scenarios {
if scenarioMatchesLast4(scenario, last4) {
return scenario
}
}
}
return s.defaultScenario
}
func (s *payoutSimulator) buildPayoutResult(operationRef string, scenario simulatedCardScenario) (*provider.CardPayoutSendResult, error) {
if s == nil {
return &provider.CardPayoutSendResult{
Accepted: true,
StatusCode: 200,
ErrorCode: "P00",
ErrorMessage: "Queued for provider processing",
}, nil
}
if msg := strings.TrimSpace(scenario.DispatchError); msg != "" {
return nil, merrors.Internal("aurora simulated transport error: " + msg)
}
id := s.seq.Add(1)
ref := strings.TrimSpace(operationRef)
if ref == "" {
ref = "card-op"
}
statusCode := 200
if !scenario.Accepted {
statusCode = 422
}
return &provider.CardPayoutSendResult{
Accepted: scenario.Accepted,
ProviderRequestID: fmt.Sprintf("aurora-%s-%06d", ref, id),
ProviderStatus: strings.TrimSpace(scenario.ProviderStatus),
StatusCode: statusCode,
ErrorCode: strings.TrimSpace(scenario.ErrorCode),
ErrorMessage: strings.TrimSpace(scenario.ErrorMessage),
}, nil
}
func scenarioMatchesLast4(scenario simulatedCardScenario, last4 string) bool {
candidate := strings.TrimSpace(last4)
if candidate == "" {
return false
}
for _, value := range scenario.CardLast4 {
if normalizeCardNumber(value) == candidate {
return true
}
}
for _, value := range scenario.CardNumbers {
normalized := normalizeCardNumber(value)
if len(normalized) >= 4 && normalized[len(normalized)-4:] == candidate {
return true
}
}
return false
}
func normalizeCardNumber(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var b strings.Builder
b.Grow(len(value))
for _, r := range value {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
func normalizeExpiryYear(year uint32) string {
if year == 0 {
return ""
}
v := int(year)
if v < 100 {
v += 2000
}
return fmt.Sprintf("%04d", v)
}
func buildSimulatedCardToken(requestID, pan string) string {
input := strings.TrimSpace(requestID) + "|" + normalizeCardNumber(pan)
sum := sha1.Sum([]byte(input))
return "aur_tok_" + hex.EncodeToString(sum[:8])
}

View File

@@ -0,0 +1,51 @@
package gateway
import (
"testing"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/gateway/aurora/storage/model"
)
func TestPayoutSimulatorResolveByPAN_KnownCard(t *testing.T) {
sim := newPayoutSimulator()
scenario := sim.resolveByPAN("2200003333333333")
if scenario.Name != "insufficient_funds" {
t.Fatalf("unexpected scenario: got=%q", scenario.Name)
}
if scenario.ErrorCode != "51" {
t.Fatalf("unexpected error code: got=%q", scenario.ErrorCode)
}
}
func TestPayoutSimulatorResolveByPAN_Default(t *testing.T) {
sim := newPayoutSimulator()
scenario := sim.resolveByPAN("2200009999999999")
if scenario.Name != "default_processing" {
t.Fatalf("unexpected default scenario: got=%q", scenario.Name)
}
if !scenario.Accepted {
t.Fatalf("default scenario should be accepted")
}
}
func TestApplyCardPayoutSendResult_AcceptedSuccessStatus(t *testing.T) {
state := &model.CardPayout{}
result := &provider.CardPayoutSendResult{
Accepted: true,
ProviderStatus: "success",
ErrorCode: "00",
ErrorMessage: "Approved",
}
applyCardPayoutSendResult(state, result)
if state.Status != model.PayoutStatusSuccess {
t.Fatalf("unexpected status: got=%q", state.Status)
}
if state.ProviderCode != "00" {
t.Fatalf("unexpected provider code: got=%q", state.ProviderCode)
}
}

View File

@@ -0,0 +1,303 @@
package gateway
import (
"context"
"net/http"
"strings"
"github.com/tech/sendico/gateway/aurora/internal/appversion"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/gateway/aurora/storage"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
)
type Service struct {
logger mlogger.Logger
clock clockpkg.Clock
producer msg.Producer
msgCfg pmodel.SettingsT
storage storage.Repository
config provider.Config
httpClient *http.Client
card *cardPayoutProcessor
outbox gatewayoutbox.ReliableRuntime
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
announcer *discovery.Announcer
invokeURI string
strictIsolation bool
connectorv1.UnimplementedConnectorServiceServer
}
type payoutFailure interface {
error
Reason() string
}
type reasonedError struct {
reason string
err error
}
func (r reasonedError) Error() string {
return r.err.Error()
}
func (r reasonedError) Unwrap() error {
return r.err
}
func (r reasonedError) Reason() string {
return r.reason
}
// NewService constructs the Aurora gateway service skeleton.
func NewService(logger mlogger.Logger, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("service"),
clock: clockpkg.NewSystem(),
config: provider.DefaultConfig(),
msgCfg: map[string]any{},
}
initMetrics()
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
if svc.clock == nil {
svc.clock = clockpkg.NewSystem()
}
if svc.httpClient == nil {
svc.httpClient = &http.Client{Timeout: svc.config.Timeout()}
} else if svc.httpClient.Timeout <= 0 {
svc.httpClient.Timeout = svc.config.Timeout()
}
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer)
if svc.strictIsolation {
svc.card.setExecutionMode(newStrictIsolatedPayoutExecutionMode())
}
svc.card.outbox = &svc.outbox
svc.card.msgCfg = svc.msgCfg
if err := svc.card.startOutboxReliableProducer(); err != nil {
svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err))
}
svc.card.applyGatewayDescriptor(svc.gatewayDescriptor)
svc.startDiscoveryAnnouncer()
return svc
}
// Register wires the service onto the provided gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
connectorv1.RegisterConnectorServiceServer(reg, s)
})
}
func (s *Service) Shutdown() {
if s == nil {
return
}
if s.card != nil {
s.card.stopRetries()
}
s.outbox.Stop()
if s.announcer != nil {
s.announcer.Stop()
}
}
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
log := svc.logger.Named("rpc")
log.Info("RPC request started", zap.String("method", method))
start := svc.clock.Now()
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req)
duration := svc.clock.Now().Sub(start)
observeRPC(method, err, duration)
if err != nil {
log.Warn("RPC request failed", zap.String("method", method), zap.Duration("duration", duration), zap.Error(err))
} else {
log.Info("RPC request completed", zap.String("method", method), zap.Duration("duration", duration))
}
return resp, err
}
func normalizeReason(reason string) string {
return strings.ToLower(strings.TrimSpace(reason))
}
func newPayoutError(reason string, err error) error {
return reasonedError{
reason: normalizeReason(reason),
err: err,
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: mservice.MntxGateway,
Rail: discovery.RailCardPayout,
Operations: discovery.CardPayoutRailGatewayOperations(),
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
InstanceID: discovery.InstanceID(),
}
if s.gatewayDescriptor != nil {
if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" {
announce.ID = id
}
announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor)
}
if strings.TrimSpace(announce.ID) == "" {
announce.ID = discovery.StablePaymentGatewayID(discovery.RailCardPayout)
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
s.announcer.Start()
}
func currenciesFromDescriptor(src *gatewayv1.GatewayInstanceDescriptor) []discovery.CurrencyAnnouncement {
if src == nil {
return nil
}
network := strings.TrimSpace(src.GetNetwork())
limitsCfg := src.GetLimits()
values := src.GetCurrencies()
if len(values) == 0 {
return nil
}
seen := map[string]bool{}
result := make([]discovery.CurrencyAnnouncement, 0, len(values))
for _, value := range values {
currency := strings.ToUpper(strings.TrimSpace(value))
if currency == "" || seen[currency] {
continue
}
seen[currency] = true
result = append(result, discovery.CurrencyAnnouncement{
Currency: currency,
Network: network,
Limits: currencyLimitsFromDescriptor(limitsCfg, currency),
})
}
if len(result) == 0 {
return nil
}
return result
}
func currencyLimitsFromDescriptor(src *gatewayv1.Limits, currency string) *discovery.CurrencyLimits {
if src == nil {
return nil
}
amountMin := firstNonEmpty(src.GetPerTxMinAmount(), src.GetMinAmount())
amountMax := firstNonEmpty(src.GetPerTxMaxAmount(), src.GetMaxAmount())
limits := &discovery.CurrencyLimits{}
if amountMin != "" || amountMax != "" {
limits.Amount = &discovery.CurrencyAmount{
Min: amountMin,
Max: amountMax,
}
}
running := &discovery.CurrencyRunningLimits{}
for bucket, max := range src.GetVolumeLimit() {
bucket = strings.TrimSpace(bucket)
max = strings.TrimSpace(max)
if bucket == "" || max == "" {
continue
}
running.Volume = append(running.Volume, discovery.VolumeLimit{
Window: discovery.Window{
Raw: bucket,
Named: bucket,
},
Max: max,
})
}
for bucket, max := range src.GetVelocityLimit() {
bucket = strings.TrimSpace(bucket)
if bucket == "" || max <= 0 {
continue
}
running.Velocity = append(running.Velocity, discovery.VelocityLimit{
Window: discovery.Window{
Raw: bucket,
Named: bucket,
},
Max: int(max),
})
}
if override := src.GetCurrencyLimits()[strings.ToUpper(strings.TrimSpace(currency))]; override != nil {
if min := strings.TrimSpace(override.GetMinAmount()); min != "" {
if limits.Amount == nil {
limits.Amount = &discovery.CurrencyAmount{}
}
limits.Amount.Min = min
}
if max := strings.TrimSpace(override.GetMaxAmount()); max != "" {
if limits.Amount == nil {
limits.Amount = &discovery.CurrencyAmount{}
}
limits.Amount.Max = max
}
if maxVolume := strings.TrimSpace(override.GetMaxVolume()); maxVolume != "" {
running.Volume = append(running.Volume, discovery.VolumeLimit{
Window: discovery.Window{
Raw: "default",
Named: "default",
},
Max: maxVolume,
})
}
if maxOps := int(override.GetMaxOps()); maxOps > 0 {
running.Velocity = append(running.Velocity, discovery.VelocityLimit{
Window: discovery.Window{
Raw: "default",
Named: "default",
},
Max: maxOps,
})
}
}
if len(running.Volume) > 0 || len(running.Velocity) > 0 {
limits.Running = running
}
if limits.Amount == nil && limits.Running == nil {
return nil
}
return limits
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
clean := strings.TrimSpace(value)
if clean != "" {
return clean
}
}
return ""
}

View File

@@ -0,0 +1,19 @@
package gateway
import (
"testing"
"go.uber.org/zap"
)
func TestNewService_StrictOperationIsolationOption(t *testing.T) {
svc := NewService(zap.NewNop(), WithStrictOperationIsolation(true))
t.Cleanup(svc.Shutdown)
if svc.card == nil {
t.Fatalf("expected card processor to be initialised")
}
if got, want := payoutExecutionModeName(svc.card.executionMode), payoutExecutionModeStrictIsolatedName; got != want {
t.Fatalf("execution mode mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -0,0 +1,86 @@
package gateway
import (
"errors"
"testing"
"time"
"github.com/tech/sendico/gateway/aurora/internal/service/provider"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
func requireReason(t *testing.T, err error, reason string) {
t.Helper()
if err == nil {
t.Fatalf("expected error")
}
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error, got %v", err)
}
reasoned, ok := err.(payoutFailure)
if !ok {
t.Fatalf("expected payout failure reason, got %T", err)
}
if reasoned.Reason() != reason {
t.Fatalf("expected reason %q, got %q", reason, reasoned.Reason())
}
}
func testProviderConfig() provider.Config {
return provider.Config{
AllowedCurrencies: []string{"RUB", "USD"},
}
}
func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
return &mntxv1.CardPayoutRequest{
PayoutId: "payout-1",
ParentPaymentRef: "payment-parent-1",
CustomerId: "cust-1",
CustomerFirstName: "Jane",
CustomerLastName: "Doe",
CustomerIp: "203.0.113.10",
AmountMinor: 1500,
Currency: "RUB",
CardPan: "4111111111111111",
CardHolder: "JANE DOE",
CardExpMonth: 12,
CardExpYear: 2035,
}
}
func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest {
return &mntxv1.CardTokenPayoutRequest{
PayoutId: "payout-1",
ParentPaymentRef: "payment-parent-1",
CustomerId: "cust-1",
CustomerFirstName: "Jane",
CustomerLastName: "Doe",
CustomerIp: "203.0.113.11",
AmountMinor: 2500,
Currency: "USD",
CardToken: "tok_123",
}
}
func validCardTokenizeRequest() *mntxv1.CardTokenizeRequest {
month, year := futureExpiry()
return &mntxv1.CardTokenizeRequest{
RequestId: "req-1",
CustomerId: "cust-1",
CustomerFirstName: "Jane",
CustomerLastName: "Doe",
CustomerIp: "203.0.113.12",
CardPan: "4111111111111111",
CardHolder: "JANE DOE",
CardCvv: "123",
CardExpMonth: month,
CardExpYear: year,
}
}
func futureExpiry() (uint32, uint32) {
now := time.Now().UTC()
return uint32(now.Month()), uint32(now.Year() + 1)
}

View File

@@ -0,0 +1,114 @@
package gateway
import (
"context"
"fmt"
"github.com/tech/sendico/gateway/aurora/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"
paytypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
)
func isFinalStatus(t *model.CardPayout) bool {
if t == nil {
return false
}
return isFinalPayoutStatus(t.Status)
}
func isFinalPayoutStatus(status model.PayoutStatus) bool {
switch status {
case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
return true
default:
return false
}
}
func toOpStatus(t *model.CardPayout) (rail.OperationResult, error) {
switch t.Status {
case model.PayoutStatusFailed:
return rail.OperationResultFailed, nil
case model.PayoutStatusSuccess:
return rail.OperationResultSuccess, nil
case model.PayoutStatusCancelled:
return rail.OperationResultCancelled, nil
default:
return rail.OperationResultFailed, merrors.InvalidArgument(fmt.Sprintf("unexpected transfer status, %s", t.Status), "t.Status")
}
}
func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *model.CardPayout) error {
if !isFinalStatus(state) {
if err := p.store.Payouts().Upsert(ctx, state); err != nil {
p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID),
zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)),
)
return err
}
p.observeExecutionState(state)
return nil
}
_, err := p.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
if upsertErr := p.store.Payouts().Upsert(txCtx, state); upsertErr != nil {
return nil, upsertErr
}
if isFinalStatus(state) {
if emitErr := p.emitTransferStatusEvent(txCtx, state); emitErr != nil {
return nil, emitErr
}
}
return nil, nil
})
if err != nil {
p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID),
zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)),
)
return err
}
p.observeExecutionState(state)
return nil
}
func (p *cardPayoutProcessor) emitTransferStatusEvent(ctx context.Context, payout *model.CardPayout) error {
if p == nil || payout == nil {
return nil
}
if p.producer == nil || p.outboxStore() == nil {
return nil
}
status, err := toOpStatus(payout)
if err != nil {
p.logger.Warn("Failed to convert payout status to operation status for transfer status event", zap.Error(err),
mzap.ObjRef("payout_ref", payout.ID), zap.String("payment_ref", payout.PaymentRef), zap.String("status", string(payout.Status)))
return err
}
exec := pmodel.PaymentGatewayExecution{
PaymentIntentID: payout.IntentRef,
IdempotencyKey: payout.IdempotencyKey,
ExecutedMoney: &paytypes.Money{
Amount: fmt.Sprintf("%d", payout.AmountMinor),
Currency: payout.Currency,
},
PaymentRef: payout.PaymentRef,
Status: status,
OperationRef: payout.OperationRef,
Error: payout.FailureReason,
TransferRef: payout.GetID().Hex(),
}
env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec)
if err := p.sendWithOutbox(ctx, env); err != nil {
p.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", payout.ID))
return err
}
return nil
}

View File

@@ -0,0 +1,78 @@
package provider
import (
"strings"
"time"
)
const (
DefaultRequestTimeout = 15 * time.Second
DefaultStatusSuccess = "success"
DefaultStatusProcessing = "processing"
OutcomeSuccess = "success"
OutcomeProcessing = "processing"
OutcomeDecline = "decline"
)
// Config holds resolved settings for communicating with Aurora.
type Config struct {
BaseURL string
ProjectID int64
SecretKey string
AllowedCurrencies []string
RequireCustomerAddress bool
RequestTimeout time.Duration
StatusSuccess string
StatusProcessing string
}
func DefaultConfig() Config {
return Config{
RequestTimeout: DefaultRequestTimeout,
StatusSuccess: DefaultStatusSuccess,
StatusProcessing: DefaultStatusProcessing,
}
}
func (c Config) timeout() time.Duration {
if c.RequestTimeout <= 0 {
return DefaultRequestTimeout
}
return c.RequestTimeout
}
// Timeout exposes the configured HTTP timeout for external callers.
func (c Config) Timeout() time.Duration {
return c.timeout()
}
func (c Config) CurrencyAllowed(code string) bool {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return false
}
if len(c.AllowedCurrencies) == 0 {
return true
}
for _, allowed := range c.AllowedCurrencies {
if strings.EqualFold(strings.TrimSpace(allowed), code) {
return true
}
}
return false
}
func (c Config) SuccessStatus() string {
if strings.TrimSpace(c.StatusSuccess) == "" {
return DefaultStatusSuccess
}
return strings.ToLower(strings.TrimSpace(c.StatusSuccess))
}
func (c Config) ProcessingStatus() string {
if strings.TrimSpace(c.StatusProcessing) == "" {
return DefaultStatusProcessing
}
return strings.ToLower(strings.TrimSpace(c.StatusProcessing))
}

View File

@@ -0,0 +1,21 @@
package provider
import "strings"
// MaskPAN redacts a primary account number by keeping the first 6 and last 4 digits.
func MaskPAN(pan string) string {
p := strings.TrimSpace(pan)
if len(p) <= 4 {
return strings.Repeat("*", len(p))
}
if len(p) <= 10 {
return p[:2] + strings.Repeat("*", len(p)-4) + p[len(p)-2:]
}
maskLen := len(p) - 10
if maskLen < 0 {
maskLen = 0
}
return p[:6] + strings.Repeat("*", maskLen) + p[len(p)-4:]
}

View File

@@ -0,0 +1,23 @@
package provider
import "testing"
func TestMaskPAN(t *testing.T) {
cases := []struct {
input string
expected string
}{
{input: "1234", expected: "****"},
{input: "1234567890", expected: "12******90"},
{input: "1234567890123456", expected: "123456******3456"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := MaskPAN(tc.input)
if got != tc.expected {
t.Fatalf("expected %q, got %q", tc.expected, got)
}
})
}
}

View File

@@ -0,0 +1,39 @@
package provider
import (
"strings"
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metricsOnce sync.Once
cardPayoutCallbacks *prometheus.CounterVec
)
func initMetrics() {
metricsOnce.Do(func() {
cardPayoutCallbacks = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "aurora_gateway",
Name: "card_payout_callbacks_total",
Help: "Aurora card payout callbacks grouped by provider status.",
}, []string{"status"})
})
}
// ObserveCallback records callback status for Aurora card payouts.
func ObserveCallback(status string) {
initMetrics()
status = strings.TrimSpace(status)
if status == "" {
status = "unknown"
}
status = strings.ToLower(status)
if cardPayoutCallbacks != nil {
cardPayoutCallbacks.WithLabelValues(status).Inc()
}
}

View File

@@ -0,0 +1,11 @@
package provider
// CardPayoutSendResult is the minimal provider result contract used by Aurora simulator.
type CardPayoutSendResult struct {
Accepted bool
ProviderRequestID string
ProviderStatus string
StatusCode int
ErrorCode string
ErrorMessage string
}

View File

@@ -0,0 +1,112 @@
package provider
import (
"bytes"
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
func signPayload(payload any, secret string) (string, error) {
canonical, err := signaturePayloadString(payload)
if err != nil {
return "", err
}
mac := hmac.New(sha512.New, []byte(secret))
if _, err := mac.Write([]byte(canonical)); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
// SignPayload exposes signature calculation for callback verification.
func SignPayload(payload any, secret string) (string, error) {
return signPayload(payload, secret)
}
func signaturePayloadString(payload any) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
var root any
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(&root); err != nil {
return "", err
}
lines := make([]string, 0)
collectSignatureLines(nil, root, &lines)
sort.Strings(lines)
return strings.Join(lines, ";"), nil
}
func collectSignatureLines(path []string, value any, lines *[]string) {
switch v := value.(type) {
case map[string]any:
for key, child := range v {
if strings.EqualFold(key, "signature") {
continue
}
collectSignatureLines(append(path, key), child, lines)
}
case []any:
if len(v) == 0 {
return
}
for idx, child := range v {
collectSignatureLines(append(path, strconv.Itoa(idx)), child, lines)
}
default:
line := formatSignatureLine(path, v)
if line != "" {
*lines = append(*lines, line)
}
}
}
func formatSignatureLine(path []string, value any) string {
if len(path) == 0 {
return ""
}
val := signatureValueString(value)
segments := append(append([]string{}, path...), val)
return strings.Join(segments, ":")
}
func signatureValueString(value any) string {
switch v := value.(type) {
case nil:
return "null"
case string:
return v
case json.Number:
return v.String()
case bool:
if v {
return "1"
}
return "0"
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case int:
return strconv.Itoa(v)
case int8, int16, int32, int64:
return fmt.Sprint(v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprint(v)
default:
return fmt.Sprint(v)
}
}

View File

@@ -0,0 +1,211 @@
package provider
import "testing"
func TestSignaturePayloadString_Example(t *testing.T) {
payload := map[string]any{
"general": map[string]any{
"project_id": 3254,
"payment_id": "id_38202316",
"signature": "<ignored>",
},
"customer": map[string]any{
"id": "585741",
"email": "johndoe@example.com",
"first_name": "John",
"last_name": "Doe",
"address": "Downing str., 23",
"identify": map[string]any{
"doc_number": "54122312544",
},
"ip_address": "198.51.100.47",
},
"payment": map[string]any{
"amount": 10800,
"currency": "USD",
"description": "Computer keyboards",
},
"receipt_data": map[string]any{
"positions": []any{
map[string]any{
"quantity": "10",
"amount": "108",
"description": "Computer keyboard",
},
},
},
"return_url": map[string]any{
"success": "https://paymentpage.example.com/complete-redirect?id=success",
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
},
}
got, err := signaturePayloadString(payload)
if err != nil {
t.Fatalf("failed to build signature string: %v", err)
}
expected := "customer:address:Downing str., 23;customer:email:johndoe@example.com;customer:first_name:John;customer:id:585741;customer:identify:doc_number:54122312544;customer:ip_address:198.51.100.47;customer:last_name:Doe;general:payment_id:id_38202316;general:project_id:3254;payment:amount:10800;payment:currency:USD;payment:description:Computer keyboards;receipt_data:positions:0:amount:108;receipt_data:positions:0:description:Computer keyboard;receipt_data:positions:0:quantity:10;return_url:decline:https://paymentpage.example.com/complete-redirect?id=decline;return_url:success:https://paymentpage.example.com/complete-redirect?id=success"
if got != expected {
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignPayload_Example(t *testing.T) {
payload := map[string]any{
"general": map[string]any{
"project_id": 3254,
"payment_id": "id_38202316",
"signature": "<ignored>",
},
"customer": map[string]any{
"id": "585741",
"email": "johndoe@example.com",
"first_name": "John",
"last_name": "Doe",
"address": "Downing str., 23",
"identify": map[string]any{
"doc_number": "54122312544",
},
"ip_address": "198.51.100.47",
},
"payment": map[string]any{
"amount": 10800,
"currency": "USD",
"description": "Computer keyboards",
},
"receipt_data": map[string]any{
"positions": []any{
map[string]any{
"quantity": "10",
"amount": "108",
"description": "Computer keyboard",
},
},
},
"return_url": map[string]any{
"success": "https://paymentpage.example.com/complete-redirect?id=success",
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
},
}
got, err := SignPayload(payload, "secret")
if err != nil {
t.Fatalf("failed to sign payload: %v", err)
}
expected := "lagSnuspAn+F6XkmQISqwtBg0PsiTy62fF9x33TM+278mnufIDZyi1yP0BQALuCxyikkIxIMbodBn2F8hMdRwA=="
if got != expected {
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignaturePayloadString_BooleansAndArrays(t *testing.T) {
payload := map[string]any{
"flag": true,
"false_flag": false,
"empty": "",
"zero": 0,
"nested": map[string]any{
"list": []any{},
"items": []any{"alpha", "beta"},
},
}
got, err := signaturePayloadString(payload)
if err != nil {
t.Fatalf("failed to build signature string: %v", err)
}
expected := "empty:;false_flag:0;flag:1;nested:items:0:alpha;nested:items:1:beta;zero:0"
if got != expected {
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignPayload_EthEstimateGasExample(t *testing.T) {
payload := map[string]any{
"jsonrpc": "2.0",
"id": 3,
"method": "eth_estimateGas",
"params": []any{
map[string]any{
"from": "0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8",
"to": "0x44162e39eefd9296231e772663a92e72958e182f",
"gasPrice": "0x64",
"data": "0xa9059cbb00000000000000000000000044162e39eefd9296231e772663a92e72958e182f00000000000000000000000000000000000000000000000000000000000f4240",
},
},
}
got, err := SignPayload(payload, "1")
if err != nil {
t.Fatalf("failed to sign payload: %v", err)
}
expected := "C4WbSvXKSMyX8yLamQcUe/Nzr6nSt9m3HYn4jHSyA7yi/FaTiqk0r8BlfIzfxSCoDaRgrSd82ihgZW+DxELhdQ=="
if got != expected {
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignPayload_AuroraCallbackExample(t *testing.T) {
payload := map[string]any{
"customer": map[string]any{
"id": "694ece88df756c2672dc6ce8",
},
"account": map[string]any{
"number": "220070******0161",
"type": "mir",
"card_holder": "STEPHAN",
"expiry_month": "03",
"expiry_year": "2030",
},
"project_id": 157432,
"payment": map[string]any{
"id": "6952d0b307d2916aba87d4e8",
"type": "payout",
"status": "success",
"date": "2025-12-29T19:04:24+0000",
"method": "card",
"sum": map[string]any{
"amount": 10849,
"currency": "RUB",
},
"description": "",
},
"operation": map[string]any{
"sum_initial": map[string]any{
"amount": 10849,
"currency": "RUB",
},
"sum_converted": map[string]any{
"amount": 10849,
"currency": "RUB",
},
"code": "0",
"message": "Success",
"provider": map[string]any{
"id": 26226,
"payment_id": "a3761838-eabc-4c65-aa36-c854c47a226b",
"auth_code": "",
"endpoint_id": 26226,
"date": "2025-12-29T19:04:23+0000",
},
"id": int64(5089807000008124),
"type": "payout",
"status": "success",
"date": "2025-12-29T19:04:24+0000",
"created_date": "2025-12-29T19:04:21+0000",
"request_id": "7c3032f00629c94ad78e399c87da936f1cdc30de-2559ba11d6958d558a9f8ab8c20474d33061c654-05089808",
},
"signature": "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ==",
}
got, err := SignPayload(payload, "1")
if err != nil {
t.Fatalf("failed to sign payload: %v", err)
}
expected := "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ=="
if got != expected {
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
}
}

View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/gateway/aurora/internal/appversion"
si "github.com/tech/sendico/gateway/aurora/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,28 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
)
// CardPayout is a Mongo/JSON representation of proto CardPayout
type CardPayout struct {
storable.Base `bson:",inline" json:",inline"`
PaymentRef string `bson:"paymentRef" json:"payment_ref"`
OperationRef string `bson:"operationRef" json:"operation_ref"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotency_key"`
IntentRef string `bson:"intentRef" json:"intentRef"`
ProjectID int64 `bson:"projectId" json:"project_id"`
CustomerID string `bson:"customerId" json:"customer_id"`
AmountMinor int64 `bson:"amountMinor" json:"amount_minor"`
Currency string `bson:"currency" json:"currency"`
Status PayoutStatus `bson:"status" json:"status"`
ProviderCode string `bson:"providerCode,omitempty" json:"provider_code,omitempty"`
ProviderMessage string `bson:"providerMessage,omitempty" json:"provider_message,omitempty"`
ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"provider_payment_id,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failure_reason,omitempty"`
CreatedAt time.Time `bson:"createdAt" json:"created_at"`
UpdatedAt time.Time `bson:"updatedAt" json:"updated_at"`
}

View File

@@ -0,0 +1,13 @@
package model
type PayoutStatus string
const (
PayoutStatusCreated PayoutStatus = "created" // record exists, not started
PayoutStatusProcessing PayoutStatus = "processing" // we are working on it
PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world
PayoutStatusSuccess PayoutStatus = "success" // final success
PayoutStatusFailed PayoutStatus = "failed" // final failure
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
)

View File

@@ -0,0 +1,88 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/gateway/aurora/storage"
"github.com/tech/sendico/gateway/aurora/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
payouts storage.PayoutsStore
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
}
payoutsStore, err := store.NewPayouts(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise payouts store", zap.Error(err), zap.String("store", "payments"))
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.payouts = payoutsStore
result.outbox = outboxStore
result.logger.Info("Payouts gateway MongoDB storage initialised")
return result, nil
}
func (r *Repository) Payouts() storage.PayoutsStore {
return r.payouts
}
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,108 @@
package store
import (
"context"
"strings"
storage "github.com/tech/sendico/gateway/aurora/storage"
"github.com/tech/sendico/gateway/aurora/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"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
const (
payoutsCollection = "card_payouts"
payoutIdemField = "idempotencyKey"
payoutIdField = "paymentRef"
payoutOpField = "operationRef"
)
type Payouts struct {
logger mlogger.Logger
repository repository.Repository
}
func NewPayouts(logger mlogger.Logger, db *mongo.Database) (*Payouts, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("payouts").With(zap.String("collection", payoutsCollection))
repo := repository.CreateMongoRepository(db, payoutsCollection)
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: payoutOpField, Sort: ri.Asc}},
Unique: true,
Sparse: true,
}); err != nil {
logger.Error("Failed to create payouts operation index",
zap.Error(err), zap.String("index_field", payoutOpField))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: payoutIdemField, Sort: ri.Asc}},
Unique: true,
}); err != nil {
logger.Error("Failed to create payouts idempotency index",
zap.Error(err), zap.String("index_field", payoutIdemField))
return nil, err
}
p := &Payouts{
logger: logger,
repository: repo,
}
p.logger.Debug("Payouts store initialised")
return p, nil
}
func (p *Payouts) findOneByField(ctx context.Context, field, value string) (*model.CardPayout, error) {
var res model.CardPayout
return &res, p.repository.FindOneByFilter(ctx, repository.Filter(field, value), &res)
}
func (p *Payouts) FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error) {
return p.findOneByField(ctx, payoutIdemField, key)
}
func (p *Payouts) FindByOperationRef(ctx context.Context, operationRef string) (*model.CardPayout, error) {
return p.findOneByField(ctx, payoutOpField, operationRef)
}
func (p *Payouts) FindByPaymentID(ctx context.Context, paymentID string) (*model.CardPayout, error) {
return p.findOneByField(ctx, payoutIdField, paymentID)
}
func (p *Payouts) Upsert(ctx context.Context, record *model.CardPayout) error {
if record == nil {
p.logger.Warn("Invalid argument provided: nil record")
return merrors.InvalidArgument("payout record is nil", "record")
}
record.OperationRef = strings.TrimSpace(record.OperationRef)
record.PaymentRef = strings.TrimSpace(record.PaymentRef)
record.CustomerID = strings.TrimSpace(record.CustomerID)
record.ProviderCode = strings.TrimSpace(record.ProviderCode)
record.ProviderPaymentID = strings.TrimSpace(record.ProviderPaymentID)
if record.OperationRef == "" {
p.logger.Warn("Invalid argument provided: operation reference missing")
return merrors.InvalidArgument("operation ref is required", "operation_ref")
}
if err := p.repository.Upsert(ctx, record); err != nil {
p.logger.Warn("Failed to upsert payout record", zap.Error(err), mzap.ObjRef("payout_ref", record.ID),
zap.String("operation_ref", record.OperationRef), zap.String("payment_ref", record.PaymentRef))
return err
}
return nil
}
var _ storage.PayoutsStore = (*Payouts)(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,21 @@
package storage
import (
"context"
"github.com/tech/sendico/gateway/aurora/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record")
type Repository interface {
Payouts() PayoutsStore
}
type PayoutsStore interface {
FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error)
FindByOperationRef(ctx context.Context, key string) (*model.CardPayout, error)
FindByPaymentID(ctx context.Context, key string) (*model.CardPayout, error)
Upsert(ctx context.Context, record *model.CardPayout) error
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/gateway/chain/internal/appversion"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
chainstoragemodel "github.com/tech/sendico/gateway/chain/storage/model"
chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
@@ -17,6 +18,7 @@ import (
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
const chainConnectorID = "chain"
@@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
operationRef := strings.TrimSpace(req.GetOperationId())
if s.storage == nil || s.storage.Transfers() == nil {
return nil, merrors.Internal("get_operation: storage is not configured")
}
transfer, err := s.storage.Transfers().FindByOperationRef(ctx, "", operationRef)
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
if transfer == nil {
return nil, merrors.NoData("transfer not found")
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(storageTransferToProto(transfer))}, nil
}
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
@@ -493,6 +505,61 @@ func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Stru
return result
}
func storageTransferToProto(transfer *chainstoragemodel.Transfer) *chainv1.Transfer {
if transfer == nil {
return nil
}
destination := &chainv1.TransferDestination{Memo: strings.TrimSpace(transfer.Destination.Memo)}
if managedWalletRef := strings.TrimSpace(transfer.Destination.ManagedWalletRef); managedWalletRef != "" {
destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: managedWalletRef}
} else if externalAddress := strings.TrimSpace(transfer.Destination.ExternalAddress); externalAddress != "" {
destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: externalAddress}
}
fees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
for _, fee := range transfer.Fees {
fees = append(fees, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fee.FeeCode),
Amount: fee.Amount,
Description: strings.TrimSpace(fee.Description),
})
}
asset := &chainv1.Asset{
Chain: shared.ChainEnumFromName(transfer.Network),
TokenSymbol: strings.TrimSpace(transfer.TokenSymbol),
ContractAddress: strings.TrimSpace(transfer.ContractAddress),
}
protoTransfer := &chainv1.Transfer{
TransferRef: strings.TrimSpace(transfer.TransferRef),
IdempotencyKey: strings.TrimSpace(transfer.IdempotencyKey),
IntentRef: strings.TrimSpace(transfer.IntentRef),
OperationRef: strings.TrimSpace(transfer.OperationRef),
OrganizationRef: strings.TrimSpace(transfer.OrganizationRef),
SourceWalletRef: strings.TrimSpace(transfer.SourceWalletRef),
Destination: destination,
Asset: asset,
RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount),
NetAmount: shared.MonenyToProto(transfer.NetAmount),
Fees: fees,
Status: shared.TransferStatusToProto(transfer.Status),
TransactionHash: strings.TrimSpace(transfer.TxHash),
FailureReason: strings.TrimSpace(transfer.FailureReason),
PaymentRef: strings.TrimSpace(transfer.PaymentRef),
}
if !transfer.CreatedAt.IsZero() {
protoTransfer.CreatedAt = timestamppb.New(transfer.CreatedAt.UTC())
}
if !transfer.UpdatedAt.IsZero() {
protoTransfer.UpdatedAt = timestamppb.New(transfer.UpdatedAt.UTC())
}
return protoTransfer
}
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{
"cap_hit": capHit,
@@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
}}},
}
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 dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
@@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
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 connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,

View File

@@ -500,6 +500,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model
return transfer, nil
}
func (t *inMemoryTransfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
t.mu.Lock()
defer t.mu.Unlock()
org := strings.TrimSpace(organizationRef)
opRef := strings.TrimSpace(operationRef)
if opRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
}
for _, transfer := range t.items {
if transfer == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(transfer.OperationRef), opRef) {
continue
}
if org != "" && !strings.EqualFold(strings.TrimSpace(transfer.OrganizationRef), org) {
continue
}
return transfer, nil
}
return nil, merrors.NoData("transfer not found")
}
func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
t.mu.Lock()
defer t.mu.Unlock()

View File

@@ -40,6 +40,9 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true,
@@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe
return transfer, nil
}
func (t *Transfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
operationRef = strings.TrimSpace(operationRef)
if operationRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
}
query := repository.Query().Filter(repository.Field("operationRef"), operationRef)
if org := strings.TrimSpace(organizationRef); org != "" {
query = query.Filter(repository.Field("organizationRef"), org)
}
transfer := &model.Transfer{}
if err := t.repo.FindOneByFilter(ctx, query, transfer); err != nil {
return nil, err
}
return transfer, nil
}
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
query := repository.Query()
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {

View File

@@ -42,6 +42,7 @@ type WalletsStore interface {
type TransfersStore interface {
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
Get(ctx context.Context, transferRef string) (*model.Transfer, error)
FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error)
List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error)
UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error)
}

View File

@@ -44,7 +44,7 @@ mcards:
request_timeout_seconds: 15
status_success: "success"
status_processing: "processing"
strict_operation_mode: true
strict_operation_mode: false
gateway:
id: "mcards"

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

@@ -12,6 +12,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/types/known/structpb"
)
const mntxConnectorID = "mntx"
@@ -92,11 +93,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetCardPayoutStatus(ctx, &mntxv1.GetCardPayoutStatusRequest{PayoutId: strings.TrimSpace(req.GetOperationId())})
operationRef := strings.TrimSpace(req.GetOperationId())
if s.storage == nil || s.storage.Payouts() == nil {
return nil, merrors.Internal("get_operation: storage is not configured")
}
payout, err := s.storage.Payouts().FindByOperationRef(ctx, operationRef)
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(resp.GetPayout())}, nil
if payout == nil {
return nil, merrors.NoData("payout not found")
}
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(StateToProto(payout))}, nil
}
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
@@ -274,7 +285,7 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
if state == nil {
return nil
}
return &connectorv1.Operation{
op := &connectorv1.Operation{
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
Type: connectorv1.OperationType_PAYOUT,
Status: payoutStatusToOperation(state.GetStatus()),
@@ -282,10 +293,30 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
Amount: minorToDecimal(state.GetAmountMinor()),
Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())),
},
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
CreatedAt: state.GetCreatedAt(),
UpdatedAt: state.GetUpdatedAt(),
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
IntentRef: strings.TrimSpace(state.GetIntentRef()),
OperationRef: strings.TrimSpace(state.GetOperationRef()),
CreatedAt: state.GetCreatedAt(),
UpdatedAt: state.GetUpdatedAt(),
}
params := map[string]interface{}{}
if paymentRef := strings.TrimSpace(state.GetParentPaymentRef()); paymentRef != "" {
params["payment_ref"] = paymentRef
params["parent_payment_ref"] = paymentRef
}
if providerCode := strings.TrimSpace(state.GetProviderCode()); providerCode != "" {
params["provider_code"] = providerCode
}
if providerMessage := strings.TrimSpace(state.GetProviderMessage()); providerMessage != "" {
params["provider_message"] = providerMessage
params["failure_reason"] = providerMessage
}
if len(params) > 0 {
op.Params = structFromMap(params)
}
return op
}
func minorToDecimal(amount int64) string {
@@ -316,6 +347,17 @@ func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationSt
}
}
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 connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,

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,47 +13,137 @@ const (
payoutFailureActionRetry
)
type payoutRetryStrategy int
const (
payoutRetryStrategyImmediate payoutRetryStrategy = iota + 1
payoutRetryStrategyDelayed
payoutRetryStrategyStatusRefresh
)
type payoutFailureDecision struct {
Action payoutFailureAction
Reason string
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,
Reason: "provider_failure",
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,
Reason: "provider_code_" + normalized,
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,
Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_code_" + normalized + "_unknown",
}
}
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
return payoutFailureDecision{
Action: payoutFailureActionRetry,
Reason: "transport_failure",
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,28 +2,73 @@ 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()
cases := []struct {
name string
code string
action payoutFailureAction
name string
code string
action payoutFailureAction
strategy payoutRetryStrategy
}{
{
name: "retryable provider limit code",
code: providerCodeDeclineAmountOrFrequencyLimit,
action: payoutFailureActionRetry,
name: "immediate retry strategy code",
code: "10000",
action: payoutFailureActionRetry,
strategy: payoutRetryStrategyImmediate,
},
{
name: "unknown provider code",
code: "99999",
action: payoutFailureActionFail,
name: "delayed retry strategy code",
code: "10101",
action: payoutFailureActionRetry,
strategy: payoutRetryStrategyDelayed,
},
{
name: "empty provider code",
code: "",
action: payoutFailureActionFail,
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
}

View File

@@ -7,7 +7,8 @@ const (
PayoutStatusProcessing PayoutStatus = "processing" // we are working on it
PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world
PayoutStatusSuccess PayoutStatus = "success" // final success
PayoutStatusFailed PayoutStatus = "failed" // final failure
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
PayoutStatusSuccess PayoutStatus = "success" // final success
PayoutStatusFailed PayoutStatus = "failed" // final failure
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
PayoutStatusNeedsAttention PayoutStatus = "needs_attention" // final, manual review required
)

View File

@@ -41,3 +41,12 @@ gateway:
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

@@ -41,3 +41,12 @@ gateway:
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

@@ -11,7 +11,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

@@ -28,11 +28,15 @@ type Imp struct {
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"`
Gateway gatewayConfig `yaml:"gateway"`
Treasury treasuryConfig `yaml:"treasury"`
}
type gatewayConfig struct {
@@ -43,6 +47,22 @@ type gatewayConfig struct {
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"),
@@ -62,6 +82,9 @@ func (i *Imp) Shutdown() {
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)
@@ -81,6 +104,19 @@ func (i *Imp) Start() error {
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)
@@ -103,6 +139,18 @@ func (i *Imp) Start() error {
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
@@ -142,6 +190,15 @@ func (i *Imp) loadConfig() (*config, error) {
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 == "" {
return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail")

View File

@@ -55,8 +55,9 @@ func (s *Service) sweepExpiredConfirmations(ctx context.Context) {
s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err))
return
}
for _, pending := range expired {
if pending == nil || strings.TrimSpace(pending.RequestID) == "" {
for i := range expired {
pending := &expired[i]
if strings.TrimSpace(pending.RequestID) == "" {
continue
}
result := &model.ConfirmationResult{
@@ -146,6 +147,7 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
message := update.Message
replyToID := strings.TrimSpace(message.ReplyToMessageID)
if replyToID == "" {
s.handleTreasuryTelegramUpdate(ctx, update)
return nil
}
replyFields := telegramReplyLogFields(update)
@@ -154,6 +156,9 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
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"),
@@ -272,6 +277,13 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
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

View File

@@ -11,6 +11,9 @@ import (
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 tgsettleConnectorID = "tgsettle"
@@ -133,13 +136,22 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
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: strings.TrimSpace(transfer.GetTransferRef()),
OperationId: operationID,
Status: transferStatusToOperation(transfer.GetStatus()),
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
},
@@ -152,12 +164,22 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
operationID := strings.TrimSpace(req.GetOperationId())
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: 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 failed", zap.String("operation_id", operationID), zap.Error(err))
s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err))
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: transferToOperation(resp.GetTransfer())}, nil
if record == nil {
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) {
@@ -211,7 +233,7 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
OperationId: strings.TrimSpace(transfer.GetOperationRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: transferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
@@ -221,6 +243,19 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
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: tgsettleConnectorID,
@@ -281,6 +316,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
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

View File

@@ -0,0 +1,119 @@
package gateway
import (
"context"
"testing"
storagemodel "github.com/tech/sendico/gateway/tgsettle/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: tgsettleConnectorID,
AccountId: "wallet-src",
}},
},
To: &connectorv1.OperationParty{
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: tgsettleConnectorID,
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)
}
}

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