Compare commits
27 Commits
d64ad89072
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f42a0e77f | |||
|
|
10bcdb4fe2 | ||
| ea5ec79a6e | |||
|
|
3295b9d9f0 | ||
| 031b8931ca | |||
|
|
4295456f63 | ||
| 2b1b4135f4 | |||
|
|
c60e7d2329 | ||
| be49254769 | |||
|
|
34e507b664 | ||
| b481de9ffc | |||
|
|
0c29e7686d | ||
| 5b26a70a15 | |||
|
|
b832c2a7c4 | ||
| 15393765b9 | |||
|
|
440b6a2553 | ||
| bc76cfe063 | |||
|
|
ed8f7c519c | ||
|
|
71d99338f2 | ||
| b499778bce | |||
|
|
4a554833c4 | ||
| b7ea11a62b | |||
|
|
026f698d9b | ||
| 0da6078468 | |||
|
|
3b65a2dc3a | ||
| a9b00b6871 | |||
|
|
4a5e26b03a |
49
Makefile
49
Makefile
@@ -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)"
|
||||
|
||||
21
README.md
21
README.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ type Payment struct {
|
||||
PaymentRef string `json:"paymentRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
FailureCode string `json:"failureCode,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
Operations []PaymentOperation `json:"operations,omitempty"`
|
||||
@@ -294,6 +295,7 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
|
||||
return &Payment{
|
||||
PaymentRef: p.GetPaymentRef(),
|
||||
State: enumJSONName(p.GetState().String()),
|
||||
Comment: strings.TrimSpace(p.GetIntentSnapshot().GetComment()),
|
||||
FailureCode: failureCode,
|
||||
FailureReason: failureReason,
|
||||
Operations: operations,
|
||||
|
||||
@@ -121,6 +121,22 @@ func TestToPaymentIgnoresHiddenFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentMapsIntentComment(t *testing.T) {
|
||||
dto := toPayment(&orchestrationv2.Payment{
|
||||
PaymentRef: "pay-3",
|
||||
State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED,
|
||||
IntentSnapshot: "ationv2.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("ationv2.PaymentQuote{
|
||||
QuoteRef: "quote-1",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
46
api/gateway/aurora/.air.toml
Normal file
46
api/gateway/aurora/.air.toml
Normal 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
5
api/gateway/aurora/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/aurora
|
||||
internal/generated
|
||||
.gocache
|
||||
tmp
|
||||
app
|
||||
28
api/gateway/aurora/README.md
Normal file
28
api/gateway/aurora/README.md
Normal 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.
|
||||
37
api/gateway/aurora/SCENARIOS.md
Normal file
37
api/gateway/aurora/SCENARIOS.md
Normal 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.
|
||||
403
api/gateway/aurora/client/client.go
Normal file
403
api/gateway/aurora/client/client.go
Normal 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
|
||||
}
|
||||
28
api/gateway/aurora/client/config.go
Normal file
28
api/gateway/aurora/client/config.go
Normal 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()
|
||||
}
|
||||
}
|
||||
45
api/gateway/aurora/client/fake.go
Normal file
45
api/gateway/aurora/client/fake.go
Normal 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 }
|
||||
62
api/gateway/aurora/config.dev.yml
Normal file
62
api/gateway/aurora/config.dev.yml
Normal 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
|
||||
62
api/gateway/aurora/config.yml
Normal file
62
api/gateway/aurora/config.yml
Normal 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
|
||||
4
api/gateway/aurora/entrypoint.sh
Executable file
4
api/gateway/aurora/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
exec /app/aurora-gateway "$@"
|
||||
55
api/gateway/aurora/go.mod
Normal file
55
api/gateway/aurora/go.mod
Normal 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
223
api/gateway/aurora/go.sum
Normal 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=
|
||||
27
api/gateway/aurora/internal/appversion/version.go
Normal file
27
api/gateway/aurora/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
func Create() version.Printer {
|
||||
info := version.Info{
|
||||
Program: "Sendico Aurora Gateway Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&info)
|
||||
}
|
||||
604
api/gateway/aurora/internal/server/internal/serverimp.go
Normal file
604
api/gateway/aurora/internal/server/internal/serverimp.go
Normal 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
12
api/gateway/aurora/internal/server/server.go
Normal file
12
api/gateway/aurora/internal/server/server.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
177
api/gateway/aurora/internal/service/gateway/callback.go
Normal file
177
api/gateway/aurora/internal/service/gateway/callback.go
Normal 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)))
|
||||
}
|
||||
139
api/gateway/aurora/internal/service/gateway/callback_test.go
Normal file
139
api/gateway/aurora/internal/service/gateway/callback_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
1580
api/gateway/aurora/internal/service/gateway/card_processor.go
Normal file
1580
api/gateway/aurora/internal/service/gateway/card_processor.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
388
api/gateway/aurora/internal/service/gateway/connector.go
Normal file
388
api/gateway/aurora/internal/service/gateway/connector.go
Normal 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
|
||||
}
|
||||
}
|
||||
99
api/gateway/aurora/internal/service/gateway/helpers.go
Normal file
99
api/gateway/aurora/internal/service/gateway/helpers.go
Normal 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
|
||||
}
|
||||
}
|
||||
62
api/gateway/aurora/internal/service/gateway/instances.go
Normal file
62
api/gateway/aurora/internal/service/gateway/instances.go
Normal 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
|
||||
}
|
||||
66
api/gateway/aurora/internal/service/gateway/metrics.go
Normal file
66
api/gateway/aurora/internal/service/gateway/metrics.go
Normal 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"
|
||||
}
|
||||
}
|
||||
86
api/gateway/aurora/internal/service/gateway/options.go
Normal file
86
api/gateway/aurora/internal/service/gateway/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
303
api/gateway/aurora/internal/service/gateway/service.go
Normal file
303
api/gateway/aurora/internal/service/gateway/service.go
Normal 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 ""
|
||||
}
|
||||
19
api/gateway/aurora/internal/service/gateway/service_test.go
Normal file
19
api/gateway/aurora/internal/service/gateway/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
78
api/gateway/aurora/internal/service/provider/config.go
Normal file
78
api/gateway/aurora/internal/service/provider/config.go
Normal 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))
|
||||
}
|
||||
21
api/gateway/aurora/internal/service/provider/mask.go
Normal file
21
api/gateway/aurora/internal/service/provider/mask.go
Normal 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:]
|
||||
}
|
||||
23
api/gateway/aurora/internal/service/provider/mask_test.go
Normal file
23
api/gateway/aurora/internal/service/provider/mask_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
39
api/gateway/aurora/internal/service/provider/metrics.go
Normal file
39
api/gateway/aurora/internal/service/provider/metrics.go
Normal 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()
|
||||
}
|
||||
}
|
||||
11
api/gateway/aurora/internal/service/provider/payloads.go
Normal file
11
api/gateway/aurora/internal/service/provider/payloads.go
Normal 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
|
||||
}
|
||||
112
api/gateway/aurora/internal/service/provider/signature.go
Normal file
112
api/gateway/aurora/internal/service/provider/signature.go
Normal 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)
|
||||
}
|
||||
}
|
||||
211
api/gateway/aurora/internal/service/provider/signature_test.go
Normal file
211
api/gateway/aurora/internal/service/provider/signature_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
api/gateway/aurora/main.go
Normal file
17
api/gateway/aurora/main.go
Normal 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)
|
||||
}
|
||||
28
api/gateway/aurora/storage/model/state.go
Normal file
28
api/gateway/aurora/storage/model/state.go
Normal 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"`
|
||||
}
|
||||
13
api/gateway/aurora/storage/model/status.go
Normal file
13
api/gateway/aurora/storage/model/status.go
Normal 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
|
||||
)
|
||||
88
api/gateway/aurora/storage/mongo/repository.go
Normal file
88
api/gateway/aurora/storage/mongo/repository.go
Normal 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)
|
||||
108
api/gateway/aurora/storage/mongo/store/payouts.go
Normal file
108
api/gateway/aurora/storage/mongo/store/payouts.go
Normal 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)
|
||||
38
api/gateway/aurora/storage/mongo/transaction.go
Normal file
38
api/gateway/aurora/storage/mongo/transaction.go
Normal 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}
|
||||
}
|
||||
21
api/gateway/aurora/storage/storage.go
Normal file
21
api/gateway/aurora/storage/storage.go
Normal 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
|
||||
}
|
||||
46
api/gateway/chsettle/.air.toml
Normal file
46
api/gateway/chsettle/.air.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
entrypoint = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go", "_templ.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
4
api/gateway/chsettle/.gitignore
vendored
Normal file
4
api/gateway/chsettle/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
tmp
|
||||
52
api/gateway/chsettle/config.dev.yml
Normal file
52
api/gateway/chsettle/config.dev.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
runtime:
|
||||
shutdown_timeout_seconds: 15
|
||||
|
||||
grpc:
|
||||
network: tcp
|
||||
address: ":50080"
|
||||
advertise_host: "dev-chsettle-gateway"
|
||||
enable_reflection: true
|
||||
enable_health: true
|
||||
|
||||
metrics:
|
||||
address: ":9406"
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
host_env: CHSETTLE_GATEWAY_MONGO_HOST
|
||||
port_env: CHSETTLE_GATEWAY_MONGO_PORT
|
||||
database_env: CHSETTLE_GATEWAY_MONGO_DATABASE
|
||||
user_env: CHSETTLE_GATEWAY_MONGO_USER
|
||||
password_env: CHSETTLE_GATEWAY_MONGO_PASSWORD
|
||||
auth_source_env: CHSETTLE_GATEWAY_MONGO_AUTH_SOURCE
|
||||
replica_set_env: CHSETTLE_GATEWAY_MONGO_REPLICA_SET
|
||||
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: NATS_URL
|
||||
host_env: NATS_HOST
|
||||
port_env: NATS_PORT
|
||||
username_env: NATS_USER
|
||||
password_env: NATS_PASSWORD
|
||||
broker_name: ChimeraSettle Gateway Service
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
buffer_size: 1024
|
||||
|
||||
gateway:
|
||||
rail: "SETTLEMENT"
|
||||
target_chat_id_env: CHSETTLE_GATEWAY_CHAT_ID
|
||||
timeout_seconds: 345600
|
||||
accepted_user_ids: []
|
||||
success_reaction: "\U0001FAE1"
|
||||
|
||||
treasury:
|
||||
execution_delay: 60s
|
||||
poll_interval: 60s
|
||||
ledger:
|
||||
timeout: 5s
|
||||
limits:
|
||||
max_amount_per_operation: "1000000"
|
||||
max_daily_amount: "5000000"
|
||||
52
api/gateway/chsettle/config.yml
Normal file
52
api/gateway/chsettle/config.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
runtime:
|
||||
shutdown_timeout_seconds: 15
|
||||
|
||||
grpc:
|
||||
network: tcp
|
||||
address: ":50080"
|
||||
advertise_host: "sendico_chsettle_gateway"
|
||||
enable_reflection: true
|
||||
enable_health: true
|
||||
|
||||
metrics:
|
||||
address: ":9406"
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
host_env: CHSETTLE_GATEWAY_MONGO_HOST
|
||||
port_env: CHSETTLE_GATEWAY_MONGO_PORT
|
||||
database_env: CHSETTLE_GATEWAY_MONGO_DATABASE
|
||||
user_env: CHSETTLE_GATEWAY_MONGO_USER
|
||||
password_env: CHSETTLE_GATEWAY_MONGO_PASSWORD
|
||||
auth_source_env: CHSETTLE_GATEWAY_MONGO_AUTH_SOURCE
|
||||
replica_set_env: CHSETTLE_GATEWAY_MONGO_REPLICA_SET
|
||||
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: NATS_URL
|
||||
host_env: NATS_HOST
|
||||
port_env: NATS_PORT
|
||||
username_env: NATS_USER
|
||||
password_env: NATS_PASSWORD
|
||||
broker_name: ChimeraSettle Gateway Service
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
buffer_size: 1024
|
||||
|
||||
gateway:
|
||||
rail: "SETTLEMENT"
|
||||
target_chat_id_env: CHSETTLE_GATEWAY_CHAT_ID
|
||||
timeout_seconds: 345600
|
||||
accepted_user_ids: []
|
||||
success_reaction: "\U0001FAE1"
|
||||
|
||||
treasury:
|
||||
execution_delay: 60s
|
||||
poll_interval: 60s
|
||||
ledger:
|
||||
timeout: 5s
|
||||
limits:
|
||||
max_amount_per_operation: ""
|
||||
max_daily_amount: ""
|
||||
52
api/gateway/chsettle/go.mod
Normal file
52
api/gateway/chsettle/go.mod
Normal file
@@ -0,0 +1,52 @@
|
||||
module github.com/tech/sendico/gateway/chsettle
|
||||
|
||||
go 1.25.7
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
replace github.com/tech/sendico/gateway/common => ../common
|
||||
|
||||
require (
|
||||
github.com/tech/sendico/gateway/common v0.1.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.79.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
)
|
||||
221
api/gateway/chsettle/go.sum
Normal file
221
api/gateway/chsettle/go.sum
Normal file
@@ -0,0 +1,221 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ=
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
27
api/gateway/chsettle/internal/appversion/version.go
Normal file
27
api/gateway/chsettle/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
func Create() version.Printer {
|
||||
info := version.Info{
|
||||
Program: "Sendico Payment Gateway Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&info)
|
||||
}
|
||||
230
api/gateway/chsettle/internal/server/internal/serverimp.go
Normal file
230
api/gateway/chsettle/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/chsettle/internal/service/gateway"
|
||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
||||
gatewaymongo "github.com/tech/sendico/gateway/chsettle/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Imp struct {
|
||||
logger mlogger.Logger
|
||||
file string
|
||||
debug bool
|
||||
|
||||
config *config
|
||||
app *grpcapp.App[storage.Repository]
|
||||
service *gateway.Service
|
||||
|
||||
discoveryWatcher *discovery.RegistryWatcher
|
||||
discoveryReg *discovery.Registry
|
||||
}
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Gateway gatewayConfig `yaml:"gateway"`
|
||||
Treasury treasuryConfig `yaml:"treasury"`
|
||||
}
|
||||
|
||||
type gatewayConfig struct {
|
||||
Rail string `yaml:"rail"`
|
||||
TargetChatIDEnv string `yaml:"target_chat_id_env"`
|
||||
TimeoutSeconds int32 `yaml:"timeout_seconds"`
|
||||
AcceptedUserIDs []string `yaml:"accepted_user_ids"`
|
||||
SuccessReaction string `yaml:"success_reaction"`
|
||||
}
|
||||
|
||||
type treasuryConfig struct {
|
||||
ExecutionDelay time.Duration `yaml:"execution_delay"`
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
Ledger ledgerConfig `yaml:"ledger"`
|
||||
Limits treasuryLimitsConfig `yaml:"limits"`
|
||||
}
|
||||
|
||||
type treasuryLimitsConfig struct {
|
||||
MaxAmountPerOperation string `yaml:"max_amount_per_operation"`
|
||||
MaxDailyAmount string `yaml:"max_daily_amount"`
|
||||
}
|
||||
|
||||
type ledgerConfig struct {
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
logger: logger.Named("server"),
|
||||
file: file,
|
||||
debug: debug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i *Imp) Shutdown() {
|
||||
if i.app == nil {
|
||||
return
|
||||
}
|
||||
timeout := 15 * time.Second
|
||||
if i.config != nil && i.config.Runtime != nil {
|
||||
timeout = i.config.Runtime.ShutdownTimeout()
|
||||
}
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
if i.discoveryWatcher != nil {
|
||||
i.discoveryWatcher.Stop()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
i.app.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (i *Imp) Start() error {
|
||||
cfg, err := i.loadConfig()
|
||||
if err != nil {
|
||||
i.logger.Error("Service startup aborted: invalid configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
i.config = cfg
|
||||
|
||||
var broker mb.Broker
|
||||
if cfg.Messaging != nil && cfg.Messaging.Driver != "" {
|
||||
broker, err = msg.CreateMessagingBroker(i.logger, cfg.Messaging)
|
||||
if err != nil {
|
||||
i.logger.Warn("Failed to create messaging broker", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if broker != nil {
|
||||
registry := discovery.NewRegistry()
|
||||
watcher, watcherErr := discovery.NewRegistryWatcher(i.logger, broker, registry)
|
||||
if watcherErr != nil {
|
||||
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(watcherErr))
|
||||
} else if startErr := watcher.Start(); startErr != nil {
|
||||
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(startErr))
|
||||
} else {
|
||||
i.discoveryWatcher = watcher
|
||||
i.discoveryReg = registry
|
||||
i.logger.Info("Discovery registry watcher started")
|
||||
}
|
||||
}
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
return gatewaymongo.New(logger, conn)
|
||||
}
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
invokeURI := ""
|
||||
if cfg.GRPC != nil {
|
||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||
}
|
||||
msgSettings := map[string]any(nil)
|
||||
if cfg.Messaging != nil {
|
||||
msgSettings = cfg.Messaging.Settings
|
||||
}
|
||||
gwCfg := gateway.Config{
|
||||
Rail: cfg.Gateway.Rail,
|
||||
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
|
||||
TimeoutSeconds: cfg.Gateway.TimeoutSeconds,
|
||||
AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs,
|
||||
SuccessReaction: cfg.Gateway.SuccessReaction,
|
||||
InvokeURI: invokeURI,
|
||||
MessagingSettings: msgSettings,
|
||||
DiscoveryRegistry: i.discoveryReg,
|
||||
Treasury: gateway.TreasuryConfig{
|
||||
ExecutionDelay: cfg.Treasury.ExecutionDelay,
|
||||
PollInterval: cfg.Treasury.PollInterval,
|
||||
Ledger: gateway.LedgerConfig{
|
||||
Timeout: cfg.Treasury.Ledger.Timeout,
|
||||
},
|
||||
Limits: gateway.TreasuryLimitsConfig{
|
||||
MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation,
|
||||
MaxDailyAmount: cfg.Treasury.Limits.MaxDailyAmount,
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := gateway.NewService(logger, repo, producer, broker, gwCfg)
|
||||
i.service = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "chsettle_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
if err != nil {
|
||||
i.logger.Error("Service startup aborted: failed to construct app", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
i.app = app
|
||||
if err := i.app.Start(); err != nil {
|
||||
i.logger.Error("Service startup aborted: app start failed", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
grpcAddress := ""
|
||||
metricsAddress := ""
|
||||
if cfg.GRPC != nil {
|
||||
grpcAddress = strings.TrimSpace(cfg.GRPC.Address)
|
||||
}
|
||||
if cfg.Metrics != nil {
|
||||
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
|
||||
}
|
||||
i.logger.Info("ChimeraSettle gateway started",
|
||||
zap.String("grpc_address", grpcAddress),
|
||||
zap.String("metrics_address", metricsAddress))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
cfg := &config{Config: &grpcapp.Config{}}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Runtime == nil {
|
||||
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||
}
|
||||
if cfg.GRPC == nil {
|
||||
cfg.GRPC = &routers.GRPCConfig{
|
||||
Network: "tcp",
|
||||
Address: ":50080",
|
||||
EnableReflection: true,
|
||||
EnableHealth: true,
|
||||
}
|
||||
}
|
||||
if cfg.Metrics == nil {
|
||||
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"}
|
||||
}
|
||||
if cfg.Treasury.ExecutionDelay <= 0 {
|
||||
cfg.Treasury.ExecutionDelay = 30 * time.Second
|
||||
}
|
||||
if cfg.Treasury.PollInterval <= 0 {
|
||||
cfg.Treasury.PollInterval = 30 * time.Second
|
||||
}
|
||||
if cfg.Treasury.Ledger.Timeout <= 0 {
|
||||
cfg.Treasury.Ledger.Timeout = 5 * time.Second
|
||||
}
|
||||
cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail)
|
||||
if cfg.Gateway.Rail == "" {
|
||||
i.logger.Error("Invalid configuration: gateway rail is required")
|
||||
return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail")
|
||||
}
|
||||
if !discovery.IsKnownRail(cfg.Gateway.Rail) {
|
||||
i.logger.Error("Invalid configuration: gateway rail is unknown", zap.String("gateway_rail", cfg.Gateway.Rail))
|
||||
return nil, merrors.InvalidArgument("gateway rail must be a known token", "gateway.rail")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
11
api/gateway/chsettle/internal/server/server.go
Normal file
11
api/gateway/chsettle/internal/server/server.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
serverimp "github.com/tech/sendico/gateway/chsettle/internal/server/internal"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
)
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return serverimp.Create(logger, file, debug)
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
|
||||
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var amountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
|
||||
var currencyPattern = regexp.MustCompile(`^[A-Za-z]{3,10}$`)
|
||||
|
||||
func (s *Service) startConfirmationTimeoutWatcher() {
|
||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
||||
return
|
||||
}
|
||||
if s.timeoutCancel != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.timeoutCtx = ctx
|
||||
s.timeoutCancel = cancel
|
||||
s.timeoutWG.Add(1)
|
||||
go func() {
|
||||
defer s.timeoutWG.Done()
|
||||
ticker := time.NewTicker(defaultConfirmationSweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.sweepExpiredConfirmations(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) sweepExpiredConfirmations(ctx context.Context) {
|
||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
||||
return
|
||||
}
|
||||
expired, err := s.repo.PendingConfirmations().ListExpired(ctx, time.Now(), 100)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for i := range expired {
|
||||
pending := &expired[i]
|
||||
if strings.TrimSpace(pending.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: pending.RequestID,
|
||||
Status: model.ConfirmationStatusTimeout,
|
||||
}
|
||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
||||
s.logger.Warn("Failed to publish timeout confirmation result", zap.Error(err), zap.String("request_id", pending.RequestID))
|
||||
continue
|
||||
}
|
||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
||||
s.logger.Warn("Failed to remove expired pending confirmation", zap.Error(err), zap.String("request_id", pending.RequestID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) persistPendingConfirmation(ctx context.Context, request *model.ConfirmationRequest) error {
|
||||
if request == nil {
|
||||
return merrors.InvalidArgument("confirmation request is nil", "request")
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
||||
return merrors.Internal("pending confirmations store unavailable")
|
||||
}
|
||||
timeout := request.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = int32(defaultConfirmationTimeoutSeconds)
|
||||
}
|
||||
pending := &storagemodel.PendingConfirmation{
|
||||
RequestID: strings.TrimSpace(request.RequestID),
|
||||
TargetChatID: strings.TrimSpace(request.TargetChatID),
|
||||
AcceptedUserIDs: normalizeStringList(request.AcceptedUserIDs),
|
||||
RequestedMoney: request.RequestedMoney,
|
||||
SourceService: strings.TrimSpace(request.SourceService),
|
||||
Rail: strings.TrimSpace(request.Rail),
|
||||
ExpiresAt: time.Now().Add(time.Duration(timeout) * time.Second),
|
||||
}
|
||||
return s.repo.PendingConfirmations().Upsert(ctx, pending)
|
||||
}
|
||||
|
||||
func (s *Service) clearPendingConfirmation(ctx context.Context, requestID string) error {
|
||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
||||
return nil
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return nil
|
||||
}
|
||||
return s.repo.PendingConfirmations().DeleteByRequestID(ctx, requestID)
|
||||
}
|
||||
|
||||
func (s *Service) onConfirmationDispatch(ctx context.Context, dispatch *model.ConfirmationRequestDispatch) error {
|
||||
if dispatch == nil {
|
||||
return merrors.InvalidArgument("confirmation dispatch is nil", "dispatch")
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
||||
return merrors.Internal("pending confirmations store unavailable")
|
||||
}
|
||||
requestID := strings.TrimSpace(dispatch.RequestID)
|
||||
messageID := strings.TrimSpace(dispatch.MessageID)
|
||||
if requestID == "" {
|
||||
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
|
||||
}
|
||||
if messageID == "" {
|
||||
return merrors.InvalidArgument("confirmation message_id is required", "message_id")
|
||||
}
|
||||
if err := s.repo.PendingConfirmations().AttachMessage(ctx, requestID, messageID); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
s.logger.Info("Confirmation dispatch ignored: pending request not found",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("message_id", messageID))
|
||||
return nil
|
||||
}
|
||||
s.logger.Warn("Failed to attach confirmation message id", zap.Error(err), zap.String("request_id", requestID), zap.String("message_id", messageID))
|
||||
return err
|
||||
}
|
||||
s.logger.Info("Pending confirmation message attached", zap.String("request_id", requestID), zap.String("message_id", messageID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) error {
|
||||
if update == nil || update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.PendingConfirmations() == nil {
|
||||
return merrors.Internal("pending confirmations store unavailable")
|
||||
}
|
||||
message := update.Message
|
||||
replyToID := strings.TrimSpace(message.ReplyToMessageID)
|
||||
if replyToID == "" {
|
||||
s.handleTreasuryTelegramUpdate(ctx, update)
|
||||
return nil
|
||||
}
|
||||
replyFields := telegramReplyLogFields(update)
|
||||
pending, err := s.repo.PendingConfirmations().FindByMessageID(ctx, replyToID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pending == nil {
|
||||
if s.handleTreasuryTelegramUpdate(ctx, update) {
|
||||
return nil
|
||||
}
|
||||
s.logger.Warn("Telegram confirmation reply dropped",
|
||||
append(replyFields,
|
||||
zap.String("outcome", "dropped"),
|
||||
zap.String("reason", "no_pending_confirmation"),
|
||||
)...)
|
||||
return nil
|
||||
}
|
||||
replyFields = append(replyFields,
|
||||
zap.String("request_id", strings.TrimSpace(pending.RequestID)),
|
||||
zap.String("target_chat_id", strings.TrimSpace(pending.TargetChatID)),
|
||||
)
|
||||
|
||||
if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) {
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: pending.RequestID,
|
||||
Status: model.ConfirmationStatusTimeout,
|
||||
}
|
||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("Telegram confirmation reply processed",
|
||||
append(replyFields,
|
||||
zap.String("outcome", "processed"),
|
||||
zap.String("result_status", string(result.Status)),
|
||||
zap.String("reason", "expired_confirmation"),
|
||||
)...)
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(message.ChatID) != strings.TrimSpace(pending.TargetChatID) {
|
||||
s.logger.Warn("Telegram confirmation reply dropped",
|
||||
append(replyFields,
|
||||
zap.String("outcome", "dropped"),
|
||||
zap.String("reason", "chat_mismatch"),
|
||||
zap.String("expected_chat_id", strings.TrimSpace(pending.TargetChatID)),
|
||||
)...)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isUserAllowed(message.FromUserID, pending.AcceptedUserIDs) {
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: pending.RequestID,
|
||||
Status: model.ConfirmationStatusRejected,
|
||||
ParseError: "unauthorized_user",
|
||||
RawReply: message,
|
||||
}
|
||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
||||
return err
|
||||
}
|
||||
if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{
|
||||
RequestID: pending.RequestID,
|
||||
ChatID: pending.TargetChatID,
|
||||
ReplyToMessageID: message.MessageID,
|
||||
Text: "Only approved users can confirm this payment.",
|
||||
}); e != nil {
|
||||
s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...)
|
||||
}
|
||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("Telegram confirmation reply processed",
|
||||
append(replyFields,
|
||||
zap.String("outcome", "processed"),
|
||||
zap.String("result_status", string(result.Status)),
|
||||
zap.String("reason", "unauthorized_user"),
|
||||
)...)
|
||||
return nil
|
||||
}
|
||||
|
||||
money, reason, err := parseConfirmationReply(message.Text)
|
||||
if err != nil {
|
||||
if markErr := s.repo.PendingConfirmations().MarkClarified(ctx, pending.RequestID); markErr != nil {
|
||||
s.logger.Warn("Failed to mark confirmation as clarified", zap.Error(markErr), zap.String("request_id", pending.RequestID))
|
||||
}
|
||||
if e := s.sendTelegramText(ctx, &model.TelegramTextRequest{
|
||||
RequestID: pending.RequestID,
|
||||
ChatID: pending.TargetChatID,
|
||||
ReplyToMessageID: message.MessageID,
|
||||
Text: clarificationMessage(reason),
|
||||
}); e != nil {
|
||||
s.logger.Warn("Failed to create telegram text", append(replyFields, zap.Error(err))...)
|
||||
}
|
||||
s.logger.Warn("Telegram confirmation reply dropped",
|
||||
append(replyFields,
|
||||
zap.String("outcome", "dropped"),
|
||||
zap.String("reason", "invalid_reply_format"),
|
||||
zap.String("parse_reason", reason),
|
||||
)...)
|
||||
return nil
|
||||
}
|
||||
|
||||
status := model.ConfirmationStatusConfirmed
|
||||
if pending.Clarified {
|
||||
status = model.ConfirmationStatusClarified
|
||||
}
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: pending.RequestID,
|
||||
Money: money,
|
||||
RawReply: message,
|
||||
Status: status,
|
||||
}
|
||||
if err := s.publishPendingConfirmationResult(pending, result); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.clearPendingConfirmation(ctx, pending.RequestID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("Telegram confirmation reply processed",
|
||||
append(replyFields,
|
||||
zap.String("outcome", "processed"),
|
||||
zap.String("result_status", string(result.Status)),
|
||||
)...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleTreasuryTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
if s == nil || s.treasury == nil || update == nil || update.Message == nil {
|
||||
return false
|
||||
}
|
||||
return s.treasury.HandleUpdate(ctx, update)
|
||||
}
|
||||
|
||||
func telegramReplyLogFields(update *model.TelegramWebhookUpdate) []zap.Field {
|
||||
if update == nil || update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
message := update.Message
|
||||
return []zap.Field{
|
||||
zap.Int64("update_id", update.UpdateID),
|
||||
zap.String("message_id", strings.TrimSpace(message.MessageID)),
|
||||
zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)),
|
||||
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
|
||||
zap.String("from_user_id", strings.TrimSpace(message.FromUserID)),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishPendingConfirmationResult(pending *storagemodel.PendingConfirmation, result *model.ConfirmationResult) error {
|
||||
if pending == nil || result == nil {
|
||||
return merrors.InvalidArgument("pending confirmation context is required")
|
||||
}
|
||||
if s == nil || s.producer == nil {
|
||||
return merrors.Internal("messaging producer is not configured")
|
||||
}
|
||||
sourceService := strings.TrimSpace(pending.SourceService)
|
||||
if sourceService == "" {
|
||||
sourceService = string(mservice.PaymentGateway)
|
||||
}
|
||||
rail := strings.TrimSpace(pending.Rail)
|
||||
if rail == "" {
|
||||
rail = s.rail
|
||||
}
|
||||
env := confirmations.ConfirmationResult(string(mservice.PaymentGateway), result, sourceService, rail)
|
||||
if err := s.producer.SendMessage(env); err != nil {
|
||||
s.logger.Warn("Failed to publish confirmation result", zap.Error(err),
|
||||
zap.String("request_id", strings.TrimSpace(result.RequestID)),
|
||||
zap.String("status", string(result.Status)),
|
||||
zap.String("source_service", sourceService),
|
||||
zap.String("rail", rail))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) sendTelegramText(_ context.Context, request *model.TelegramTextRequest) error {
|
||||
if request == nil {
|
||||
return merrors.InvalidArgument("telegram text request is nil", "request")
|
||||
}
|
||||
if s == nil || s.producer == nil {
|
||||
return merrors.Internal("messaging producer is not configured")
|
||||
}
|
||||
request.ChatID = strings.TrimSpace(request.ChatID)
|
||||
request.Text = strings.TrimSpace(request.Text)
|
||||
request.ReplyToMessageID = strings.TrimSpace(request.ReplyToMessageID)
|
||||
if request.ChatID == "" || request.Text == "" {
|
||||
return merrors.InvalidArgument("telegram chat_id and text are required", "chat_id", "text")
|
||||
}
|
||||
env := tnotifications.TelegramText(string(mservice.PaymentGateway), request)
|
||||
if err := s.producer.SendMessage(env); err != nil {
|
||||
s.logger.Warn("Failed to publish telegram text request", zap.Error(err),
|
||||
zap.String("request_id", request.RequestID),
|
||||
zap.String("chat_id", request.ChatID),
|
||||
zap.String("reply_to_message_id", request.ReplyToMessageID))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isFinalConfirmationStatus(status model.ConfirmationStatus) bool {
|
||||
switch status {
|
||||
case model.ConfirmationStatusConfirmed, model.ConfirmationStatusRejected, model.ConfirmationStatusTimeout, model.ConfirmationStatusClarified:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isUserAllowed(userID string, allowed []string) bool {
|
||||
allowed = normalizeStringList(allowed)
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
for _, id := range allowed {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseConfirmationReply(text string) (*paymenttypes.Money, string, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, "empty", merrors.InvalidArgument("empty reply")
|
||||
}
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 {
|
||||
if len(parts) == 1 && amountPattern.MatchString(parts[0]) {
|
||||
return nil, "missing_currency", merrors.InvalidArgument("currency is required")
|
||||
}
|
||||
return nil, "missing_amount", merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
return nil, "format", merrors.InvalidArgument("reply format is invalid")
|
||||
}
|
||||
amount := parts[0]
|
||||
currency := parts[1]
|
||||
if !amountPattern.MatchString(amount) {
|
||||
return nil, "invalid_amount", merrors.InvalidArgument("amount format is invalid")
|
||||
}
|
||||
if !currencyPattern.MatchString(currency) {
|
||||
return nil, "invalid_currency", merrors.InvalidArgument("currency format is invalid")
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
Currency: strings.ToUpper(currency),
|
||||
}, "", nil
|
||||
}
|
||||
|
||||
func clarificationMessage(reason string) string {
|
||||
switch reason {
|
||||
case "missing_currency":
|
||||
return "Currency code is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
case "missing_amount":
|
||||
return "Amount is required. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
case "invalid_amount":
|
||||
return "Amount must be a decimal number. Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
case "invalid_currency":
|
||||
return "Currency must be a code like USD or EUR. Reply with \"<amount> <currency>\"."
|
||||
default:
|
||||
return "Reply with \"<amount> <currency>\" (e.g., 12.34 USD)."
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
413
api/gateway/chsettle/internal/service/gateway/connector.go
Normal file
413
api/gateway/chsettle/internal/service/gateway/connector.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/connector/params"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const (
|
||||
chsettleConnectorID = "chsettle"
|
||||
connectorScenarioParam = "scenario"
|
||||
connectorScenarioMetaKey = "chsettle_scenario"
|
||||
)
|
||||
|
||||
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
|
||||
return &connectorv1.GetCapabilitiesResponse{
|
||||
Capabilities: &connectorv1.ConnectorCapabilities{
|
||||
ConnectorType: chsettleConnectorID,
|
||||
Version: "",
|
||||
SupportedAccountKinds: nil,
|
||||
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER},
|
||||
OperationParams: chsettleOperationParams(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
|
||||
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
|
||||
return nil, merrors.NotImplemented("get_account: unsupported")
|
||||
}
|
||||
|
||||
func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||
return nil, merrors.NotImplemented("list_accounts: unsupported")
|
||||
}
|
||||
|
||||
func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
|
||||
return nil, merrors.NotImplemented("get_balance: unsupported")
|
||||
}
|
||||
|
||||
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
|
||||
if req == nil || req.GetOperation() == nil {
|
||||
s.logger.Warn("Submit operation rejected", zap.String("reason", "operation is required"))
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
|
||||
}
|
||||
op := req.GetOperation()
|
||||
s.logger.Debug("Submit operation request received",
|
||||
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
|
||||
zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())),
|
||||
zap.String("operation_ref", strings.TrimSpace(op.GetOperationRef())))
|
||||
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
|
||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "idempotency_key is required"))...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
|
||||
}
|
||||
if op.GetType() != connectorv1.OperationType_TRANSFER {
|
||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "unsupported operation type"))...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
|
||||
}
|
||||
reader := params.New(op.GetParams())
|
||||
metadata := reader.StringMap("metadata")
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id"))
|
||||
if paymentIntentID == "" {
|
||||
paymentIntentID = strings.TrimSpace(reader.String("payment_ref"))
|
||||
}
|
||||
if paymentIntentID == "" {
|
||||
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
|
||||
}
|
||||
if paymentIntentID == "" {
|
||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "payment_intent_id is required"))...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: payment_intent_id is required", op, "")}}, nil
|
||||
}
|
||||
source := operationAccountID(op.GetFrom())
|
||||
if source == "" {
|
||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "from.account is required"))...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account is required", op, "")}}, nil
|
||||
}
|
||||
dest, err := transferDestinationFromOperation(op)
|
||||
if err != nil {
|
||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.Error(err))...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||
}
|
||||
amount := op.GetMoney()
|
||||
if amount == nil {
|
||||
s.logger.Warn("Submit operation rejected", append(operationLogFields(op), zap.String("reason", "money is required"))...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
|
||||
}
|
||||
|
||||
metadata[metadataPaymentIntentID] = paymentIntentID
|
||||
quoteRef := strings.TrimSpace(reader.String("quote_ref"))
|
||||
if quoteRef != "" {
|
||||
metadata[metadataQuoteRef] = quoteRef
|
||||
}
|
||||
targetChatID := strings.TrimSpace(reader.String("target_chat_id"))
|
||||
if targetChatID != "" {
|
||||
metadata[metadataTargetChatID] = targetChatID
|
||||
}
|
||||
outgoingLeg := normalizeRail(reader.String("outgoing_leg"))
|
||||
if outgoingLeg != "" {
|
||||
metadata[metadataOutgoingLeg] = outgoingLeg
|
||||
}
|
||||
if scenario := strings.TrimSpace(reader.String(connectorScenarioParam)); scenario != "" {
|
||||
metadata[connectorScenarioMetaKey] = scenario
|
||||
}
|
||||
s.logger.Debug("Submit operation parsed transfer metadata",
|
||||
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
|
||||
zap.String("payment_intent_id", paymentIntentID),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.String("target_chat_id", targetChatID),
|
||||
zap.String("outgoing_leg", outgoingLeg),
|
||||
zap.String("scenario_override", strings.TrimSpace(metadata[connectorScenarioMetaKey])))
|
||||
|
||||
normalizedAmount := normalizeMoneyForTransfer(amount)
|
||||
logFields := append(operationLogFields(op),
|
||||
zap.String("payment_intent_id", paymentIntentID),
|
||||
zap.String("organization_ref", strings.TrimSpace(reader.String("organization_ref"))),
|
||||
zap.String("source_wallet_ref", source),
|
||||
zap.String("amount", strings.TrimSpace(normalizedAmount.GetAmount())),
|
||||
zap.String("currency", strings.TrimSpace(normalizedAmount.GetCurrency())),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.String("operation_ref", req.Operation.GetOperationRef()),
|
||||
zap.String("intent_ref", op.GetIntentRef()),
|
||||
zap.String("outgoing_leg", outgoingLeg),
|
||||
)
|
||||
logFields = append(logFields, transferDestinationLogFields(dest)...)
|
||||
s.logger.Debug("Submit operation forwarding to transfer handler", logFields...)
|
||||
|
||||
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
|
||||
OrganizationRef: strings.TrimSpace(reader.String("organization_ref")),
|
||||
SourceWalletRef: source,
|
||||
Destination: dest,
|
||||
Amount: normalizedAmount,
|
||||
Metadata: metadata,
|
||||
PaymentRef: paymentIntentID,
|
||||
IntentRef: strings.TrimSpace(op.GetIntentRef()),
|
||||
OperationRef: strings.TrimSpace(op.GetOperationRef()),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Submit operation transfer failed", append(logFields, zap.Error(err))...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||
}
|
||||
transfer := resp.GetTransfer()
|
||||
operationID := strings.TrimSpace(transfer.GetOperationRef())
|
||||
if operationID == "" {
|
||||
s.logger.Warn("Submit operation transfer response missing operation_ref", append(logFields,
|
||||
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
|
||||
)...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{
|
||||
Error: connectorError(connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE, "submit_operation: operation_ref is missing in transfer response", op, ""),
|
||||
}}, nil
|
||||
}
|
||||
s.logger.Info("Submit operation transfer submitted", append(logFields,
|
||||
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
|
||||
zap.String("status", transfer.GetStatus().String()),
|
||||
)...)
|
||||
return &connectorv1.SubmitOperationResponse{
|
||||
Receipt: &connectorv1.OperationReceipt{
|
||||
OperationId: operationID,
|
||||
Status: transferStatusToOperation(transfer.GetStatus()),
|
||||
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
|
||||
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||
s.logger.Warn("Get operation rejected", zap.String("reason", "operation_id is required"))
|
||||
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||
}
|
||||
operationID := strings.TrimSpace(req.GetOperationId())
|
||||
s.logger.Debug("Get operation request received", zap.String("operation_id", operationID))
|
||||
|
||||
if s.repo == nil || s.repo.Payments() == nil {
|
||||
s.logger.Warn("Get operation storage unavailable", zap.String("operation_id", operationID))
|
||||
return nil, merrors.Internal("get_operation: storage is not configured")
|
||||
}
|
||||
|
||||
record, err := s.repo.Payments().FindByOperationRef(ctx, operationID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
s.logger.Info("Get operation not found", zap.String("operation_id", operationID))
|
||||
return nil, status.Error(codes.NotFound, "operation not found")
|
||||
}
|
||||
|
||||
return &connectorv1.GetOperationResponse{Operation: transferToOperation(transferFromPayment(record, nil))}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||
return nil, merrors.NotImplemented("list_operations: unsupported")
|
||||
}
|
||||
|
||||
func chsettleOperationParams() []*connectorv1.OperationParamSpec {
|
||||
return []*connectorv1.OperationParamSpec{
|
||||
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
|
||||
{Key: "payment_intent_id", Type: connectorv1.ParamType_STRING, Required: true},
|
||||
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: "quote_ref", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: "target_chat_id", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: "outgoing_leg", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: connectorScenarioParam, Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
|
||||
if op == nil {
|
||||
return nil, merrors.InvalidArgument("transfer: operation is required")
|
||||
}
|
||||
if to := op.GetTo(); to != nil {
|
||||
if account := to.GetAccount(); account != nil {
|
||||
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
|
||||
}
|
||||
if ext := to.GetExternal(); ext != nil {
|
||||
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
|
||||
}
|
||||
}
|
||||
return nil, merrors.InvalidArgument("transfer: to.account or to.external is required")
|
||||
}
|
||||
|
||||
func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
currency := strings.TrimSpace(m.GetCurrency())
|
||||
if idx := strings.Index(currency, "-"); idx > 0 {
|
||||
currency = currency[:idx]
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Amount: strings.TrimSpace(m.GetAmount()),
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
|
||||
func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
|
||||
if transfer == nil {
|
||||
return nil
|
||||
}
|
||||
op := &connectorv1.Operation{
|
||||
OperationId: strings.TrimSpace(transfer.GetOperationRef()),
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
Status: transferStatusToOperation(transfer.GetStatus()),
|
||||
Money: transfer.GetRequestedAmount(),
|
||||
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
|
||||
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
|
||||
CreatedAt: transfer.GetCreatedAt(),
|
||||
UpdatedAt: transfer.GetUpdatedAt(),
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
|
||||
params["payment_ref"] = paymentRef
|
||||
}
|
||||
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
|
||||
params["organization_ref"] = organizationRef
|
||||
}
|
||||
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
|
||||
params["failure_reason"] = failureReason
|
||||
}
|
||||
if len(params) > 0 {
|
||||
op.Params = structFromMap(params)
|
||||
}
|
||||
if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
|
||||
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: chsettleConnectorID,
|
||||
AccountId: source,
|
||||
}}}
|
||||
}
|
||||
if dest := transfer.GetDestination(); dest != nil {
|
||||
switch d := dest.GetDestination().(type) {
|
||||
case *chainv1.TransferDestination_ManagedWalletRef:
|
||||
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: chsettleConnectorID,
|
||||
AccountId: strings.TrimSpace(d.ManagedWalletRef),
|
||||
}}}
|
||||
case *chainv1.TransferDestination_ExternalAddress:
|
||||
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
|
||||
ExternalRef: strings.TrimSpace(d.ExternalAddress),
|
||||
}}}
|
||||
}
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
|
||||
switch status {
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_CREATED:
|
||||
return connectorv1.OperationStatus_OPERATION_CREATED
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_PROCESSING:
|
||||
return connectorv1.OperationStatus_OPERATION_PROCESSING
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_WAITING:
|
||||
return connectorv1.OperationStatus_OPERATION_WAITING
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_SUCCESS:
|
||||
return connectorv1.OperationStatus_OPERATION_SUCCESS
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||
return connectorv1.OperationStatus_OPERATION_FAILED
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
return connectorv1.OperationStatus_OPERATION_CANCELLED
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED:
|
||||
fallthrough
|
||||
default:
|
||||
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func operationAccountID(party *connectorv1.OperationParty) string {
|
||||
if party == nil {
|
||||
return ""
|
||||
}
|
||||
if account := party.GetAccount(); account != nil {
|
||||
return strings.TrimSpace(account.GetAccountId())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func structFromMap(values map[string]interface{}) *structpb.Struct {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := structpb.NewStruct(values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func operationLogFields(op *connectorv1.Operation) []zap.Field {
|
||||
if op == nil {
|
||||
return nil
|
||||
}
|
||||
return []zap.Field{
|
||||
zap.String("operation_id", strings.TrimSpace(op.GetOperationId())),
|
||||
zap.String("idempotency_key", strings.TrimSpace(op.GetIdempotencyKey())),
|
||||
zap.String("correlation_id", strings.TrimSpace(op.GetCorrelationId())),
|
||||
zap.String("parent_intent_id", strings.TrimSpace(op.GetParentIntentId())),
|
||||
zap.String("operation_type", op.GetType().String()),
|
||||
zap.String("intent_ref", strings.TrimSpace(op.GetIntentRef())),
|
||||
}
|
||||
}
|
||||
|
||||
func transferDestinationLogFields(dest *chainv1.TransferDestination) []zap.Field {
|
||||
if dest == nil {
|
||||
return nil
|
||||
}
|
||||
switch d := dest.GetDestination().(type) {
|
||||
case *chainv1.TransferDestination_ManagedWalletRef:
|
||||
return []zap.Field{
|
||||
zap.String("destination_type", "managed_wallet"),
|
||||
zap.String("destination_ref", strings.TrimSpace(d.ManagedWalletRef)),
|
||||
}
|
||||
case *chainv1.TransferDestination_ExternalAddress:
|
||||
return []zap.Field{
|
||||
zap.String("destination_type", "external_address"),
|
||||
zap.String("destination_ref", strings.TrimSpace(d.ExternalAddress)),
|
||||
}
|
||||
default:
|
||||
return []zap.Field{zap.String("destination_type", "unknown")}
|
||||
}
|
||||
}
|
||||
|
||||
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||
err := &connectorv1.ConnectorError{
|
||||
Code: code,
|
||||
Message: strings.TrimSpace(message),
|
||||
AccountId: strings.TrimSpace(accountID),
|
||||
}
|
||||
if op != nil {
|
||||
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
|
||||
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
|
||||
err.OperationId = strings.TrimSpace(op.GetOperationId())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func mapErrorCode(err error) connectorv1.ErrorCode {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return connectorv1.ErrorCode_INVALID_PARAMS
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return connectorv1.ErrorCode_NOT_FOUND
|
||||
case errors.Is(err, merrors.ErrNotImplemented):
|
||||
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
|
||||
case errors.Is(err, merrors.ErrInternal):
|
||||
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
|
||||
default:
|
||||
return connectorv1.ErrorCode_PROVIDER_ERROR
|
||||
}
|
||||
}
|
||||
119
api/gateway/chsettle/internal/service/gateway/connector_test.go
Normal file
119
api/gateway/chsettle/internal/service/gateway/connector_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestSubmitOperation_UsesOperationRefAsOperationID(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
svc.chatID = "1"
|
||||
|
||||
req := &connectorv1.SubmitOperationRequest{
|
||||
Operation: &connectorv1.Operation{
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
IdempotencyKey: "idem-settlement-1",
|
||||
OperationRef: "payment-1:hop_2_settlement_fx_convert",
|
||||
IntentRef: "intent-1",
|
||||
Money: &moneyv1.Money{Amount: "1.00", Currency: "USDT"},
|
||||
From: &connectorv1.OperationParty{
|
||||
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: chsettleConnectorID,
|
||||
AccountId: "wallet-src",
|
||||
}},
|
||||
},
|
||||
To: &connectorv1.OperationParty{
|
||||
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: chsettleConnectorID,
|
||||
AccountId: "wallet-dst",
|
||||
}},
|
||||
},
|
||||
Params: structFromMap(map[string]interface{}{
|
||||
"payment_ref": "payment-1",
|
||||
"organization_ref": "org-1",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.SubmitOperation(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitOperation returned error: %v", err)
|
||||
}
|
||||
if resp.GetReceipt() == nil {
|
||||
t.Fatal("expected receipt")
|
||||
}
|
||||
if got := resp.GetReceipt().GetError(); got != nil {
|
||||
t.Fatalf("expected no connector error, got: %v", got)
|
||||
}
|
||||
if got, want := resp.GetReceipt().GetOperationId(), "payment-1:hop_2_settlement_fx_convert"; got != want {
|
||||
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resp.GetReceipt().GetProviderRef(), "idem-settlement-1"; got != want {
|
||||
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperation_UsesOperationRefIdentity(t *testing.T) {
|
||||
svc, repo, _ := newTestService(t)
|
||||
|
||||
record := &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-settlement-2",
|
||||
OperationRef: "payment-2:hop_2_settlement_fx_convert",
|
||||
PaymentIntentID: "pi-2",
|
||||
PaymentRef: "payment-2",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
|
||||
Status: storagemodel.PaymentStatusSuccess,
|
||||
}
|
||||
if err := repo.payments.Upsert(context.Background(), record); err != nil {
|
||||
t.Fatalf("failed to seed payment record: %v", err)
|
||||
}
|
||||
|
||||
resp, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
|
||||
OperationId: "payment-2:hop_2_settlement_fx_convert",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetOperation returned error: %v", err)
|
||||
}
|
||||
if resp.GetOperation() == nil {
|
||||
t.Fatal("expected operation")
|
||||
}
|
||||
if got, want := resp.GetOperation().GetOperationId(), "payment-2:hop_2_settlement_fx_convert"; got != want {
|
||||
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resp.GetOperation().GetProviderRef(), "idem-settlement-2"; got != want {
|
||||
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperation_DoesNotResolveByIdempotencyKey(t *testing.T) {
|
||||
svc, repo, _ := newTestService(t)
|
||||
|
||||
record := &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-settlement-3",
|
||||
OperationRef: "payment-3:hop_2_settlement_fx_convert",
|
||||
PaymentIntentID: "pi-3",
|
||||
PaymentRef: "payment-3",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
|
||||
Status: storagemodel.PaymentStatusSuccess,
|
||||
}
|
||||
if err := repo.payments.Upsert(context.Background(), record); err != nil {
|
||||
t.Fatalf("failed to seed payment record: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
|
||||
OperationId: "idem-settlement-3",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
if status.Code(err) != codes.NotFound {
|
||||
t.Fatalf("unexpected error code: got=%s want=%s", status.Code(err), codes.NotFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
)
|
||||
|
||||
type tgOutboxProvider interface {
|
||||
Outbox() gatewayoutbox.Store
|
||||
}
|
||||
|
||||
type tgTransactionProvider interface {
|
||||
TransactionFactory() transaction.Factory
|
||||
}
|
||||
|
||||
func (s *Service) outboxStore() gatewayoutbox.Store {
|
||||
provider, ok := s.repo.(tgOutboxProvider)
|
||||
if !ok || provider == nil {
|
||||
return nil
|
||||
}
|
||||
return provider.Outbox()
|
||||
}
|
||||
|
||||
func (s *Service) startOutboxReliableProducer() error {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil
|
||||
}
|
||||
return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg)
|
||||
}
|
||||
|
||||
func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error {
|
||||
if err := s.startOutboxReliableProducer(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.outbox.Send(ctx, env)
|
||||
}
|
||||
|
||||
func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
provider, ok := s.repo.(tgTransactionProvider)
|
||||
if !ok || provider == nil || provider.TransactionFactory() == nil {
|
||||
return cb(ctx)
|
||||
}
|
||||
return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
const (
|
||||
scenarioMetadataKey = "chsettle_scenario"
|
||||
scenarioMetadataAliasKey = "scenario"
|
||||
)
|
||||
|
||||
type settlementScenario struct {
|
||||
Name string
|
||||
InitialStatus storagemodel.PaymentStatus
|
||||
FinalStatus storagemodel.PaymentStatus
|
||||
FinalDelay time.Duration
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
type settlementScenarioTrace struct {
|
||||
Source string
|
||||
OverrideRaw string
|
||||
OverrideNormalized string
|
||||
AmountRaw string
|
||||
AmountCurrency string
|
||||
BucketSlot int
|
||||
}
|
||||
|
||||
var scenarioFastSuccess = settlementScenario{
|
||||
Name: "fast_success",
|
||||
InitialStatus: storagemodel.PaymentStatusSuccess,
|
||||
}
|
||||
|
||||
var scenarioSlowSuccess = settlementScenario{
|
||||
Name: "slow_success",
|
||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
||||
FinalStatus: storagemodel.PaymentStatusSuccess,
|
||||
FinalDelay: 30 * time.Second,
|
||||
}
|
||||
|
||||
var scenarioFailImmediate = settlementScenario{
|
||||
Name: "fail_immediate",
|
||||
InitialStatus: storagemodel.PaymentStatusFailed,
|
||||
FailureReason: "simulated_fail_immediate",
|
||||
}
|
||||
|
||||
var scenarioFailTimeout = settlementScenario{
|
||||
Name: "fail_timeout",
|
||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
||||
FinalStatus: storagemodel.PaymentStatusFailed,
|
||||
FinalDelay: 45 * time.Second,
|
||||
FailureReason: "simulated_fail_timeout",
|
||||
}
|
||||
|
||||
var scenarioStuckPending = settlementScenario{
|
||||
Name: "stuck_pending",
|
||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
||||
}
|
||||
|
||||
var scenarioRetryThenSuccess = settlementScenario{
|
||||
Name: "retry_then_success",
|
||||
InitialStatus: storagemodel.PaymentStatusProcessing,
|
||||
FinalStatus: storagemodel.PaymentStatusSuccess,
|
||||
FinalDelay: 25 * time.Second,
|
||||
}
|
||||
|
||||
var scenarioWebhookDelayedSuccess = settlementScenario{
|
||||
Name: "webhook_delayed_success",
|
||||
InitialStatus: storagemodel.PaymentStatusWaiting,
|
||||
FinalStatus: storagemodel.PaymentStatusSuccess,
|
||||
FinalDelay: 60 * time.Second,
|
||||
}
|
||||
|
||||
var scenarioSlowThenFail = settlementScenario{
|
||||
Name: "slow_then_fail",
|
||||
InitialStatus: storagemodel.PaymentStatusProcessing,
|
||||
FinalStatus: storagemodel.PaymentStatusFailed,
|
||||
FinalDelay: 75 * time.Second,
|
||||
FailureReason: "simulated_slow_then_fail",
|
||||
}
|
||||
|
||||
var scenarioPartialProgressStuck = settlementScenario{
|
||||
Name: "partial_progress_stuck",
|
||||
InitialStatus: storagemodel.PaymentStatusProcessing,
|
||||
}
|
||||
|
||||
func resolveSettlementScenario(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) settlementScenario {
|
||||
scenario, _ := resolveSettlementScenarioWithTrace(idempotencyKey, amount, metadata)
|
||||
return scenario
|
||||
}
|
||||
|
||||
func resolveSettlementScenarioWithTrace(idempotencyKey string, amount *paymenttypes.Money, metadata map[string]string) (settlementScenario, settlementScenarioTrace) {
|
||||
trace := settlementScenarioTrace{
|
||||
BucketSlot: -1,
|
||||
}
|
||||
if amount != nil {
|
||||
trace.AmountRaw = strings.TrimSpace(amount.Amount)
|
||||
trace.AmountCurrency = strings.TrimSpace(amount.Currency)
|
||||
}
|
||||
overrideScenario, overrideRaw, overrideNormalized, overrideApplied := parseScenarioOverride(metadata)
|
||||
if overrideRaw != "" {
|
||||
trace.OverrideRaw = overrideRaw
|
||||
trace.OverrideNormalized = overrideNormalized
|
||||
}
|
||||
if overrideApplied {
|
||||
trace.Source = "explicit_override"
|
||||
return overrideScenario, trace
|
||||
}
|
||||
slot, ok := amountModuloSlot(amount)
|
||||
if ok {
|
||||
if trace.OverrideRaw != "" {
|
||||
trace.Source = "invalid_override_amount_bucket"
|
||||
} else {
|
||||
trace.Source = "amount_bucket"
|
||||
}
|
||||
trace.BucketSlot = slot
|
||||
return scenarioBySlot(slot, idempotencyKey), trace
|
||||
}
|
||||
slot = hashModulo(idempotencyKey, 1000)
|
||||
if trace.OverrideRaw != "" {
|
||||
trace.Source = "invalid_override_idempotency_hash_bucket"
|
||||
} else {
|
||||
trace.Source = "idempotency_hash_bucket"
|
||||
}
|
||||
trace.BucketSlot = slot
|
||||
return scenarioBySlot(slot, idempotencyKey), trace
|
||||
}
|
||||
|
||||
func parseScenarioOverride(metadata map[string]string) (settlementScenario, string, string, bool) {
|
||||
if len(metadata) == 0 {
|
||||
return settlementScenario{}, "", "", false
|
||||
}
|
||||
overrideRaw := strings.TrimSpace(metadata[scenarioMetadataKey])
|
||||
if overrideRaw == "" {
|
||||
overrideRaw = strings.TrimSpace(metadata[scenarioMetadataAliasKey])
|
||||
}
|
||||
if overrideRaw == "" {
|
||||
return settlementScenario{}, "", "", false
|
||||
}
|
||||
scenario, normalized, ok := scenarioByName(overrideRaw)
|
||||
return scenario, overrideRaw, normalized, ok
|
||||
}
|
||||
|
||||
func scenarioByName(value string) (settlementScenario, string, bool) {
|
||||
key := normalizeScenarioName(value)
|
||||
switch key {
|
||||
case "fast_success", "success_fast", "instant_success":
|
||||
return scenarioFastSuccess, key, true
|
||||
case "slow_success", "success_slow":
|
||||
return scenarioSlowSuccess, key, true
|
||||
case "fail_immediate", "immediate_fail", "failed":
|
||||
return scenarioFailImmediate, key, true
|
||||
case "fail_timeout", "timeout_fail":
|
||||
return scenarioFailTimeout, key, true
|
||||
case "stuck", "stuck_pending", "pending_stuck":
|
||||
return scenarioStuckPending, key, true
|
||||
case "retry_then_success":
|
||||
return scenarioRetryThenSuccess, key, true
|
||||
case "webhook_delayed_success":
|
||||
return scenarioWebhookDelayedSuccess, key, true
|
||||
case "slow_then_fail":
|
||||
return scenarioSlowThenFail, key, true
|
||||
case "partial_progress_stuck":
|
||||
return scenarioPartialProgressStuck, key, true
|
||||
case "chaos", "chaos_random_seeded":
|
||||
return scenarioBySlot(950, ""), key, true
|
||||
default:
|
||||
return settlementScenario{}, key, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeScenarioName(value string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(value))
|
||||
key = strings.ReplaceAll(key, "-", "_")
|
||||
return key
|
||||
}
|
||||
|
||||
func scenarioBySlot(slot int, seed string) settlementScenario {
|
||||
switch {
|
||||
case slot < 100:
|
||||
return scenarioFastSuccess
|
||||
case slot < 200:
|
||||
return scenarioSlowSuccess
|
||||
case slot < 300:
|
||||
return scenarioFailImmediate
|
||||
case slot < 400:
|
||||
return scenarioFailTimeout
|
||||
case slot < 500:
|
||||
return scenarioStuckPending
|
||||
case slot < 600:
|
||||
return scenarioRetryThenSuccess
|
||||
case slot < 700:
|
||||
return scenarioWebhookDelayedSuccess
|
||||
case slot < 800:
|
||||
return scenarioSlowThenFail
|
||||
case slot < 900:
|
||||
return scenarioPartialProgressStuck
|
||||
default:
|
||||
return chaosScenario(seed)
|
||||
}
|
||||
}
|
||||
|
||||
func chaosScenario(seed string) settlementScenario {
|
||||
choices := []settlementScenario{
|
||||
scenarioFastSuccess,
|
||||
scenarioSlowSuccess,
|
||||
scenarioFailImmediate,
|
||||
scenarioFailTimeout,
|
||||
scenarioStuckPending,
|
||||
scenarioSlowThenFail,
|
||||
}
|
||||
idx := hashModulo(seed, len(choices))
|
||||
return choices[idx]
|
||||
}
|
||||
|
||||
func amountModuloSlot(amount *paymenttypes.Money) (int, bool) {
|
||||
if amount == nil {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimSpace(amount.Amount)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
sign := 1
|
||||
if strings.HasPrefix(raw, "+") {
|
||||
raw = strings.TrimPrefix(raw, "+")
|
||||
}
|
||||
if strings.HasPrefix(raw, "-") {
|
||||
sign = -1
|
||||
raw = strings.TrimPrefix(raw, "-")
|
||||
}
|
||||
parts := strings.SplitN(raw, ".", 3)
|
||||
if len(parts) == 0 || len(parts) > 2 {
|
||||
return 0, false
|
||||
}
|
||||
whole := parts[0]
|
||||
if whole == "" || !digitsOnly(whole) {
|
||||
return 0, false
|
||||
}
|
||||
frac := "00"
|
||||
if len(parts) == 2 {
|
||||
f := parts[1]
|
||||
if f == "" || !digitsOnly(f) {
|
||||
return 0, false
|
||||
}
|
||||
if len(f) >= 2 {
|
||||
frac = f[:2]
|
||||
} else {
|
||||
frac = f + "0"
|
||||
}
|
||||
}
|
||||
wholeMod := digitsMod(whole, 10)
|
||||
fracVal, _ := strconv.Atoi(frac)
|
||||
slot := (wholeMod*100 + fracVal) % 1000
|
||||
if sign < 0 {
|
||||
slot = (-slot + 1000) % 1000
|
||||
}
|
||||
return slot, true
|
||||
}
|
||||
|
||||
func digitsOnly(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] < '0' || value[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func digitsMod(value string, mod int) int {
|
||||
if mod <= 0 {
|
||||
return 0
|
||||
}
|
||||
result := 0
|
||||
for i := 0; i < len(value); i++ {
|
||||
digit := int(value[i] - '0')
|
||||
result = (result*10 + digit) % mod
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hashModulo(input string, mod int) int {
|
||||
if mod <= 0 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(strings.TrimSpace(input)))
|
||||
return int(h.Sum32() % uint32(mod))
|
||||
}
|
||||
|
||||
func (s settlementScenario) delayedTransitionEnabled() bool {
|
||||
return s.FinalStatus != "" && s.FinalDelay > 0
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
)
|
||||
|
||||
func TestResolveSettlementScenario_AmountBuckets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount string
|
||||
want string
|
||||
}{
|
||||
{name: "bucket_000_fast_success", amount: "10.00", want: scenarioFastSuccess.Name},
|
||||
{name: "bucket_100_slow_success", amount: "11.00", want: scenarioSlowSuccess.Name},
|
||||
{name: "bucket_200_fail_immediate", amount: "12.00", want: scenarioFailImmediate.Name},
|
||||
{name: "bucket_300_fail_timeout", amount: "13.00", want: scenarioFailTimeout.Name},
|
||||
{name: "bucket_400_stuck_pending", amount: "14.00", want: scenarioStuckPending.Name},
|
||||
{name: "bucket_500_retry_then_success", amount: "15.00", want: scenarioRetryThenSuccess.Name},
|
||||
{name: "bucket_600_webhook_delayed_success", amount: "16.00", want: scenarioWebhookDelayedSuccess.Name},
|
||||
{name: "bucket_700_slow_then_fail", amount: "17.00", want: scenarioSlowThenFail.Name},
|
||||
{name: "bucket_800_partial_progress_stuck", amount: "18.00", want: scenarioPartialProgressStuck.Name},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := resolveSettlementScenario("idem-"+tc.name, &paymenttypes.Money{Amount: tc.amount, Currency: "USD"}, nil)
|
||||
if got.Name != tc.want {
|
||||
t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSettlementScenario_ExplicitOverride(t *testing.T) {
|
||||
got := resolveSettlementScenario("idem-override", &paymenttypes.Money{Amount: "10.00", Currency: "USD"}, map[string]string{
|
||||
scenarioMetadataKey: "stuck",
|
||||
})
|
||||
if got.Name != scenarioStuckPending.Name {
|
||||
t.Fatalf("scenario mismatch: got=%q want=%q", got.Name, scenarioStuckPending.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitTransfer_UsesImmediateFailureScenario(t *testing.T) {
|
||||
svc, repo, _ := newTestService(t)
|
||||
|
||||
resp, err := svc.SubmitTransfer(context.Background(), &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: "idem-immediate-fail",
|
||||
IntentRef: "intent-immediate-fail",
|
||||
OperationRef: "op-immediate-fail",
|
||||
PaymentRef: "payment-immediate-fail",
|
||||
Amount: &moneyv1.Money{Amount: "9.99", Currency: "USD"},
|
||||
Metadata: map[string]string{
|
||||
scenarioMetadataAliasKey: "fail_immediate",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("submit transfer failed: %v", err)
|
||||
}
|
||||
if got, want := resp.GetTransfer().GetStatus(), chainv1.TransferStatus_TRANSFER_FAILED; got != want {
|
||||
t.Fatalf("transfer status mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
|
||||
record := repo.payments.records["idem-immediate-fail"]
|
||||
if record == nil {
|
||||
t.Fatalf("expected payment record")
|
||||
}
|
||||
if got, want := record.Status, storagemodel.PaymentStatusFailed; got != want {
|
||||
t.Fatalf("record status mismatch: got=%s want=%s", got, want)
|
||||
}
|
||||
if got, want := record.Scenario, scenarioFailImmediate.Name; got != want {
|
||||
t.Fatalf("record scenario mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyScenarioTransition_SetsFinalSuccess(t *testing.T) {
|
||||
svc, repo, _ := newTestService(t)
|
||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-transition-success",
|
||||
IntentRef: "intent-transition-success",
|
||||
OperationRef: "op-transition-success",
|
||||
PaymentRef: "payment-transition-success",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USD"},
|
||||
Status: storagemodel.PaymentStatusWaiting,
|
||||
Scenario: scenarioSlowSuccess.Name,
|
||||
})
|
||||
|
||||
svc.applyScenarioTransition("idem-transition-success", scenarioSlowSuccess)
|
||||
|
||||
record := repo.payments.records["idem-transition-success"]
|
||||
if record == nil {
|
||||
t.Fatalf("expected payment record")
|
||||
}
|
||||
if got, want := record.Status, storagemodel.PaymentStatusSuccess; got != want {
|
||||
t.Fatalf("record status mismatch: got=%s want=%s", got, want)
|
||||
}
|
||||
if record.ExecutedMoney == nil {
|
||||
t.Fatalf("expected executed money")
|
||||
}
|
||||
}
|
||||
1040
api/gateway/chsettle/internal/service/gateway/service.go
Normal file
1040
api/gateway/chsettle/internal/service/gateway/service.go
Normal file
File diff suppressed because it is too large
Load Diff
417
api/gateway/chsettle/internal/service/gateway/service_test.go
Normal file
417
api/gateway/chsettle/internal/service/gateway/service_test.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
envelope "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
)
|
||||
|
||||
//
|
||||
// FAKE STORES
|
||||
//
|
||||
|
||||
type fakePaymentsStore struct {
|
||||
mu sync.Mutex
|
||||
records map[string]*storagemodel.PaymentRecord
|
||||
}
|
||||
|
||||
func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.records == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return f.records[key], nil
|
||||
}
|
||||
|
||||
func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.records == nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, record := range f.records {
|
||||
if record != nil && record.OperationRef == key {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.records == nil {
|
||||
f.records = map[string]*storagemodel.PaymentRecord{}
|
||||
}
|
||||
f.records[record.IdempotencyKey] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeTelegramStore struct {
|
||||
mu sync.Mutex
|
||||
records map[string]*storagemodel.TelegramConfirmation
|
||||
}
|
||||
|
||||
func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.records == nil {
|
||||
f.records = map[string]*storagemodel.TelegramConfirmation{}
|
||||
}
|
||||
f.records[record.RequestID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
payments *fakePaymentsStore
|
||||
tg *fakeTelegramStore
|
||||
pending *fakePendingStore
|
||||
treasury storage.TreasuryRequestsStore
|
||||
users storage.TreasuryTelegramUsersStore
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
||||
return f.payments
|
||||
}
|
||||
|
||||
func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
|
||||
return f.tg
|
||||
}
|
||||
|
||||
func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
|
||||
return f.pending
|
||||
}
|
||||
|
||||
func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore {
|
||||
return f.treasury
|
||||
}
|
||||
|
||||
func (f *fakeRepo) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
|
||||
return f.users
|
||||
}
|
||||
|
||||
type fakePendingStore struct {
|
||||
mu sync.Mutex
|
||||
records map[string]*storagemodel.PendingConfirmation
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) Upsert(_ context.Context, record *storagemodel.PendingConfirmation) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.records == nil {
|
||||
f.records = map[string]*storagemodel.PendingConfirmation{}
|
||||
}
|
||||
cp := *record
|
||||
f.records[record.RequestID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) FindByRequestID(_ context.Context, requestID string) (*storagemodel.PendingConfirmation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.records == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return f.records[requestID], nil
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) FindByMessageID(_ context.Context, messageID string) (*storagemodel.PendingConfirmation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, record := range f.records {
|
||||
if record != nil && record.MessageID == messageID {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) MarkClarified(_ context.Context, requestID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if record := f.records[requestID]; record != nil {
|
||||
record.Clarified = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) AttachMessage(_ context.Context, requestID string, messageID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if record := f.records[requestID]; record != nil {
|
||||
if record.MessageID == "" {
|
||||
record.MessageID = messageID
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.records, requestID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]storagemodel.PendingConfirmation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
result := make([]storagemodel.PendingConfirmation, 0)
|
||||
for _, record := range f.records {
|
||||
if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
result = append(result, *record)
|
||||
if int64(len(result)) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
//
|
||||
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
|
||||
//
|
||||
|
||||
type fakeBroker struct{}
|
||||
|
||||
func (f *fakeBroker) Publish(_ envelope.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBroker) Subscribe(event model.NotificationEvent) (<-chan envelope.Envelope, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeBroker) Unsubscribe(event model.NotificationEvent, subChan <-chan envelope.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//
|
||||
// CAPTURE ONLY TELEGRAM REACTIONS
|
||||
//
|
||||
|
||||
type captureProducer struct {
|
||||
mu sync.Mutex
|
||||
reactions []envelope.Envelope
|
||||
sig string
|
||||
}
|
||||
|
||||
func (c *captureProducer) SendMessage(env envelope.Envelope) error {
|
||||
if env.GetSignature().ToString() != c.sig {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.reactions = append(c.reactions, env)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
//
|
||||
// TESTS
|
||||
//
|
||||
|
||||
func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
|
||||
repo := &fakeRepo{
|
||||
payments: &fakePaymentsStore{},
|
||||
tg: &fakeTelegramStore{},
|
||||
pending: &fakePendingStore{},
|
||||
}
|
||||
|
||||
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
|
||||
RequestID: "x",
|
||||
ChatID: "1",
|
||||
MessageID: "2",
|
||||
Emoji: "ok",
|
||||
})
|
||||
|
||||
prod := &captureProducer{
|
||||
sig: sigEnv.GetSignature().ToString(),
|
||||
}
|
||||
|
||||
svc := NewService(logger, repo, prod, &fakeBroker{}, Config{
|
||||
Rail: "card",
|
||||
SuccessReaction: "👍",
|
||||
})
|
||||
|
||||
return svc, repo, prod
|
||||
}
|
||||
|
||||
func TestConfirmed(t *testing.T) {
|
||||
svc, repo, prod := newTestService(t)
|
||||
|
||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
PaymentIntentID: "pi-1",
|
||||
QuoteRef: "quote-1",
|
||||
OutgoingLeg: "card",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
||||
Status: storagemodel.PaymentStatusWaiting,
|
||||
})
|
||||
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: "idem-1",
|
||||
Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
||||
Status: model.ConfirmationStatusConfirmed,
|
||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
||||
}
|
||||
|
||||
_ = svc.onConfirmationResult(context.Background(), result)
|
||||
|
||||
rec := repo.payments.records["idem-1"]
|
||||
|
||||
if rec.Status != storagemodel.PaymentStatusSuccess {
|
||||
t.Fatalf("expected success, got %s", rec.Status)
|
||||
}
|
||||
if rec.RequestedMoney == nil {
|
||||
t.Fatalf("requested money not set")
|
||||
}
|
||||
if rec.ExecutedAt.IsZero() {
|
||||
t.Fatalf("executedAt not set")
|
||||
}
|
||||
if repo.tg.records["idem-1"] == nil {
|
||||
t.Fatalf("telegram confirmation not stored")
|
||||
}
|
||||
if len(prod.reactions) != 1 {
|
||||
t.Fatalf("reaction must be published")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClarified(t *testing.T) {
|
||||
svc, repo, prod := newTestService(t)
|
||||
|
||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-2",
|
||||
Status: storagemodel.PaymentStatusWaiting,
|
||||
})
|
||||
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: "idem-2",
|
||||
Status: model.ConfirmationStatusClarified,
|
||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
||||
}
|
||||
|
||||
_ = svc.onConfirmationResult(context.Background(), result)
|
||||
|
||||
rec := repo.payments.records["idem-2"]
|
||||
|
||||
if rec.Status != storagemodel.PaymentStatusWaiting {
|
||||
t.Fatalf("clarified must not change status")
|
||||
}
|
||||
if repo.tg.records["idem-2"] == nil {
|
||||
t.Fatalf("telegram confirmation must be stored")
|
||||
}
|
||||
if len(prod.reactions) != 0 {
|
||||
t.Fatalf("clarified must not publish reaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejected(t *testing.T) {
|
||||
svc, repo, prod := newTestService(t)
|
||||
|
||||
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
|
||||
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
|
||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-3",
|
||||
PaymentIntentID: "pi-3",
|
||||
QuoteRef: "quote-3",
|
||||
OutgoingLeg: "card",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
||||
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
|
||||
Status: storagemodel.PaymentStatusWaiting,
|
||||
})
|
||||
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: "idem-3",
|
||||
Status: model.ConfirmationStatusRejected,
|
||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
||||
}
|
||||
|
||||
_ = svc.onConfirmationResult(context.Background(), result)
|
||||
|
||||
rec := repo.payments.records["idem-3"]
|
||||
|
||||
if rec.Status != storagemodel.PaymentStatusFailed {
|
||||
t.Fatalf("expected failed")
|
||||
}
|
||||
if repo.tg.records["idem-3"] == nil {
|
||||
t.Fatalf("telegram confirmation must be stored")
|
||||
}
|
||||
if len(prod.reactions) != 0 {
|
||||
t.Fatalf("rejected must not publish reaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
svc, repo, prod := newTestService(t)
|
||||
|
||||
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
|
||||
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
|
||||
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-4",
|
||||
PaymentIntentID: "pi-4",
|
||||
QuoteRef: "quote-4",
|
||||
OutgoingLeg: "card",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
|
||||
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
|
||||
Status: storagemodel.PaymentStatusWaiting,
|
||||
})
|
||||
|
||||
result := &model.ConfirmationResult{
|
||||
RequestID: "idem-4",
|
||||
Status: model.ConfirmationStatusTimeout,
|
||||
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
|
||||
}
|
||||
|
||||
_ = svc.onConfirmationResult(context.Background(), result)
|
||||
|
||||
rec := repo.payments.records["idem-4"]
|
||||
|
||||
if rec.Status != storagemodel.PaymentStatusFailed {
|
||||
t.Fatalf("timeout must be failed")
|
||||
}
|
||||
if repo.tg.records["idem-4"] == nil {
|
||||
t.Fatalf("telegram confirmation must be stored")
|
||||
}
|
||||
if len(prod.reactions) != 0 {
|
||||
t.Fatalf("timeout must not publish reaction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentFromSubmitTransfer_NormalizesOutgoingLeg(t *testing.T) {
|
||||
intent, err := intentFromSubmitTransfer(&chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: "idem-5",
|
||||
IntentRef: "pi-5",
|
||||
OperationRef: "op-5",
|
||||
PaymentRef: "pay-5",
|
||||
Amount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
Metadata: map[string]string{
|
||||
metadataOutgoingLeg: "card",
|
||||
},
|
||||
}, "provider_settlement", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := intent.OutgoingLeg, discovery.RailCardPayout; got != want {
|
||||
t.Fatalf("unexpected outgoing leg: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func isFinalStatus(t *model.PaymentRecord) bool {
|
||||
switch t.Status {
|
||||
case model.PaymentStatusFailed, model.PaymentStatusSuccess, model.PaymentStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func toOpStatus(t *model.PaymentRecord) (rail.OperationResult, error) {
|
||||
switch t.Status {
|
||||
case model.PaymentStatusFailed:
|
||||
return rail.OperationResultFailed, nil
|
||||
case model.PaymentStatusSuccess:
|
||||
return rail.OperationResultSuccess, nil
|
||||
case model.PaymentStatusCancelled:
|
||||
return rail.OperationResultCancelled, nil
|
||||
default:
|
||||
return rail.OperationResultFailed, merrors.InvalidArgument("unexpected transfer status", "payment.status")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error {
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("payment record is required", "record")
|
||||
}
|
||||
s.logger.Debug("Persisting transfer status",
|
||||
zap.String("idempotency_key", record.IdempotencyKey),
|
||||
zap.String("payment_ref", record.PaymentIntentID),
|
||||
zap.String("status", string(record.Status)),
|
||||
zap.Bool("is_final", isFinalStatus(record)))
|
||||
if !isFinalStatus(record) {
|
||||
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
|
||||
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.logger.Debug("Transfer status persisted (non-final)",
|
||||
zap.String("idempotency_key", record.IdempotencyKey),
|
||||
zap.String("status", string(record.Status)))
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
|
||||
if upsertErr := s.repo.Payments().Upsert(txCtx, record); upsertErr != nil {
|
||||
return nil, upsertErr
|
||||
}
|
||||
if isFinalStatus(record) {
|
||||
if emitErr := s.emitTransferStatusEvent(txCtx, record); emitErr != nil {
|
||||
return nil, emitErr
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.logger.Info("Transfer status persisted (final)",
|
||||
zap.String("idempotency_key", record.IdempotencyKey),
|
||||
zap.String("status", string(record.Status)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) emitTransferStatusEvent(ctx context.Context, record *model.PaymentRecord) error {
|
||||
if s == nil || record == nil {
|
||||
return nil
|
||||
}
|
||||
if s.producer == nil || s.outboxStore() == nil {
|
||||
return nil
|
||||
}
|
||||
status, err := toOpStatus(record)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID))
|
||||
return err
|
||||
}
|
||||
|
||||
exec := pmodel.PaymentGatewayExecution{
|
||||
PaymentIntentID: record.PaymentIntentID,
|
||||
IdempotencyKey: record.IdempotencyKey,
|
||||
ExecutedMoney: record.ExecutedMoney,
|
||||
PaymentRef: record.PaymentRef,
|
||||
Status: status,
|
||||
OperationRef: record.OperationRef,
|
||||
Error: record.FailureReason,
|
||||
TransferRef: record.ID.Hex(),
|
||||
}
|
||||
env := paymentgateway.PaymentGatewayExecution(mservice.ChSettle, &exec)
|
||||
if sendErr := s.sendWithOutbox(ctx, env); sendErr != nil {
|
||||
s.logger.Warn("Failed to publish transfer status event", zap.Error(sendErr), mzap.ObjRef("transfer_ref", record.ID))
|
||||
return sendErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package bot
|
||||
|
||||
import "strings"
|
||||
|
||||
type Command string
|
||||
|
||||
const (
|
||||
CommandStart Command = "start"
|
||||
CommandHelp Command = "help"
|
||||
CommandFund Command = "fund"
|
||||
CommandWithdraw Command = "withdraw"
|
||||
CommandConfirm Command = "confirm"
|
||||
CommandCancel Command = "cancel"
|
||||
)
|
||||
|
||||
var supportedCommands = []Command{
|
||||
CommandStart,
|
||||
CommandHelp,
|
||||
CommandFund,
|
||||
CommandWithdraw,
|
||||
CommandConfirm,
|
||||
CommandCancel,
|
||||
}
|
||||
|
||||
func (c Command) Slash() string {
|
||||
name := strings.TrimSpace(string(c))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return "/" + name
|
||||
}
|
||||
|
||||
func parseCommand(text string) Command {
|
||||
text = strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return ""
|
||||
}
|
||||
token := text
|
||||
if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimPrefix(token, "/")
|
||||
if idx := strings.Index(token, "@"); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
return Command(strings.ToLower(strings.TrimSpace(token)))
|
||||
}
|
||||
|
||||
func supportedCommandsMessage() string {
|
||||
lines := make([]string, 0, len(supportedCommands)+2)
|
||||
lines = append(lines, "*Supported Commands*")
|
||||
lines = append(lines, "")
|
||||
for _, cmd := range supportedCommands {
|
||||
lines = append(lines, markdownCommand(cmd))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func confirmationCommandsMessage() string {
|
||||
return strings.Join([]string{
|
||||
"*Confirm Operation*",
|
||||
"",
|
||||
"Use " + markdownCommand(CommandConfirm) + " to execute.",
|
||||
"Use " + markdownCommand(CommandCancel) + " to abort.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func helpMessage(accountCode string, currency string) string {
|
||||
accountCode = strings.TrimSpace(accountCode)
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if accountCode == "" {
|
||||
accountCode = "N/A"
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "N/A"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"*Treasury Bot Help*",
|
||||
"",
|
||||
"*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")",
|
||||
"",
|
||||
"*How to use*",
|
||||
"1. Start funding with " + markdownCommand(CommandFund) + " or withdrawal with " + markdownCommand(CommandWithdraw) + ".",
|
||||
"2. Enter amount as decimal with dot separator and no currency.",
|
||||
" Example: " + markdownCode("1250.75"),
|
||||
"3. Confirm with " + markdownCommand(CommandConfirm) + " or abort with " + markdownCommand(CommandCancel) + ".",
|
||||
"",
|
||||
"*Cooldown*",
|
||||
"After confirmation there is a cooldown window. You can cancel during it with " + markdownCommand(CommandCancel) + ".",
|
||||
"You will receive a follow-up message with execution success or failure.",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
)
|
||||
|
||||
type DialogState string
|
||||
|
||||
const (
|
||||
DialogStateWaitingAmount DialogState = "waiting_amount"
|
||||
DialogStateWaitingConfirmation DialogState = "waiting_confirmation"
|
||||
)
|
||||
|
||||
type DialogSession struct {
|
||||
State DialogState
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
LedgerAccountID string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type Dialogs struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]DialogSession
|
||||
}
|
||||
|
||||
func NewDialogs() *Dialogs {
|
||||
return &Dialogs{
|
||||
sessions: map[string]DialogSession{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dialogs) Get(telegramUserID string) (DialogSession, bool) {
|
||||
if d == nil {
|
||||
return DialogSession{}, false
|
||||
}
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if telegramUserID == "" {
|
||||
return DialogSession{}, false
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
session, ok := d.sessions[telegramUserID]
|
||||
return session, ok
|
||||
}
|
||||
|
||||
func (d *Dialogs) Set(telegramUserID string, session DialogSession) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if telegramUserID == "" {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.sessions[telegramUserID] = session
|
||||
}
|
||||
|
||||
func (d *Dialogs) Clear(telegramUserID string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if telegramUserID == "" {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
delete(d.sessions, telegramUserID)
|
||||
}
|
||||
18
api/gateway/chsettle/internal/service/treasury/bot/markup.go
Normal file
18
api/gateway/chsettle/internal/service/treasury/bot/markup.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func markdownCode(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
value = "N/A"
|
||||
}
|
||||
value = strings.ReplaceAll(value, "`", "'")
|
||||
return "`" + value + "`"
|
||||
}
|
||||
|
||||
func markdownCommand(command Command) string {
|
||||
return command.Slash()
|
||||
}
|
||||
527
api/gateway/chsettle/internal/service/treasury/bot/router.go
Normal file
527
api/gateway/chsettle/internal/service/treasury/bot/router.go
Normal file
@@ -0,0 +1,527 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const unauthorizedMessage = "*Unauthorized*\nYour Telegram account is not allowed to perform treasury operations."
|
||||
const unauthorizedChatMessage = "*Unauthorized Chat*\nThis Telegram chat is not allowed to perform treasury operations."
|
||||
|
||||
const amountInputHint = "*Amount format*\nEnter amount as a decimal number using a dot separator and without currency.\nExample: `1250.75`"
|
||||
|
||||
type SendTextFunc func(ctx context.Context, chatID string, text string) error
|
||||
|
||||
type ScheduleTracker interface {
|
||||
TrackScheduled(record *storagemodel.TreasuryRequest)
|
||||
Untrack(requestID string)
|
||||
}
|
||||
|
||||
type AccountProfile struct {
|
||||
AccountID string
|
||||
AccountCode string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
LedgerAccountID string
|
||||
ChatID string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type TreasuryService interface {
|
||||
ExecutionDelay() time.Duration
|
||||
MaxPerOperationLimit() string
|
||||
|
||||
GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error)
|
||||
GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error)
|
||||
CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error)
|
||||
ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
||||
CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
||||
}
|
||||
|
||||
type UserBinding struct {
|
||||
TelegramUserID string
|
||||
LedgerAccountID string
|
||||
AllowedChatIDs []string
|
||||
}
|
||||
|
||||
type UserBindingResolver interface {
|
||||
ResolveUserBinding(ctx context.Context, telegramUserID string) (*UserBinding, error)
|
||||
}
|
||||
|
||||
type limitError interface {
|
||||
error
|
||||
LimitKind() string
|
||||
LimitMax() string
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
logger mlogger.Logger
|
||||
|
||||
service TreasuryService
|
||||
dialogs *Dialogs
|
||||
send SendTextFunc
|
||||
tracker ScheduleTracker
|
||||
|
||||
users UserBindingResolver
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
logger mlogger.Logger,
|
||||
service TreasuryService,
|
||||
send SendTextFunc,
|
||||
tracker ScheduleTracker,
|
||||
users UserBindingResolver,
|
||||
) *Router {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury_router")
|
||||
}
|
||||
return &Router{
|
||||
logger: logger,
|
||||
service: service,
|
||||
dialogs: NewDialogs(),
|
||||
send: send,
|
||||
tracker: tracker,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Enabled() bool {
|
||||
return r != nil && r.service != nil && r.users != nil
|
||||
}
|
||||
|
||||
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
if !r.Enabled() || update == nil || update.Message == nil {
|
||||
return false
|
||||
}
|
||||
message := update.Message
|
||||
chatID := strings.TrimSpace(message.ChatID)
|
||||
userID := strings.TrimSpace(message.FromUserID)
|
||||
text := strings.TrimSpace(message.Text)
|
||||
|
||||
if chatID == "" || userID == "" {
|
||||
return false
|
||||
}
|
||||
command := parseCommand(text)
|
||||
if r.logger != nil {
|
||||
r.logger.Debug("Telegram treasury update received",
|
||||
zap.Int64("update_id", update.UpdateID),
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("telegram_user_id", userID),
|
||||
zap.String("command", strings.TrimSpace(string(command))),
|
||||
zap.String("message_text", text),
|
||||
zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)),
|
||||
)
|
||||
}
|
||||
|
||||
binding, err := r.users.ResolveUserBinding(ctx, userID)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Failed to resolve treasury user binding",
|
||||
zap.Error(err),
|
||||
zap.String("telegram_user_id", userID),
|
||||
zap.String("chat_id", chatID))
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check treasury authorization right now. Please try again.")
|
||||
return true
|
||||
}
|
||||
if binding == nil || strings.TrimSpace(binding.LedgerAccountID) == "" {
|
||||
r.logUnauthorized(update)
|
||||
_ = r.sendText(ctx, chatID, unauthorizedMessage)
|
||||
return true
|
||||
}
|
||||
if !isChatAllowed(chatID, binding.AllowedChatIDs) {
|
||||
r.logUnauthorized(update)
|
||||
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
|
||||
return true
|
||||
}
|
||||
accountID := strings.TrimSpace(binding.LedgerAccountID)
|
||||
|
||||
switch command {
|
||||
case CommandStart:
|
||||
profile := r.resolveAccountProfile(ctx, accountID)
|
||||
_ = r.sendText(ctx, chatID, welcomeMessage(profile))
|
||||
return true
|
||||
case CommandHelp:
|
||||
profile := r.resolveAccountProfile(ctx, accountID)
|
||||
_ = r.sendText(ctx, chatID, helpMessage(displayAccountCode(profile, accountID), profile.Currency))
|
||||
return true
|
||||
case CommandFund:
|
||||
if r.logger != nil {
|
||||
r.logger.Info("Treasury funding dialog requested",
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("telegram_user_id", userID),
|
||||
zap.String("ledger_account_id", accountID))
|
||||
}
|
||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
|
||||
return true
|
||||
case CommandWithdraw:
|
||||
if r.logger != nil {
|
||||
r.logger.Info("Treasury withdrawal dialog requested",
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("telegram_user_id", userID),
|
||||
zap.String("ledger_account_id", accountID))
|
||||
}
|
||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
|
||||
return true
|
||||
case CommandConfirm:
|
||||
if r.logger != nil {
|
||||
r.logger.Info("Treasury confirmation requested",
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("telegram_user_id", userID),
|
||||
zap.String("ledger_account_id", accountID))
|
||||
}
|
||||
r.confirm(ctx, userID, accountID, chatID)
|
||||
return true
|
||||
case CommandCancel:
|
||||
if r.logger != nil {
|
||||
r.logger.Info("Treasury cancellation requested",
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("telegram_user_id", userID),
|
||||
zap.String("ledger_account_id", accountID))
|
||||
}
|
||||
r.cancel(ctx, userID, accountID, chatID)
|
||||
return true
|
||||
}
|
||||
|
||||
session, hasSession := r.dialogs.Get(userID)
|
||||
if hasSession {
|
||||
switch session.State {
|
||||
case DialogStateWaitingAmount:
|
||||
r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text)
|
||||
return true
|
||||
case DialogStateWaitingConfirmation:
|
||||
_ = r.sendText(ctx, chatID, confirmationCommandsMessage())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(text, "/") {
|
||||
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(message.ReplyToMessageID) != "" {
|
||||
return false
|
||||
}
|
||||
if text != "" {
|
||||
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID))
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check pending treasury operations right now. Please try again.")
|
||||
return
|
||||
}
|
||||
if active != nil {
|
||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(active))
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: active.RequestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingAmount,
|
||||
OperationType: operation,
|
||||
LedgerAccountID: accountID,
|
||||
})
|
||||
profile := r.resolveAccountProfile(ctx, accountID)
|
||||
_ = r.sendText(ctx, chatID, amountPromptMessage(operation, profile, accountID))
|
||||
}
|
||||
|
||||
func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) {
|
||||
record, err := r.service.CreateRequest(ctx, CreateRequestInput{
|
||||
OperationType: operation,
|
||||
TelegramUserID: userID,
|
||||
LedgerAccountID: accountID,
|
||||
ChatID: chatID,
|
||||
Amount: amount,
|
||||
})
|
||||
if err != nil {
|
||||
if record != nil {
|
||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(record))
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: record.RequestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
if typed, ok := err.(limitError); ok {
|
||||
switch typed.LimitKind() {
|
||||
case "per_operation":
|
||||
_ = r.sendText(ctx, chatID, "*Amount exceeds allowed limit*\n\n*Max per operation:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
||||
return
|
||||
case "daily":
|
||||
_ = r.sendText(ctx, chatID, "*Daily amount limit exceeded*\n\n*Max per day:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
||||
return
|
||||
}
|
||||
}
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
_ = r.sendText(ctx, chatID, "*Invalid amount*\n\n"+amountInputHint+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
||||
return
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
||||
return
|
||||
}
|
||||
if record == nil {
|
||||
_ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
|
||||
return
|
||||
}
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: record.RequestID,
|
||||
})
|
||||
_ = r.sendText(ctx, chatID, confirmationPrompt(record))
|
||||
}
|
||||
|
||||
func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
requestID := ""
|
||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
||||
requestID = strings.TrimSpace(session.RequestID)
|
||||
} else {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err == nil && active != nil {
|
||||
requestID = strings.TrimSpace(active.RequestID)
|
||||
}
|
||||
}
|
||||
if requestID == "" {
|
||||
_ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
|
||||
return
|
||||
}
|
||||
record, err := r.service.ConfirmRequest(ctx, requestID, userID)
|
||||
if err != nil {
|
||||
_ = r.sendText(ctx, chatID, "*Unable to confirm treasury request.*\n\nUse "+markdownCommand(CommandCancel)+" or create a new request with "+markdownCommand(CommandFund)+" or "+markdownCommand(CommandWithdraw)+".")
|
||||
return
|
||||
}
|
||||
if r.tracker != nil {
|
||||
r.tracker.TrackScheduled(record)
|
||||
}
|
||||
r.dialogs.Clear(userID)
|
||||
delay := int64(r.service.ExecutionDelay().Seconds())
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
_ = r.sendText(ctx, chatID,
|
||||
"*Operation confirmed*\n\n"+
|
||||
"*Execution:* scheduled in "+markdownCode(formatSeconds(delay))+".\n"+
|
||||
"You can cancel during this cooldown with "+markdownCommand(CommandCancel)+".\n\n"+
|
||||
"You will receive a follow-up message with execution success or failure.\n\n"+
|
||||
"*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID)))
|
||||
}
|
||||
|
||||
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
requestID := ""
|
||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
||||
requestID = strings.TrimSpace(session.RequestID)
|
||||
} else {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err == nil && active != nil {
|
||||
requestID = strings.TrimSpace(active.RequestID)
|
||||
}
|
||||
}
|
||||
if requestID == "" {
|
||||
r.dialogs.Clear(userID)
|
||||
_ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
|
||||
return
|
||||
}
|
||||
record, err := r.service.CancelRequest(ctx, requestID, userID)
|
||||
if err != nil {
|
||||
_ = r.sendText(ctx, chatID, "*Unable to cancel treasury request.*")
|
||||
return
|
||||
}
|
||||
if r.tracker != nil {
|
||||
r.tracker.Untrack(record.RequestID)
|
||||
}
|
||||
r.dialogs.Clear(userID)
|
||||
_ = r.sendText(ctx, chatID, "*Operation cancelled*\n\n*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID)))
|
||||
}
|
||||
|
||||
func (r *Router) sendText(ctx context.Context, chatID string, text string) error {
|
||||
if r == nil || r.send == nil {
|
||||
return nil
|
||||
}
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
text = strings.TrimSpace(text)
|
||||
if chatID == "" || text == "" {
|
||||
return nil
|
||||
}
|
||||
if err := r.send(ctx, chatID, text); err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Failed to send treasury bot response",
|
||||
zap.Error(err),
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("message_text", text))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) {
|
||||
if r == nil || r.logger == nil || update == nil || update.Message == nil {
|
||||
return
|
||||
}
|
||||
message := update.Message
|
||||
r.logger.Warn("unauthorized_access",
|
||||
zap.String("event", "unauthorized_access"),
|
||||
zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)),
|
||||
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
|
||||
zap.String("message_text", strings.TrimSpace(message.Text)),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
|
||||
if record == nil {
|
||||
return "*Pending treasury operation already exists.*\n\nUse " + markdownCommand(CommandCancel) + "."
|
||||
}
|
||||
return "*Pending Treasury Operation*\n\n" +
|
||||
"*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
|
||||
"*Request ID:* " + markdownCode(strings.TrimSpace(record.RequestID)) + "\n" +
|
||||
"*Status:* " + markdownCode(strings.TrimSpace(string(record.Status))) + "\n" +
|
||||
"*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
|
||||
"Wait for execution or cancel with " + markdownCommand(CommandCancel) + "."
|
||||
}
|
||||
|
||||
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
|
||||
if record == nil {
|
||||
return "*Request created.*\n\nUse " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + "."
|
||||
}
|
||||
title := "*Funding request created.*"
|
||||
if record.OperationType == storagemodel.TreasuryOperationWithdraw {
|
||||
title = "*Withdrawal request created.*"
|
||||
}
|
||||
return title + "\n\n" +
|
||||
"*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
|
||||
"*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
|
||||
confirmationCommandsMessage()
|
||||
}
|
||||
|
||||
func welcomeMessage(profile *AccountProfile) string {
|
||||
accountCode := displayAccountCode(profile, "")
|
||||
currency := ""
|
||||
if profile != nil {
|
||||
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
|
||||
}
|
||||
if accountCode == "" {
|
||||
accountCode = "N/A"
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "N/A"
|
||||
}
|
||||
return "*Sendico Treasury Bot*\n\n" +
|
||||
"*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n" +
|
||||
"Use " + markdownCommand(CommandFund) + " to credit your account and " + markdownCommand(CommandWithdraw) + " to debit it.\n" +
|
||||
"After entering an amount, use " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + ".\n" +
|
||||
"Use " + markdownCommand(CommandHelp) + " for detailed usage."
|
||||
}
|
||||
|
||||
func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string {
|
||||
title := "*Funding request*"
|
||||
if operation == storagemodel.TreasuryOperationWithdraw {
|
||||
title = "*Withdrawal request*"
|
||||
}
|
||||
accountCode := displayAccountCode(profile, fallbackAccountID)
|
||||
currency := ""
|
||||
if profile != nil {
|
||||
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
|
||||
}
|
||||
if accountCode == "" {
|
||||
accountCode = "N/A"
|
||||
}
|
||||
if currency == "" {
|
||||
currency = "N/A"
|
||||
}
|
||||
return title + "\n\n" +
|
||||
"*Account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n\n" +
|
||||
amountInputHint
|
||||
}
|
||||
|
||||
func requestAccountDisplay(record *storagemodel.TreasuryRequest) string {
|
||||
if record == nil {
|
||||
return ""
|
||||
}
|
||||
if code := strings.TrimSpace(record.LedgerAccountCode); code != "" {
|
||||
return code
|
||||
}
|
||||
return strings.TrimSpace(record.LedgerAccountID)
|
||||
}
|
||||
|
||||
func displayAccountCode(profile *AccountProfile, fallbackAccountID string) string {
|
||||
if profile != nil {
|
||||
if code := strings.TrimSpace(profile.AccountCode); code != "" {
|
||||
return code
|
||||
}
|
||||
if id := strings.TrimSpace(profile.AccountID); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fallbackAccountID)
|
||||
}
|
||||
|
||||
func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID string) *AccountProfile {
|
||||
if r == nil || r.service == nil {
|
||||
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
|
||||
}
|
||||
profile, err := r.service.GetAccountProfile(ctx, ledgerAccountID)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Failed to resolve treasury account profile",
|
||||
zap.Error(err),
|
||||
zap.String("ledger_account_id", strings.TrimSpace(ledgerAccountID)))
|
||||
}
|
||||
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
|
||||
}
|
||||
if profile == nil {
|
||||
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
|
||||
}
|
||||
if strings.TrimSpace(profile.AccountID) == "" {
|
||||
profile.AccountID = strings.TrimSpace(ledgerAccountID)
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func isChatAllowed(chatID string, allowedChatIDs []string) bool {
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
if chatID == "" {
|
||||
return false
|
||||
}
|
||||
if len(allowedChatIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range allowedChatIDs {
|
||||
if strings.TrimSpace(allowed) == chatID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatSeconds(value int64) string {
|
||||
if value == 1 {
|
||||
return "1 second"
|
||||
}
|
||||
return strconv.FormatInt(value, 10) + " seconds"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type fakeService struct{}
|
||||
|
||||
type fakeUserBindingResolver struct {
|
||||
bindings map[string]*UserBinding
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeUserBindingResolver) ResolveUserBinding(_ context.Context, telegramUserID string) (*UserBinding, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.bindings == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return f.bindings[telegramUserID], nil
|
||||
}
|
||||
|
||||
func (fakeService) ExecutionDelay() time.Duration {
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
func (fakeService) MaxPerOperationLimit() string {
|
||||
return "1000000"
|
||||
}
|
||||
|
||||
func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) GetAccountProfile(_ context.Context, ledgerAccountID string) (*AccountProfile, error) {
|
||||
return &AccountProfile{
|
||||
AccountID: ledgerAccountID,
|
||||
AccountCode: ledgerAccountID,
|
||||
Currency: "USD",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ConfirmRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CancelRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
AllowedChatIDs: []string{"100"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "100",
|
||||
FromUserID: "999",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterUnknownChatGetsDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
AllowedChatIDs: []string{"100"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "999",
|
||||
FromUserID: "123",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedChatMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "999",
|
||||
FromUserID: "123",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != amountPromptMessage(
|
||||
storagemodel.TreasuryOperationFund,
|
||||
&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"},
|
||||
"acct-1",
|
||||
) {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "999",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "123",
|
||||
Text: "/start",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != welcomeMessage(&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"}) {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterHelpAuthorizedShowsHelp(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "123",
|
||||
Text: "/help",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != helpMessage("acct-1", "USD") {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterStartUnauthorizedGetsDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "999",
|
||||
Text: "/start",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "123",
|
||||
Text: "hello",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != supportedCommandsMessage() {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
11
api/gateway/chsettle/internal/service/treasury/config.go
Normal file
11
api/gateway/chsettle/internal/service/treasury/config.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package treasury
|
||||
|
||||
import "time"
|
||||
|
||||
type Config struct {
|
||||
ExecutionDelay time.Duration
|
||||
PollInterval time.Duration
|
||||
|
||||
MaxAmountPerOperation string
|
||||
MaxDailyAmount string
|
||||
}
|
||||
312
api/gateway/chsettle/internal/service/treasury/ledger/client.go
Normal file
312
api/gateway/chsettle/internal/service/treasury/ledger/client.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const ledgerConnectorID = "ledger"
|
||||
|
||||
type Config struct {
|
||||
Endpoint string
|
||||
Timeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
AccountID string
|
||||
AccountCode string
|
||||
Currency string
|
||||
OrganizationRef string
|
||||
}
|
||||
|
||||
type Balance struct {
|
||||
AccountID string
|
||||
Amount string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type PostRequest struct {
|
||||
AccountID string
|
||||
OrganizationRef string
|
||||
Amount string
|
||||
Currency string
|
||||
Reference string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type OperationResult struct {
|
||||
Reference string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
GetAccount(ctx context.Context, accountID string) (*Account, error)
|
||||
GetBalance(ctx context.Context, accountID string) (*Balance, error)
|
||||
ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error)
|
||||
ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type grpcConnectorClient interface {
|
||||
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
|
||||
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
|
||||
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
|
||||
}
|
||||
|
||||
type connectorClient struct {
|
||||
cfg Config
|
||||
conn *grpc.ClientConn
|
||||
client grpcConnectorClient
|
||||
}
|
||||
|
||||
func New(cfg Config) (Client, error) {
|
||||
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
|
||||
if cfg.Endpoint == "" {
|
||||
return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint")
|
||||
}
|
||||
if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" {
|
||||
cfg.Endpoint = normalized
|
||||
if insecure {
|
||||
cfg.Insecure = true
|
||||
}
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
dialOpts := []grpc.DialOption{}
|
||||
if cfg.Insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint))
|
||||
}
|
||||
return &connectorClient{
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
client: connectorv1.NewConnectorServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) Close() error {
|
||||
if c == nil || c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
||||
}
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{
|
||||
AccountRef: &connectorv1.AccountRef{
|
||||
ConnectorId: ledgerConnectorID,
|
||||
AccountId: accountID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account := resp.GetAccount()
|
||||
if account == nil {
|
||||
return nil, merrors.NoData("ledger account not found")
|
||||
}
|
||||
accountCode := strings.TrimSpace(account.GetLabel())
|
||||
organizationRef := strings.TrimSpace(account.GetOwnerRef())
|
||||
if organizationRef == "" && account.GetProviderDetails() != nil {
|
||||
details := account.GetProviderDetails().AsMap()
|
||||
if organizationRef == "" {
|
||||
organizationRef = firstDetailValue(details, "organization_ref", "organizationRef", "org_ref")
|
||||
}
|
||||
if accountCode == "" {
|
||||
accountCode = firstDetailValue(details, "account_code", "accountCode", "code", "ledger_account_code")
|
||||
}
|
||||
}
|
||||
return &Account{
|
||||
AccountID: accountID,
|
||||
AccountCode: accountCode,
|
||||
Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())),
|
||||
OrganizationRef: organizationRef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
||||
}
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{
|
||||
AccountRef: &connectorv1.AccountRef{
|
||||
ConnectorId: ledgerConnectorID,
|
||||
AccountId: accountID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balance := resp.GetBalance()
|
||||
if balance == nil || balance.GetAvailable() == nil {
|
||||
return nil, merrors.Internal("ledger balance is unavailable")
|
||||
}
|
||||
return &Balance{
|
||||
AccountID: accountID,
|
||||
Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
||||
return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req)
|
||||
}
|
||||
|
||||
func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
||||
return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req)
|
||||
}
|
||||
|
||||
func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) {
|
||||
req.AccountID = strings.TrimSpace(req.AccountID)
|
||||
req.OrganizationRef = strings.TrimSpace(req.OrganizationRef)
|
||||
req.Amount = strings.TrimSpace(req.Amount)
|
||||
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
req.Reference = strings.TrimSpace(req.Reference)
|
||||
req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey)
|
||||
|
||||
if req.AccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
||||
}
|
||||
if req.OrganizationRef == "" {
|
||||
return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref")
|
||||
}
|
||||
if req.Amount == "" || req.Currency == "" {
|
||||
return nil, merrors.InvalidArgument("ledger amount is required", "amount")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key")
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"organization_ref": req.OrganizationRef,
|
||||
"operation": operation,
|
||||
"description": "chsettle treasury operation",
|
||||
"metadata": map[string]any{
|
||||
"reference": req.Reference,
|
||||
},
|
||||
}
|
||||
operationReq := &connectorv1.Operation{
|
||||
Type: opType,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Money: &moneyv1.Money{
|
||||
Amount: req.Amount,
|
||||
Currency: req.Currency,
|
||||
},
|
||||
Params: structFromMap(params),
|
||||
}
|
||||
account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID}
|
||||
switch opType {
|
||||
case connectorv1.OperationType_CREDIT:
|
||||
operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
|
||||
case connectorv1.OperationType_DEBIT:
|
||||
operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
|
||||
}
|
||||
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.GetReceipt() == nil {
|
||||
return nil, merrors.Internal("ledger receipt is unavailable")
|
||||
}
|
||||
if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil {
|
||||
message := strings.TrimSpace(receiptErr.GetMessage())
|
||||
if message == "" {
|
||||
message = "ledger operation failed"
|
||||
}
|
||||
return nil, merrors.InvalidArgument(message)
|
||||
}
|
||||
reference := strings.TrimSpace(resp.GetReceipt().GetOperationId())
|
||||
if reference == "" {
|
||||
reference = req.Reference
|
||||
}
|
||||
return &OperationResult{Reference: reference}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
}
|
||||
|
||||
func structFromMap(values map[string]any) *structpb.Struct {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := structpb.NewStruct(values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeEndpoint(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw, false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
||||
case "http", "grpc":
|
||||
return parsed.Host, true
|
||||
case "https", "grpcs":
|
||||
return parsed.Host, false
|
||||
default:
|
||||
return raw, false
|
||||
}
|
||||
}
|
||||
|
||||
func firstDetailValue(values map[string]any, keys ...string) string {
|
||||
if len(values) == 0 || len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if value, ok := values[key]; ok {
|
||||
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type DiscoveryConfig struct {
|
||||
Logger mlogger.Logger
|
||||
Registry *discovery.Registry
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type discoveryEndpoint struct {
|
||||
address string
|
||||
insecure bool
|
||||
raw string
|
||||
}
|
||||
|
||||
func (e discoveryEndpoint) key() string {
|
||||
return fmt.Sprintf("%s|%t", e.address, e.insecure)
|
||||
}
|
||||
|
||||
type discoveryClient struct {
|
||||
logger mlogger.Logger
|
||||
registry *discovery.Registry
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
client Client
|
||||
endpointKey string
|
||||
}
|
||||
|
||||
func NewDiscoveryClient(cfg DiscoveryConfig) (Client, error) {
|
||||
if cfg.Registry == nil {
|
||||
return nil, merrors.InvalidArgument("treasury ledger discovery registry is required", "registry")
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
logger := cfg.Logger
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury_ledger_discovery")
|
||||
}
|
||||
return &discoveryClient{
|
||||
logger: logger,
|
||||
registry: cfg.Registry,
|
||||
timeout: cfg.Timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *discoveryClient) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.client != nil {
|
||||
err := c.client.Close()
|
||||
c.client = nil
|
||||
c.endpointKey = ""
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *discoveryClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
|
||||
client, err := c.resolveClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.GetAccount(ctx, accountID)
|
||||
}
|
||||
|
||||
func (c *discoveryClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
|
||||
client, err := c.resolveClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.GetBalance(ctx, accountID)
|
||||
}
|
||||
|
||||
func (c *discoveryClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
||||
client, err := c.resolveClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ExternalCredit(ctx, req)
|
||||
}
|
||||
|
||||
func (c *discoveryClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
||||
client, err := c.resolveClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ExternalDebit(ctx, req)
|
||||
}
|
||||
|
||||
func (c *discoveryClient) resolveClient(_ context.Context) (Client, error) {
|
||||
if c == nil || c.registry == nil {
|
||||
return nil, merrors.Internal("treasury ledger discovery is unavailable")
|
||||
}
|
||||
endpoint, err := c.resolveEndpoint()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := endpoint.key()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client != nil && c.endpointKey == key {
|
||||
return c.client, nil
|
||||
}
|
||||
if c.client != nil {
|
||||
_ = c.client.Close()
|
||||
c.client = nil
|
||||
c.endpointKey = ""
|
||||
}
|
||||
next, err := New(Config{
|
||||
Endpoint: endpoint.address,
|
||||
Timeout: c.timeout,
|
||||
Insecure: endpoint.insecure,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.client = next
|
||||
c.endpointKey = key
|
||||
if c.logger != nil {
|
||||
c.logger.Info("Discovered ledger endpoint selected",
|
||||
zap.String("service", string(mservice.Ledger)),
|
||||
zap.String("invoke_uri", endpoint.raw),
|
||||
zap.String("address", endpoint.address),
|
||||
zap.Bool("insecure", endpoint.insecure))
|
||||
}
|
||||
return c.client, nil
|
||||
}
|
||||
|
||||
func (c *discoveryClient) resolveEndpoint() (discoveryEndpoint, error) {
|
||||
entries := c.registry.List(time.Now(), true)
|
||||
type match struct {
|
||||
entry discovery.RegistryEntry
|
||||
opMatch bool
|
||||
}
|
||||
matches := make([]match, 0, len(entries))
|
||||
requiredOps := discovery.LedgerServiceOperations()
|
||||
for _, entry := range entries {
|
||||
if !matchesService(entry.Service, mservice.Ledger) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, match{
|
||||
entry: entry,
|
||||
opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps),
|
||||
})
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return discoveryEndpoint{}, merrors.NoData("discovery: ledger service unavailable")
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].opMatch != matches[j].opMatch {
|
||||
return matches[i].opMatch
|
||||
}
|
||||
if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority {
|
||||
return matches[i].entry.RoutingPriority > matches[j].entry.RoutingPriority
|
||||
}
|
||||
if matches[i].entry.ID != matches[j].entry.ID {
|
||||
return matches[i].entry.ID < matches[j].entry.ID
|
||||
}
|
||||
return matches[i].entry.InstanceID < matches[j].entry.InstanceID
|
||||
})
|
||||
return parseDiscoveryEndpoint(matches[0].entry.InvokeURI)
|
||||
}
|
||||
|
||||
func matchesService(service string, candidate mservice.Type) bool {
|
||||
service = strings.TrimSpace(service)
|
||||
if service == "" || strings.TrimSpace(string(candidate)) == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(service, strings.TrimSpace(string(candidate)))
|
||||
}
|
||||
|
||||
func parseDiscoveryEndpoint(raw string) (discoveryEndpoint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri is required")
|
||||
}
|
||||
|
||||
if !strings.Contains(raw, "://") {
|
||||
if _, _, splitErr := net.SplitHostPort(raw); splitErr != nil {
|
||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
||||
}
|
||||
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" {
|
||||
if err != nil {
|
||||
return discoveryEndpoint{}, err
|
||||
}
|
||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
||||
switch scheme {
|
||||
case "grpc":
|
||||
address := strings.TrimSpace(parsed.Host)
|
||||
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
|
||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
||||
}
|
||||
return discoveryEndpoint{address: address, insecure: true, raw: raw}, nil
|
||||
case "grpcs":
|
||||
address := strings.TrimSpace(parsed.Host)
|
||||
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
|
||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
|
||||
}
|
||||
return discoveryEndpoint{address: address, insecure: false, raw: raw}, nil
|
||||
case "dns", "passthrough":
|
||||
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
|
||||
default:
|
||||
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: unsupported invoke uri scheme")
|
||||
}
|
||||
}
|
||||
205
api/gateway/chsettle/internal/service/treasury/module.go
Normal file
205
api/gateway/chsettle/internal/service/treasury/module.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/bot"
|
||||
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger"
|
||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
logger mlogger.Logger
|
||||
|
||||
service *Service
|
||||
router *bot.Router
|
||||
scheduler *Scheduler
|
||||
ledger ledger.Client
|
||||
}
|
||||
|
||||
func NewModule(
|
||||
logger mlogger.Logger,
|
||||
repo storage.TreasuryRequestsStore,
|
||||
users storage.TreasuryTelegramUsersStore,
|
||||
ledgerClient ledger.Client,
|
||||
cfg Config,
|
||||
send bot.SendTextFunc,
|
||||
) (*Module, error) {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury")
|
||||
}
|
||||
if users == nil {
|
||||
return nil, merrors.InvalidArgument("treasury telegram users store is required", "users")
|
||||
}
|
||||
service, err := NewService(
|
||||
logger,
|
||||
repo,
|
||||
ledgerClient,
|
||||
cfg.ExecutionDelay,
|
||||
cfg.MaxAmountPerOperation,
|
||||
cfg.MaxDailyAmount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
module := &Module{
|
||||
logger: logger,
|
||||
service: service,
|
||||
ledger: ledgerClient,
|
||||
}
|
||||
module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval)
|
||||
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, &botUsersAdapter{store: users})
|
||||
return module, nil
|
||||
}
|
||||
|
||||
func (m *Module) Enabled() bool {
|
||||
return m != nil && m.router != nil && m.router.Enabled() && m.scheduler != nil
|
||||
}
|
||||
|
||||
func (m *Module) Start() {
|
||||
if m == nil || m.scheduler == nil {
|
||||
return
|
||||
}
|
||||
m.scheduler.Start()
|
||||
}
|
||||
|
||||
func (m *Module) Shutdown() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if m.scheduler != nil {
|
||||
m.scheduler.Shutdown()
|
||||
}
|
||||
if m.ledger != nil {
|
||||
_ = m.ledger.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
if m == nil || m.router == nil {
|
||||
return false
|
||||
}
|
||||
return m.router.HandleUpdate(ctx, update)
|
||||
}
|
||||
|
||||
type botServiceAdapter struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
type botUsersAdapter struct {
|
||||
store storage.TreasuryTelegramUsersStore
|
||||
}
|
||||
|
||||
func (a *botUsersAdapter) ResolveUserBinding(ctx context.Context, telegramUserID string) (*bot.UserBinding, error) {
|
||||
if a == nil || a.store == nil {
|
||||
return nil, merrors.Internal("treasury users store unavailable")
|
||||
}
|
||||
record, err := a.store.FindByTelegramUserID(ctx, telegramUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &bot.UserBinding{
|
||||
TelegramUserID: strings.TrimSpace(record.TelegramUserID),
|
||||
LedgerAccountID: strings.TrimSpace(record.LedgerAccountID),
|
||||
AllowedChatIDs: normalizeChatIDs(record.AllowedChatIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) {
|
||||
if a == nil || a.svc == nil {
|
||||
return 0
|
||||
}
|
||||
return a.svc.ExecutionDelay()
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) MaxPerOperationLimit() string {
|
||||
if a == nil || a.svc == nil {
|
||||
return ""
|
||||
}
|
||||
return a.svc.MaxPerOperationLimit()
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.GetActiveRequestForAccount(ctx, ledgerAccountID)
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*bot.AccountProfile, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
profile, err := a.svc.GetAccountProfile(ctx, ledgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &bot.AccountProfile{
|
||||
AccountID: strings.TrimSpace(profile.AccountID),
|
||||
AccountCode: strings.TrimSpace(profile.AccountCode),
|
||||
Currency: strings.TrimSpace(profile.Currency),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) CreateRequest(ctx context.Context, input bot.CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.CreateRequest(ctx, CreateRequestInput{
|
||||
OperationType: input.OperationType,
|
||||
TelegramUserID: input.TelegramUserID,
|
||||
LedgerAccountID: input.LedgerAccountID,
|
||||
ChatID: input.ChatID,
|
||||
Amount: input.Amount,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.ConfirmRequest(ctx, requestID, telegramUserID)
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.CancelRequest(ctx, requestID, telegramUserID)
|
||||
}
|
||||
|
||||
func normalizeChatIDs(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, next := range values {
|
||||
next = strings.TrimSpace(next)
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[next]; ok {
|
||||
continue
|
||||
}
|
||||
seen[next] = struct{}{}
|
||||
out = append(out, next)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
327
api/gateway/chsettle/internal/service/treasury/scheduler.go
Normal file
327
api/gateway/chsettle/internal/service/treasury/scheduler.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type NotifyFunc func(ctx context.Context, chatID string, text string) error
|
||||
|
||||
type Scheduler struct {
|
||||
logger mlogger.Logger
|
||||
service *Service
|
||||
notify NotifyFunc
|
||||
safetySweepInterval time.Duration
|
||||
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
timersMu sync.Mutex
|
||||
timers map[string]*time.Timer
|
||||
}
|
||||
|
||||
func NewScheduler(logger mlogger.Logger, service *Service, notify NotifyFunc, safetySweepInterval time.Duration) *Scheduler {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury_scheduler")
|
||||
}
|
||||
if safetySweepInterval <= 0 {
|
||||
safetySweepInterval = 30 * time.Second
|
||||
}
|
||||
return &Scheduler{
|
||||
logger: logger,
|
||||
service: service,
|
||||
notify: notify,
|
||||
safetySweepInterval: safetySweepInterval,
|
||||
timers: map[string]*time.Timer{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
if s == nil || s.service == nil || s.cancel != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
|
||||
// Rebuild in-memory timers from DB on startup.
|
||||
s.hydrateTimers(ctx)
|
||||
// Safety pass for overdue items at startup.
|
||||
s.sweep(ctx)
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(s.safetySweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.sweep(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Shutdown() {
|
||||
if s == nil || s.cancel == nil {
|
||||
return
|
||||
}
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
s.timersMu.Lock()
|
||||
for requestID, timer := range s.timers {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
delete(s.timers, requestID)
|
||||
}
|
||||
s.timersMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) TrackScheduled(record *storagemodel.TreasuryRequest) {
|
||||
if s == nil || s.service == nil || record == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(record.RequestID) == "" {
|
||||
return
|
||||
}
|
||||
if record.Status != storagemodel.TreasuryRequestStatusScheduled {
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(record.RequestID)
|
||||
when := record.ScheduledAt
|
||||
if when.IsZero() {
|
||||
when = time.Now()
|
||||
}
|
||||
delay := time.Until(when)
|
||||
if delay <= 0 {
|
||||
s.Untrack(requestID)
|
||||
go s.executeAndNotifyByID(context.Background(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
s.timersMu.Lock()
|
||||
if existing := s.timers[requestID]; existing != nil {
|
||||
existing.Stop()
|
||||
}
|
||||
s.timers[requestID] = time.AfterFunc(delay, func() {
|
||||
s.Untrack(requestID)
|
||||
s.executeAndNotifyByID(context.Background(), requestID)
|
||||
})
|
||||
s.timersMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Untrack(requestID string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
s.timersMu.Lock()
|
||||
if timer := s.timers[requestID]; timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
delete(s.timers, requestID)
|
||||
s.timersMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) hydrateTimers(ctx context.Context) {
|
||||
if s == nil || s.service == nil {
|
||||
return
|
||||
}
|
||||
scheduled, err := s.service.ScheduledRequests(ctx, 1000)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to hydrate scheduled treasury requests", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, record := range scheduled {
|
||||
s.TrackScheduled(&record)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) sweep(ctx context.Context) {
|
||||
if s == nil || s.service == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
confirmed, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
|
||||
storagemodel.TreasuryRequestStatusConfirmed,
|
||||
}, now, 100)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to list confirmed treasury requests", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, request := range confirmed {
|
||||
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
|
||||
}
|
||||
|
||||
scheduled, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
|
||||
storagemodel.TreasuryRequestStatusScheduled,
|
||||
}, now, 100)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to list scheduled treasury requests", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, request := range scheduled {
|
||||
s.Untrack(strings.TrimSpace(request.RequestID))
|
||||
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) executeAndNotifyByID(ctx context.Context, requestID string) {
|
||||
if s == nil || s.service == nil {
|
||||
return
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
runCtx := ctx
|
||||
if runCtx == nil {
|
||||
runCtx = context.Background()
|
||||
}
|
||||
withTimeout, cancel := context.WithTimeout(runCtx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := s.service.ExecuteRequest(withTimeout, requestID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to execute treasury request", zap.Error(err), zap.String("request_id", requestID))
|
||||
return
|
||||
}
|
||||
if result == nil || result.Request == nil {
|
||||
s.logger.Debug("Treasury execution produced no result", zap.String("request_id", requestID))
|
||||
return
|
||||
}
|
||||
if s.notify == nil {
|
||||
s.logger.Warn("Treasury execution notifier is unavailable", zap.String("request_id", requestID))
|
||||
return
|
||||
}
|
||||
|
||||
text := executionMessage(result)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
s.logger.Debug("Treasury execution result has no notification text",
|
||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
||||
return
|
||||
}
|
||||
chatID := strings.TrimSpace(result.Request.ChatID)
|
||||
if chatID == "" {
|
||||
s.logger.Warn("Treasury execution notification skipped: empty chat_id",
|
||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)))
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("Sending treasury execution notification",
|
||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
||||
|
||||
notifyCtx := context.Background()
|
||||
if ctx != nil {
|
||||
notifyCtx = ctx
|
||||
}
|
||||
notifyCtx, notifyCancel := context.WithTimeout(notifyCtx, 15*time.Second)
|
||||
defer notifyCancel()
|
||||
|
||||
if err := s.notify(notifyCtx, chatID, text); err != nil {
|
||||
s.logger.Warn("Failed to notify treasury execution result",
|
||||
zap.Error(err),
|
||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
||||
return
|
||||
}
|
||||
s.logger.Info("Treasury execution notification sent",
|
||||
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
|
||||
zap.String("chat_id", chatID),
|
||||
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
|
||||
}
|
||||
|
||||
func executionMessage(result *ExecutionResult) string {
|
||||
if result == nil || result.Request == nil {
|
||||
return ""
|
||||
}
|
||||
request := result.Request
|
||||
switch request.Status {
|
||||
case storagemodel.TreasuryRequestStatusExecuted:
|
||||
op := "Funding"
|
||||
sign := "+"
|
||||
if request.OperationType == storagemodel.TreasuryOperationWithdraw {
|
||||
op = "Withdrawal"
|
||||
sign = "-"
|
||||
}
|
||||
balanceAmount := "unavailable"
|
||||
balanceCurrency := strings.TrimSpace(request.Currency)
|
||||
if result.NewBalance != nil {
|
||||
if strings.TrimSpace(result.NewBalance.Amount) != "" {
|
||||
balanceAmount = strings.TrimSpace(result.NewBalance.Amount)
|
||||
}
|
||||
if strings.TrimSpace(result.NewBalance.Currency) != "" {
|
||||
balanceCurrency = strings.TrimSpace(result.NewBalance.Currency)
|
||||
}
|
||||
}
|
||||
return "*" + op + " completed*\n\n" +
|
||||
"*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
|
||||
"*Amount:* " + markdownCode(sign+strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
|
||||
"*New balance:* " + markdownCode(balanceAmount+" "+balanceCurrency) + "\n\n" +
|
||||
"*Reference:* " + markdownCode(strings.TrimSpace(request.RequestID))
|
||||
case storagemodel.TreasuryRequestStatusFailed:
|
||||
reason := strings.TrimSpace(request.ErrorMessage)
|
||||
if reason == "" && result.ExecutionError != nil {
|
||||
reason = strings.TrimSpace(result.ExecutionError.Error())
|
||||
}
|
||||
if reason == "" {
|
||||
reason = "Unknown error."
|
||||
}
|
||||
return "*Execution failed*\n\n" +
|
||||
"*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
|
||||
"*Amount:* " + markdownCode(strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
|
||||
"*Status:* " + markdownCode("FAILED") + "\n" +
|
||||
"*Reason:* " + markdownCode(compactForMarkdown(reason)) + "\n\n" +
|
||||
"*Request ID:* " + markdownCode(strings.TrimSpace(request.RequestID))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func requestAccountCode(request *storagemodel.TreasuryRequest) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
if code := strings.TrimSpace(request.LedgerAccountCode); code != "" {
|
||||
return code
|
||||
}
|
||||
return strings.TrimSpace(request.LedgerAccountID)
|
||||
}
|
||||
|
||||
func markdownCode(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
value = "N/A"
|
||||
}
|
||||
value = strings.ReplaceAll(value, "`", "'")
|
||||
return "`" + value + "`"
|
||||
}
|
||||
|
||||
func compactForMarkdown(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "Unknown error."
|
||||
}
|
||||
value = strings.ReplaceAll(value, "\r\n", " ")
|
||||
value = strings.ReplaceAll(value, "\n", " ")
|
||||
value = strings.ReplaceAll(value, "\r", " ")
|
||||
return strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
457
api/gateway/chsettle/internal/service/treasury/service.go
Normal file
457
api/gateway/chsettle/internal/service/treasury/service.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger"
|
||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var ErrActiveTreasuryRequest = errors.New("active treasury request exists")
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
LedgerAccountID string
|
||||
ChatID string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type AccountProfile struct {
|
||||
AccountID string
|
||||
AccountCode string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type ExecutionResult struct {
|
||||
Request *storagemodel.TreasuryRequest
|
||||
NewBalance *ledger.Balance
|
||||
ExecutionError error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
repo storage.TreasuryRequestsStore
|
||||
ledger ledger.Client
|
||||
|
||||
validator *Validator
|
||||
executionDelay time.Duration
|
||||
}
|
||||
|
||||
func NewService(
|
||||
logger mlogger.Logger,
|
||||
repo storage.TreasuryRequestsStore,
|
||||
ledgerClient ledger.Client,
|
||||
executionDelay time.Duration,
|
||||
maxPerOperation string,
|
||||
maxDaily string,
|
||||
) (*Service, error) {
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("logger is required", "logger")
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("treasury repository is required", "repo")
|
||||
}
|
||||
if ledgerClient == nil {
|
||||
return nil, merrors.InvalidArgument("ledger client is required", "ledger_client")
|
||||
}
|
||||
if executionDelay <= 0 {
|
||||
executionDelay = 30 * time.Second
|
||||
}
|
||||
validator, err := NewValidator(repo, maxPerOperation, maxDaily)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{
|
||||
logger: logger.Named("treasury_service"),
|
||||
repo: repo,
|
||||
ledger: ledgerClient,
|
||||
validator: validator,
|
||||
executionDelay: executionDelay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExecutionDelay() time.Duration {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.executionDelay
|
||||
}
|
||||
|
||||
func (s *Service) MaxPerOperationLimit() string {
|
||||
if s == nil || s.validator == nil {
|
||||
return ""
|
||||
}
|
||||
return s.validator.MaxPerOperation()
|
||||
}
|
||||
|
||||
func (s *Service) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindActiveByLedgerAccountID(ctx, ledgerAccountID)
|
||||
}
|
||||
|
||||
func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindByRequestID(ctx, requestID)
|
||||
}
|
||||
|
||||
func (s *Service) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error) {
|
||||
if s == nil || s.ledger == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
|
||||
if ledgerAccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
||||
}
|
||||
|
||||
account, err := s.ledger.GetAccount(ctx, ledgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account == nil {
|
||||
return nil, merrors.NoData("ledger account not found")
|
||||
}
|
||||
return &AccountProfile{
|
||||
AccountID: ledgerAccountID,
|
||||
AccountCode: resolveAccountCode(account, ledgerAccountID),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil || s.ledger == nil || s.validator == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
input.TelegramUserID = strings.TrimSpace(input.TelegramUserID)
|
||||
input.LedgerAccountID = strings.TrimSpace(input.LedgerAccountID)
|
||||
input.ChatID = strings.TrimSpace(input.ChatID)
|
||||
input.Amount = strings.TrimSpace(input.Amount)
|
||||
|
||||
switch input.OperationType {
|
||||
case storagemodel.TreasuryOperationFund, storagemodel.TreasuryOperationWithdraw:
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
||||
}
|
||||
if input.TelegramUserID == "" {
|
||||
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
||||
}
|
||||
if input.LedgerAccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
||||
}
|
||||
if input.ChatID == "" {
|
||||
return nil, merrors.InvalidArgument("chat_id is required", "chat_id")
|
||||
}
|
||||
|
||||
active, err := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active != nil {
|
||||
return active, ErrActiveTreasuryRequest
|
||||
}
|
||||
|
||||
amountRat, normalizedAmount, err := s.validator.ValidateAmount(input.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validator.ValidateDailyLimit(ctx, input.LedgerAccountID, amountRat, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := s.ledger.GetAccount(ctx, input.LedgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account == nil || strings.TrimSpace(account.Currency) == "" {
|
||||
return nil, merrors.Internal("ledger account currency is unavailable")
|
||||
}
|
||||
if strings.TrimSpace(account.OrganizationRef) == "" {
|
||||
return nil, merrors.Internal("ledger account organization is unavailable")
|
||||
}
|
||||
|
||||
requestID := newRequestID()
|
||||
record := &storagemodel.TreasuryRequest{
|
||||
RequestID: requestID,
|
||||
OperationType: input.OperationType,
|
||||
TelegramUserID: input.TelegramUserID,
|
||||
LedgerAccountID: input.LedgerAccountID,
|
||||
LedgerAccountCode: resolveAccountCode(account, input.LedgerAccountID),
|
||||
OrganizationRef: account.OrganizationRef,
|
||||
ChatID: input.ChatID,
|
||||
Amount: normalizedAmount,
|
||||
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
|
||||
Status: storagemodel.TreasuryRequestStatusCreated,
|
||||
IdempotencyKey: fmt.Sprintf("chsettle:%s", requestID),
|
||||
Active: true,
|
||||
}
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicate) {
|
||||
active, fetchErr := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
||||
if fetchErr != nil {
|
||||
return nil, fetchErr
|
||||
}
|
||||
if active != nil {
|
||||
return active, ErrActiveTreasuryRequest
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logRequest(record, "created", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, merrors.NoData("treasury request not found")
|
||||
}
|
||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusScheduled:
|
||||
return record, nil
|
||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed:
|
||||
now := time.Now()
|
||||
record.ConfirmedAt = now
|
||||
record.ScheduledAt = now.Add(s.executionDelay)
|
||||
record.Status = storagemodel.TreasuryRequestStatusScheduled
|
||||
record.Active = true
|
||||
record.ErrorMessage = ""
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury request cannot be confirmed in current status", "status")
|
||||
}
|
||||
if err := s.repo.Update(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logRequest(record, "scheduled", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, merrors.NoData("treasury request not found")
|
||||
}
|
||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusCancelled:
|
||||
return record, nil
|
||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed, storagemodel.TreasuryRequestStatusScheduled:
|
||||
record.Status = storagemodel.TreasuryRequestStatusCancelled
|
||||
record.CancelledAt = time.Now()
|
||||
record.Active = false
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury request cannot be cancelled in current status", "status")
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logRequest(record, "cancelled", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExecuteRequest(ctx context.Context, requestID string) (*ExecutionResult, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusExecuted,
|
||||
storagemodel.TreasuryRequestStatusCancelled,
|
||||
storagemodel.TreasuryRequestStatusFailed:
|
||||
return nil, nil
|
||||
case storagemodel.TreasuryRequestStatusScheduled:
|
||||
claimed, err := s.repo.ClaimScheduled(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claimed {
|
||||
return nil, nil
|
||||
}
|
||||
record, err = s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
if record.Status != storagemodel.TreasuryRequestStatusConfirmed {
|
||||
return nil, nil
|
||||
}
|
||||
return s.executeClaimed(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.TreasuryRequest) (*ExecutionResult, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("treasury request is required", "request")
|
||||
}
|
||||
postReq := ledger.PostRequest{
|
||||
AccountID: record.LedgerAccountID,
|
||||
OrganizationRef: record.OrganizationRef,
|
||||
Amount: record.Amount,
|
||||
Currency: record.Currency,
|
||||
Reference: record.RequestID,
|
||||
IdempotencyKey: record.IdempotencyKey,
|
||||
}
|
||||
|
||||
var (
|
||||
opResult *ledger.OperationResult
|
||||
err error
|
||||
)
|
||||
switch record.OperationType {
|
||||
case storagemodel.TreasuryOperationFund:
|
||||
opResult, err = s.ledger.ExternalCredit(ctx, postReq)
|
||||
case storagemodel.TreasuryOperationWithdraw:
|
||||
opResult, err = s.ledger.ExternalDebit(ctx, postReq)
|
||||
default:
|
||||
err = merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
||||
}
|
||||
now := time.Now()
|
||||
if err != nil {
|
||||
record.Status = storagemodel.TreasuryRequestStatusFailed
|
||||
record.Active = false
|
||||
record.ExecutedAt = now
|
||||
record.ErrorMessage = strings.TrimSpace(err.Error())
|
||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
s.logRequest(record, "failed", err)
|
||||
return &ExecutionResult{
|
||||
Request: record,
|
||||
ExecutionError: err,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if opResult != nil {
|
||||
record.LedgerReference = strings.TrimSpace(opResult.Reference)
|
||||
}
|
||||
record.Status = storagemodel.TreasuryRequestStatusExecuted
|
||||
record.Active = false
|
||||
record.ExecutedAt = now
|
||||
record.ErrorMessage = ""
|
||||
|
||||
balance, balanceErr := s.ledger.GetBalance(ctx, record.LedgerAccountID)
|
||||
if balanceErr != nil {
|
||||
record.ErrorMessage = strings.TrimSpace(balanceErr.Error())
|
||||
}
|
||||
|
||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
s.logRequest(record, "executed", nil)
|
||||
return &ExecutionResult{
|
||||
Request: record,
|
||||
NewBalance: balance,
|
||||
ExecutionError: balanceErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindDueByStatus(ctx, statuses, now, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ScheduledRequests(ctx context.Context, limit int64) ([]storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindDueByStatus(
|
||||
ctx,
|
||||
[]storagemodel.TreasuryRequestStatus{storagemodel.TreasuryRequestStatusScheduled},
|
||||
time.Now().Add(10*365*24*time.Hour),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) ParseAmount(value string) (*big.Rat, error) {
|
||||
return parseAmountRat(value)
|
||||
}
|
||||
|
||||
func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string, err error) {
|
||||
if s == nil || s.logger == nil || record == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("request_id", strings.TrimSpace(record.RequestID)),
|
||||
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
|
||||
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
|
||||
zap.String("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)),
|
||||
zap.String("chat_id", strings.TrimSpace(record.ChatID)),
|
||||
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
|
||||
zap.String("amount", strings.TrimSpace(record.Amount)),
|
||||
zap.String("currency", strings.TrimSpace(record.Currency)),
|
||||
zap.String("status", status),
|
||||
zap.String("ledger_reference", strings.TrimSpace(record.LedgerReference)),
|
||||
zap.String("error_message", strings.TrimSpace(record.ErrorMessage)),
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
s.logger.Info("treasury_request", fields...)
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
return "TG-TREASURY-" + strings.ToUpper(bson.NewObjectID().Hex())
|
||||
}
|
||||
|
||||
func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string {
|
||||
if account != nil {
|
||||
if code := strings.TrimSpace(account.AccountCode); code != "" {
|
||||
return code
|
||||
}
|
||||
if code := strings.TrimSpace(account.AccountID); code != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fallbackAccountID)
|
||||
}
|
||||
178
api/gateway/chsettle/internal/service/treasury/validator.go
Normal file
178
api/gateway/chsettle/internal/service/treasury/validator.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/chsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
var treasuryAmountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
|
||||
|
||||
type LimitKind string
|
||||
|
||||
const (
|
||||
LimitKindPerOperation LimitKind = "per_operation"
|
||||
LimitKindDaily LimitKind = "daily"
|
||||
)
|
||||
|
||||
type LimitError struct {
|
||||
Kind LimitKind
|
||||
Max string
|
||||
}
|
||||
|
||||
func (e *LimitError) Error() string {
|
||||
if e == nil {
|
||||
return "limit exceeded"
|
||||
}
|
||||
switch e.Kind {
|
||||
case LimitKindPerOperation:
|
||||
return "max amount per operation exceeded"
|
||||
case LimitKindDaily:
|
||||
return "max daily amount exceeded"
|
||||
default:
|
||||
return "limit exceeded"
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LimitError) LimitKind() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return string(e.Kind)
|
||||
}
|
||||
|
||||
func (e *LimitError) LimitMax() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Max
|
||||
}
|
||||
|
||||
type Validator struct {
|
||||
repo storage.TreasuryRequestsStore
|
||||
|
||||
maxPerOperation *big.Rat
|
||||
maxDaily *big.Rat
|
||||
|
||||
maxPerOperationRaw string
|
||||
maxDailyRaw string
|
||||
}
|
||||
|
||||
func NewValidator(repo storage.TreasuryRequestsStore, maxPerOperation string, maxDaily string) (*Validator, error) {
|
||||
validator := &Validator{
|
||||
repo: repo,
|
||||
maxPerOperationRaw: strings.TrimSpace(maxPerOperation),
|
||||
maxDailyRaw: strings.TrimSpace(maxDaily),
|
||||
}
|
||||
if validator.maxPerOperationRaw != "" {
|
||||
value, err := parseAmountRat(validator.maxPerOperationRaw)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("treasury max_amount_per_operation is invalid", "treasury.limits.max_amount_per_operation")
|
||||
}
|
||||
validator.maxPerOperation = value
|
||||
}
|
||||
if validator.maxDailyRaw != "" {
|
||||
value, err := parseAmountRat(validator.maxDailyRaw)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("treasury max_daily_amount is invalid", "treasury.limits.max_daily_amount")
|
||||
}
|
||||
validator.maxDaily = value
|
||||
}
|
||||
return validator, nil
|
||||
}
|
||||
|
||||
func (v *Validator) MaxPerOperation() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.maxPerOperationRaw
|
||||
}
|
||||
|
||||
func (v *Validator) MaxDaily() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.maxDailyRaw
|
||||
}
|
||||
|
||||
func (v *Validator) ValidateAmount(amount string) (*big.Rat, string, error) {
|
||||
amount = strings.TrimSpace(amount)
|
||||
value, err := parseAmountRat(amount)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if v != nil && v.maxPerOperation != nil && value.Cmp(v.maxPerOperation) > 0 {
|
||||
return nil, "", &LimitError{
|
||||
Kind: LimitKindPerOperation,
|
||||
Max: v.maxPerOperationRaw,
|
||||
}
|
||||
}
|
||||
return value, amount, nil
|
||||
}
|
||||
|
||||
func (v *Validator) ValidateDailyLimit(ctx context.Context, ledgerAccountID string, amount *big.Rat, now time.Time) error {
|
||||
if v == nil || v.maxDaily == nil || v.repo == nil {
|
||||
return nil
|
||||
}
|
||||
if amount == nil {
|
||||
return merrors.InvalidArgument("amount is required", "amount")
|
||||
}
|
||||
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
records, err := v.repo.ListByAccountAndStatuses(
|
||||
ctx,
|
||||
ledgerAccountID,
|
||||
[]storagemodel.TreasuryRequestStatus{
|
||||
storagemodel.TreasuryRequestStatusCreated,
|
||||
storagemodel.TreasuryRequestStatusConfirmed,
|
||||
storagemodel.TreasuryRequestStatusScheduled,
|
||||
storagemodel.TreasuryRequestStatusExecuted,
|
||||
},
|
||||
dayStart,
|
||||
dayEnd,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total := new(big.Rat)
|
||||
for _, record := range records {
|
||||
next, err := parseAmountRat(record.Amount)
|
||||
if err != nil {
|
||||
return merrors.Internal("treasury request amount is invalid")
|
||||
}
|
||||
total.Add(total, next)
|
||||
}
|
||||
total.Add(total, amount)
|
||||
if total.Cmp(v.maxDaily) > 0 {
|
||||
return &LimitError{
|
||||
Kind: LimitKindDaily,
|
||||
Max: v.maxDailyRaw,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAmountRat(value string) (*big.Rat, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, merrors.InvalidArgument("amount is required", "amount")
|
||||
}
|
||||
if !treasuryAmountPattern.MatchString(value) {
|
||||
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
|
||||
}
|
||||
amount := new(big.Rat)
|
||||
if _, ok := amount.SetString(value); !ok {
|
||||
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
|
||||
}
|
||||
if amount.Sign() <= 0 {
|
||||
return nil, merrors.InvalidArgument("amount must be positive", "amount")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
17
api/gateway/chsettle/main.go
Normal file
17
api/gateway/chsettle/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/gateway/chsettle/internal/appversion"
|
||||
si "github.com/tech/sendico/gateway/chsettle/internal/server"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
smain "github.com/tech/sendico/pkg/server/main"
|
||||
)
|
||||
|
||||
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return si.Create(logger, file, debug)
|
||||
}
|
||||
|
||||
func main() {
|
||||
smain.RunServer("gateway", appversion.Create(), factory)
|
||||
}
|
||||
65
api/gateway/chsettle/storage/model/execution.go
Normal file
65
api/gateway/chsettle/storage/model/execution.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
type PaymentStatus string
|
||||
|
||||
const (
|
||||
PaymentStatusCreated PaymentStatus = "created" // created
|
||||
PaymentStatusProcessing PaymentStatus = "processing" // processing
|
||||
PaymentStatusWaiting PaymentStatus = "waiting" // waiting external action
|
||||
PaymentStatusSuccess PaymentStatus = "success" // final success
|
||||
PaymentStatusFailed PaymentStatus = "failed" // final failure
|
||||
PaymentStatusCancelled PaymentStatus = "cancelled" // cancelled final
|
||||
)
|
||||
|
||||
type PaymentRecord struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"`
|
||||
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
|
||||
ConfirmationRef string `bson:"confirmationRef,omitempty" json:"confirmation_ref,omitempty"`
|
||||
ConfirmationMessageID string `bson:"confirmationMessageId,omitempty" json:"confirmation_message_id,omitempty"`
|
||||
ConfirmationReplyMessageID string `bson:"confirmationReplyMessageId,omitempty" json:"confirmation_reply_message_id,omitempty"`
|
||||
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
|
||||
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
|
||||
IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"`
|
||||
PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"`
|
||||
Scenario string `bson:"scenario,omitempty" json:"scenario,omitempty"`
|
||||
OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"`
|
||||
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
|
||||
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
|
||||
ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"`
|
||||
Status PaymentStatus `bson:"status,omitempty" json:"status,omitempty"`
|
||||
FailureReason string `bson:"failureReason,omitempty" json:"Failure_reason,omitempty"`
|
||||
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`
|
||||
ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"`
|
||||
ExpiredAt time.Time `bson:"expiredAt,omitempty" json:"expired_at,omitempty"`
|
||||
}
|
||||
|
||||
type TelegramConfirmation struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
||||
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
|
||||
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
|
||||
RawReply *model.TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"`
|
||||
ReceivedAt time.Time `bson:"receivedAt,omitempty" json:"received_at,omitempty"`
|
||||
}
|
||||
|
||||
type PendingConfirmation struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
||||
MessageID string `bson:"messageId,omitempty" json:"message_id,omitempty"`
|
||||
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
|
||||
AcceptedUserIDs []string `bson:"acceptedUserIds,omitempty" json:"accepted_user_ids,omitempty"`
|
||||
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
|
||||
SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"`
|
||||
Rail string `bson:"rail,omitempty" json:"rail,omitempty"`
|
||||
Clarified bool `bson:"clarified,omitempty" json:"clarified,omitempty"`
|
||||
ExpiresAt time.Time `bson:"expiresAt,omitempty" json:"expires_at,omitempty"`
|
||||
}
|
||||
29
api/gateway/chsettle/storage/model/storable.go
Normal file
29
api/gateway/chsettle/storage/model/storable.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package model
|
||||
|
||||
const (
|
||||
paymentsCollection = "payments"
|
||||
telegramConfirmationsCollection = "telegram_confirmations"
|
||||
pendingConfirmationsCollection = "pending_confirmations"
|
||||
treasuryRequestsCollection = "treasury_requests"
|
||||
treasuryTelegramUsersCollection = "treasury_telegram_users"
|
||||
)
|
||||
|
||||
func (*PaymentRecord) Collection() string {
|
||||
return paymentsCollection
|
||||
}
|
||||
|
||||
func (*TelegramConfirmation) Collection() string {
|
||||
return telegramConfirmationsCollection
|
||||
}
|
||||
|
||||
func (*PendingConfirmation) Collection() string {
|
||||
return pendingConfirmationsCollection
|
||||
}
|
||||
|
||||
func (*TreasuryRequest) Collection() string {
|
||||
return treasuryRequestsCollection
|
||||
}
|
||||
|
||||
func (*TreasuryTelegramUser) Collection() string {
|
||||
return treasuryTelegramUsersCollection
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user