Compare commits
29 Commits
d6a3a0cc5b
...
SEND066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aceb2f441 | ||
|
|
281b3834d3 | ||
|
|
2b0ada1541 | ||
| ea5ec79a6e | |||
|
|
3295b9d9f0 | ||
| 031b8931ca | |||
|
|
4295456f63 | ||
| 2b1b4135f4 | |||
|
|
c60e7d2329 | ||
| be49254769 | |||
|
|
34e507b664 | ||
|
|
b67d199427 | ||
| b481de9ffc | |||
|
|
0c29e7686d | ||
| 5b26a70a15 | |||
|
|
b832c2a7c4 | ||
|
|
97b16542c2 | ||
|
|
39c04beb21 | ||
| 15393765b9 | |||
|
|
440b6a2553 | ||
| bc76cfe063 | |||
|
|
ed8f7c519c | ||
|
|
71d99338f2 | ||
| b499778bce | |||
|
|
4a554833c4 | ||
| b7ea11a62b | |||
|
|
026f698d9b | ||
| 0da6078468 | |||
|
|
3b65a2dc3a |
41
Makefile
41
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,6 +52,9 @@ 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)"
|
||||
@@ -229,6 +253,21 @@ services-up:
|
||||
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
|
||||
|
||||
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,6 +354,22 @@ func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organi
|
||||
}
|
||||
}
|
||||
|
||||
currencies := []string{"RUB", "USDT"}
|
||||
if chainTokenCurrency := strings.ToUpper(strings.TrimSpace(a.chainAsset.GetTokenSymbol())); chainTokenCurrency != "" {
|
||||
currencies = append(currencies, chainTokenCurrency)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(currencies))
|
||||
for _, currency := range currencies {
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if currency == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[currency]; exists {
|
||||
continue
|
||||
}
|
||||
seen[currency] = struct{}{}
|
||||
|
||||
resp, err := a.ledgerClient.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
|
||||
OrganizationRef: org.ID.Hex(),
|
||||
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
|
||||
@@ -370,13 +383,18 @@ func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organi
|
||||
Describable: describable,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org))
|
||||
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org), zap.String("currency", currency))
|
||||
return err
|
||||
}
|
||||
if resp == nil || resp.GetAccount() == nil || strings.TrimSpace(resp.GetAccount().GetLedgerAccountRef()) == "" {
|
||||
return merrors.Internal("ledger returned empty account reference")
|
||||
}
|
||||
|
||||
a.logger.Info("Ledger account created for organization", mzap.StorableRef(org), zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
|
||||
a.logger.Info("Ledger account created for organization",
|
||||
mzap.StorableRef(org),
|
||||
zap.String("currency", currency),
|
||||
zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
)
|
||||
|
||||
type stubLedgerAccountClient struct {
|
||||
createReq *ledgerv1.CreateAccountRequest
|
||||
createReqs []*ledgerv1.CreateAccountRequest
|
||||
createResp *ledgerv1.CreateAccountResponse
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (s *stubLedgerAccountClient) CreateAccount(_ context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
|
||||
s.createReq = req
|
||||
s.createReqs = append(s.createReqs, req)
|
||||
return s.createResp, s.createErr
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (s *stubLedgerAccountClient) Close() error {
|
||||
}
|
||||
|
||||
func TestOpenOrgLedgerAccount(t *testing.T) {
|
||||
t.Run("creates operating ledger account", func(t *testing.T) {
|
||||
t.Run("creates operating ledger accounts for RUB and USDT", func(t *testing.T) {
|
||||
desc := " Main org ledger account "
|
||||
sr := &srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
@@ -65,23 +65,27 @@ func TestOpenOrgLedgerAccount(t *testing.T) {
|
||||
|
||||
err := api.openOrgLedgerAccount(context.Background(), org, sr)
|
||||
assert.NoError(t, err)
|
||||
if assert.NotNil(t, ledgerStub.createReq) {
|
||||
assert.Equal(t, org.ID.Hex(), ledgerStub.createReq.GetOrganizationRef())
|
||||
assert.Equal(t, "RUB", ledgerStub.createReq.GetCurrency())
|
||||
assert.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, ledgerStub.createReq.GetAccountType())
|
||||
assert.Equal(t, ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, ledgerStub.createReq.GetStatus())
|
||||
assert.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerStub.createReq.GetRole())
|
||||
if assert.Len(t, ledgerStub.createReqs, 2) {
|
||||
currencies := make([]string, 0, len(ledgerStub.createReqs))
|
||||
for _, req := range ledgerStub.createReqs {
|
||||
currencies = append(currencies, req.GetCurrency())
|
||||
assert.Equal(t, org.ID.Hex(), req.GetOrganizationRef())
|
||||
assert.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, req.GetAccountType())
|
||||
assert.Equal(t, ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, req.GetStatus())
|
||||
assert.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, req.GetRole())
|
||||
assert.Equal(t, map[string]string{
|
||||
"source": "signup",
|
||||
"login": "owner@example.com",
|
||||
}, ledgerStub.createReq.GetMetadata())
|
||||
if assert.NotNil(t, ledgerStub.createReq.GetDescribable()) {
|
||||
assert.Equal(t, "Primary Ledger", ledgerStub.createReq.GetDescribable().GetName())
|
||||
if assert.NotNil(t, ledgerStub.createReq.GetDescribable().Description) {
|
||||
assert.Equal(t, "Main org ledger account", ledgerStub.createReq.GetDescribable().GetDescription())
|
||||
}, req.GetMetadata())
|
||||
if assert.NotNil(t, req.GetDescribable()) {
|
||||
assert.Equal(t, "Primary Ledger", req.GetDescribable().GetName())
|
||||
if assert.NotNil(t, req.GetDescribable().Description) {
|
||||
assert.Equal(t, "Main org ledger account", req.GetDescribable().GetDescription())
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"RUB", "USDT"}, currencies)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails when ledger client is missing", func(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ replace github.com/tech/sendico/gateway/common => ../common
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/gateway/common v0.1.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
@@ -36,6 +35,7 @@ require (
|
||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
|
||||
@@ -54,7 +54,7 @@ type cardPayoutProcessor struct {
|
||||
dispatchSerialGate chan struct{}
|
||||
|
||||
retryPolicy payoutFailurePolicy
|
||||
retryDelayFn func(attempt uint32) time.Duration
|
||||
retryDelayFn func(attempt uint32, strategy payoutRetryStrategy) time.Duration
|
||||
|
||||
retryMu sync.Mutex
|
||||
retryTimers map[string]*time.Timer
|
||||
@@ -149,15 +149,13 @@ func applyCardPayoutSendResult(state *model.CardPayout, result *monetix.CardPayo
|
||||
return
|
||||
}
|
||||
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
|
||||
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
|
||||
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
|
||||
if result.Accepted {
|
||||
state.Status = model.PayoutStatusWaiting
|
||||
state.ProviderCode = ""
|
||||
state.ProviderMessage = ""
|
||||
return
|
||||
}
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
|
||||
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
|
||||
}
|
||||
|
||||
func payoutStateLogFields(state *model.CardPayout) []zap.Field {
|
||||
@@ -593,13 +591,20 @@ func payoutAcceptedForState(state *model.CardPayout) bool {
|
||||
return false
|
||||
}
|
||||
switch state.Status {
|
||||
case model.PayoutStatusFailed, model.PayoutStatusCancelled:
|
||||
case model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func terminalStatusAfterRetryExhausted(decision payoutFailureDecision) model.PayoutStatus {
|
||||
if decision.Action == payoutFailureActionRetry {
|
||||
return model.PayoutStatusNeedsAttention
|
||||
}
|
||||
return model.PayoutStatusFailed
|
||||
}
|
||||
|
||||
func cardPayoutResponseFromState(
|
||||
state *model.CardPayout,
|
||||
accepted bool,
|
||||
@@ -733,15 +738,21 @@ func (p *cardPayoutProcessor) scheduleRetryTimer(operationRef string, delay time
|
||||
p.retryTimers[key] = timer
|
||||
}
|
||||
|
||||
func retryDelayDuration(attempt uint32) time.Duration {
|
||||
return time.Duration(retryDelayForAttempt(attempt)) * time.Second
|
||||
func retryDelayDuration(attempt uint32, strategy payoutRetryStrategy) time.Duration {
|
||||
return time.Duration(retryDelayForAttempt(attempt, strategy)) * time.Second
|
||||
}
|
||||
|
||||
func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequest, failedAttempt uint32, maxAttempts uint32) {
|
||||
func (p *cardPayoutProcessor) scheduleCardPayoutRetry(
|
||||
req *mntxv1.CardPayoutRequest,
|
||||
failedAttempt uint32,
|
||||
maxAttempts uint32,
|
||||
strategy payoutRetryStrategy,
|
||||
) {
|
||||
if p == nil || req == nil {
|
||||
return
|
||||
}
|
||||
maxAttempts = maxDispatchAttempts(maxAttempts)
|
||||
strategy = normalizeRetryStrategy(strategy)
|
||||
nextAttempt := failedAttempt + 1
|
||||
if nextAttempt > maxAttempts {
|
||||
return
|
||||
@@ -751,12 +762,13 @@ func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequ
|
||||
return
|
||||
}
|
||||
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
|
||||
delay := retryDelayDuration(failedAttempt)
|
||||
delay := retryDelayDuration(failedAttempt, strategy)
|
||||
if p.retryDelayFn != nil {
|
||||
delay = p.retryDelayFn(failedAttempt)
|
||||
delay = p.retryDelayFn(failedAttempt, strategy)
|
||||
}
|
||||
p.logger.Info("Scheduling card payout retry",
|
||||
zap.String("operation_ref", operationRef),
|
||||
zap.String("strategy", strategy.String()),
|
||||
zap.Uint32("failed_attempt", failedAttempt),
|
||||
zap.Uint32("next_attempt", nextAttempt),
|
||||
zap.Uint32("max_attempts", maxAttempts),
|
||||
@@ -767,11 +779,17 @@ func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequ
|
||||
})
|
||||
}
|
||||
|
||||
func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(req *mntxv1.CardTokenPayoutRequest, failedAttempt uint32, maxAttempts uint32) {
|
||||
func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(
|
||||
req *mntxv1.CardTokenPayoutRequest,
|
||||
failedAttempt uint32,
|
||||
maxAttempts uint32,
|
||||
strategy payoutRetryStrategy,
|
||||
) {
|
||||
if p == nil || req == nil {
|
||||
return
|
||||
}
|
||||
maxAttempts = maxDispatchAttempts(maxAttempts)
|
||||
strategy = normalizeRetryStrategy(strategy)
|
||||
nextAttempt := failedAttempt + 1
|
||||
if nextAttempt > maxAttempts {
|
||||
return
|
||||
@@ -781,12 +799,13 @@ func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(req *mntxv1.CardToken
|
||||
return
|
||||
}
|
||||
operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId())
|
||||
delay := retryDelayDuration(failedAttempt)
|
||||
delay := retryDelayDuration(failedAttempt, strategy)
|
||||
if p.retryDelayFn != nil {
|
||||
delay = p.retryDelayFn(failedAttempt)
|
||||
delay = p.retryDelayFn(failedAttempt, strategy)
|
||||
}
|
||||
p.logger.Info("Scheduling card token payout retry",
|
||||
zap.String("operation_ref", operationRef),
|
||||
zap.String("strategy", strategy.String()),
|
||||
zap.Uint32("failed_attempt", failedAttempt),
|
||||
zap.Uint32("next_attempt", nextAttempt),
|
||||
zap.Uint32("max_attempts", maxAttempts),
|
||||
@@ -857,11 +876,11 @@ func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest,
|
||||
p.logger.Warn("Failed to persist retryable payout transport failure", zap.Error(upErr))
|
||||
return
|
||||
}
|
||||
p.scheduleCardPayoutRetry(req, attempt, maxAttempts)
|
||||
p.scheduleCardPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||
return
|
||||
}
|
||||
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason("", err.Error())
|
||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||
p.logger.Warn("Failed to persist terminal payout transport failure", zap.Error(upErr))
|
||||
@@ -889,11 +908,11 @@ func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest,
|
||||
p.logger.Warn("Failed to persist retryable payout provider failure", zap.Error(upErr))
|
||||
return
|
||||
}
|
||||
p.scheduleCardPayoutRetry(req, attempt, maxAttempts)
|
||||
p.scheduleCardPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||
return
|
||||
}
|
||||
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||
p.logger.Warn("Failed to persist terminal payout provider failure", zap.Error(upErr))
|
||||
@@ -946,11 +965,11 @@ func (p *cardPayoutProcessor) runCardTokenPayoutRetry(req *mntxv1.CardTokenPayou
|
||||
p.logger.Warn("Failed to persist retryable token payout transport failure", zap.Error(upErr))
|
||||
return
|
||||
}
|
||||
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts)
|
||||
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||
return
|
||||
}
|
||||
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason("", err.Error())
|
||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||
p.logger.Warn("Failed to persist terminal token payout transport failure", zap.Error(upErr))
|
||||
@@ -978,11 +997,11 @@ func (p *cardPayoutProcessor) runCardTokenPayoutRetry(req *mntxv1.CardTokenPayou
|
||||
p.logger.Warn("Failed to persist retryable token payout provider failure", zap.Error(upErr))
|
||||
return
|
||||
}
|
||||
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts)
|
||||
p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts, decision.Strategy)
|
||||
return
|
||||
}
|
||||
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||
if upErr := p.updatePayoutStatus(ctx, state); upErr != nil {
|
||||
p.logger.Warn("Failed to persist terminal token payout provider failure", zap.Error(upErr))
|
||||
@@ -1067,7 +1086,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
}
|
||||
if existing != nil {
|
||||
switch existing.Status {
|
||||
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled:
|
||||
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
|
||||
p.observeExecutionState(existing)
|
||||
return cardPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
|
||||
}
|
||||
@@ -1088,11 +1107,11 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
p.logger.Warn("Failed to update payout status", fields...)
|
||||
return nil, e
|
||||
}
|
||||
p.scheduleCardPayoutRetry(req, 1, maxAttempts)
|
||||
p.scheduleCardPayoutRetry(req, 1, maxAttempts, decision.Strategy)
|
||||
return cardPayoutResponseFromState(state, true, "", ""), nil
|
||||
}
|
||||
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason("", err.Error())
|
||||
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
||||
fields := append([]zap.Field{zap.Error(e)}, payoutStateLogFields(state)...)
|
||||
@@ -1112,6 +1131,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
errorMessage := strings.TrimSpace(result.ErrorMessage)
|
||||
scheduleRetry := false
|
||||
retryMaxAttempts := uint32(0)
|
||||
retryStrategy := payoutRetryStrategyImmediate
|
||||
|
||||
if !result.Accepted {
|
||||
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
|
||||
@@ -1124,8 +1144,9 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
errorMessage = ""
|
||||
scheduleRetry = true
|
||||
retryMaxAttempts = maxAttempts
|
||||
retryStrategy = decision.Strategy
|
||||
} else {
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||
p.clearRetryState(state.OperationRef)
|
||||
}
|
||||
@@ -1144,7 +1165,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
return nil, err
|
||||
}
|
||||
if scheduleRetry {
|
||||
p.scheduleCardPayoutRetry(req, 1, retryMaxAttempts)
|
||||
p.scheduleCardPayoutRetry(req, 1, retryMaxAttempts, retryStrategy)
|
||||
}
|
||||
|
||||
resp := cardPayoutResponseFromState(state, accepted, errorCode, errorMessage)
|
||||
@@ -1231,7 +1252,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
}
|
||||
if existing != nil {
|
||||
switch existing.Status {
|
||||
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled:
|
||||
case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusCancelled:
|
||||
p.observeExecutionState(existing)
|
||||
return cardTokenPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil
|
||||
}
|
||||
@@ -1250,11 +1271,11 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p.scheduleCardTokenPayoutRetry(req, 1, maxAttempts)
|
||||
p.scheduleCardTokenPayoutRetry(req, 1, maxAttempts, decision.Strategy)
|
||||
return cardTokenPayoutResponseFromState(state, true, "", ""), nil
|
||||
}
|
||||
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason("", err.Error())
|
||||
if e := p.updatePayoutStatus(ctx, state); e != nil {
|
||||
return nil, e
|
||||
@@ -1274,6 +1295,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
errorMessage := strings.TrimSpace(result.ErrorMessage)
|
||||
scheduleRetry := false
|
||||
retryMaxAttempts := uint32(0)
|
||||
retryStrategy := payoutRetryStrategyImmediate
|
||||
|
||||
if !result.Accepted {
|
||||
decision := p.retryPolicy.decideProviderFailure(result.ErrorCode)
|
||||
@@ -1286,8 +1308,9 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
errorMessage = ""
|
||||
scheduleRetry = true
|
||||
retryMaxAttempts = maxAttempts
|
||||
retryStrategy = decision.Strategy
|
||||
} else {
|
||||
state.Status = model.PayoutStatusFailed
|
||||
state.Status = terminalStatusAfterRetryExhausted(decision)
|
||||
state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage)
|
||||
p.clearRetryState(state.OperationRef)
|
||||
}
|
||||
@@ -1301,7 +1324,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
return nil, err
|
||||
}
|
||||
if scheduleRetry {
|
||||
p.scheduleCardTokenPayoutRetry(req, 1, retryMaxAttempts)
|
||||
p.scheduleCardTokenPayoutRetry(req, 1, retryMaxAttempts, retryStrategy)
|
||||
}
|
||||
|
||||
resp := cardTokenPayoutResponseFromState(state, accepted, errorCode, errorMessage)
|
||||
@@ -1470,7 +1493,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
}
|
||||
|
||||
retryScheduled := false
|
||||
if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled {
|
||||
if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled || state.Status == model.PayoutStatusNeedsAttention {
|
||||
decision := p.retryPolicy.decideProviderFailure(state.ProviderCode)
|
||||
attemptsUsed := p.currentDispatchAttempt(operationRef)
|
||||
maxAttempts := p.maxDispatchAttempts()
|
||||
@@ -1488,7 +1511,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
p.logger.Warn("Failed to persist callback retry scheduling state", zap.Error(err))
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
p.scheduleCardPayoutRetry(req, attemptsUsed, maxAttempts)
|
||||
p.scheduleCardPayoutRetry(req, attemptsUsed, maxAttempts, decision.Strategy)
|
||||
retryScheduled = true
|
||||
} else if req := p.loadCardTokenRetryRequest(operationRef); req != nil {
|
||||
state.Status = model.PayoutStatusProcessing
|
||||
@@ -1503,7 +1526,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
p.logger.Warn("Failed to persist callback token retry scheduling state", zap.Error(err))
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
p.scheduleCardTokenPayoutRetry(req, attemptsUsed, maxAttempts)
|
||||
p.scheduleCardTokenPayoutRetry(req, attemptsUsed, maxAttempts, decision.Strategy)
|
||||
retryScheduled = true
|
||||
} else {
|
||||
p.logger.Warn("Retryable callback decline received but no retry request snapshot found",
|
||||
@@ -1514,6 +1537,12 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
)
|
||||
}
|
||||
}
|
||||
if !retryScheduled && decision.Action == payoutFailureActionRetry {
|
||||
state.Status = model.PayoutStatusNeedsAttention
|
||||
}
|
||||
if existing != nil && existing.Status == model.PayoutStatusNeedsAttention {
|
||||
state.Status = model.PayoutStatusNeedsAttention
|
||||
}
|
||||
if !retryScheduled && strings.TrimSpace(state.FailureReason) == "" {
|
||||
state.FailureReason = payoutFailureReason(state.ProviderCode, state.ProviderMessage)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,68 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_AcceptedBodyErrorRemainsWaiting(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
resp := monetix.APIResponse{
|
||||
Status: "error",
|
||||
Code: "3062",
|
||||
Message: "Payment details not received",
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil)
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted payout response")
|
||||
}
|
||||
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING {
|
||||
t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus())
|
||||
}
|
||||
if got := resp.GetErrorCode(); got != "3062" {
|
||||
t.Fatalf("expected response error code %q, got %q", "3062", got)
|
||||
}
|
||||
if got := resp.GetErrorMessage(); got != "Payment details not received" {
|
||||
t.Fatalf("expected response error message, got %q", got)
|
||||
}
|
||||
|
||||
stored, ok := repo.payouts.Get(req.GetPayoutId())
|
||||
if !ok || stored == nil {
|
||||
t.Fatalf("expected payout state stored")
|
||||
}
|
||||
if got := stored.Status; got != model.PayoutStatusWaiting {
|
||||
t.Fatalf("expected stored waiting status, got %v", got)
|
||||
}
|
||||
if got := stored.ProviderCode; got != "3062" {
|
||||
t.Fatalf("expected stored provider code %q, got %q", "3062", got)
|
||||
}
|
||||
if got := stored.ProviderMessage; got != "Payment details not received" {
|
||||
t.Fatalf("expected stored provider message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
@@ -525,7 +587,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
|
||||
n := calls.Add(1)
|
||||
resp := monetix.APIResponse{}
|
||||
if n == 1 {
|
||||
resp.Code = providerCodeDeclineAmountOrFrequencyLimit
|
||||
resp.Code = "10101"
|
||||
resp.Message = "Decline due to amount or frequency limit"
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
@@ -554,7 +616,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.retryDelayFn = func(uint32) time.Duration { return 10 * time.Millisecond }
|
||||
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 10 * time.Millisecond }
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
@@ -581,7 +643,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *testing.T) {
|
||||
func TestCardPayoutProcessor_Submit_ProviderRetryUsesDelayedStrategy(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
@@ -590,12 +652,10 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
var calls atomic.Int32
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
_ = calls.Add(1)
|
||||
resp := monetix.APIResponse{
|
||||
Code: providerCodeDeclineAmountOrFrequencyLimit,
|
||||
Code: "10101",
|
||||
Message: "Decline due to amount or frequency limit",
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
@@ -617,7 +677,159 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
|
||||
|
||||
capturedStrategy := payoutRetryStrategy(0)
|
||||
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
|
||||
capturedStrategy = strategy
|
||||
return time.Hour
|
||||
}
|
||||
|
||||
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("submit returned error: %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted response when retry is scheduled")
|
||||
}
|
||||
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyDelayed {
|
||||
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyDelayed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_StatusRefreshRetryUsesStatusRefreshStrategy(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
resp := monetix.APIResponse{
|
||||
Code: "3061",
|
||||
Message: "Transaction not found",
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
|
||||
capturedStrategy := payoutRetryStrategy(0)
|
||||
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
|
||||
capturedStrategy = strategy
|
||||
return time.Hour
|
||||
}
|
||||
|
||||
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("submit returned error: %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted response when retry is scheduled")
|
||||
}
|
||||
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyStatusRefresh {
|
||||
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyStatusRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_TransportRetryUsesImmediateStrategy(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("transport timeout")
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
|
||||
capturedStrategy := payoutRetryStrategy(0)
|
||||
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
|
||||
capturedStrategy = strategy
|
||||
return time.Hour
|
||||
}
|
||||
|
||||
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("submit returned error: %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted response when retry is scheduled")
|
||||
}
|
||||
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyImmediate {
|
||||
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyImmediate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttention(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
var calls atomic.Int32
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
_ = calls.Add(1)
|
||||
resp := monetix.APIResponse{
|
||||
Code: "10101",
|
||||
Message: "Decline due to amount or frequency limit",
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return time.Millisecond }
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
@@ -631,14 +843,14 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
state, ok := repo.payouts.Get(req.GetPayoutId())
|
||||
if ok && state != nil && state.Status == model.PayoutStatusFailed {
|
||||
if !strings.Contains(state.FailureReason, providerCodeDeclineAmountOrFrequencyLimit) {
|
||||
if ok && state != nil && state.Status == model.PayoutStatusNeedsAttention {
|
||||
if !strings.Contains(state.FailureReason, "10101") {
|
||||
t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timeout waiting for terminal failed status")
|
||||
t.Fatalf("timeout waiting for terminal needs_attention status")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
@@ -647,6 +859,59 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_NonRetryProviderDeclineRemainsFailed(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
resp := monetix.APIResponse{
|
||||
Code: "10003",
|
||||
Message: "Decline by anti-fraud policy",
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("submit returned error: %v", err)
|
||||
}
|
||||
if resp.GetAccepted() {
|
||||
t.Fatalf("expected non-accepted response for non-retryable provider decline")
|
||||
}
|
||||
|
||||
state, ok := repo.payouts.Get(req.GetPayoutId())
|
||||
if !ok || state == nil {
|
||||
t.Fatal("expected stored payout state")
|
||||
}
|
||||
if got, want := state.Status, model.PayoutStatusFailed; got != want {
|
||||
t.Fatalf("unexpected payout status: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
@@ -687,7 +952,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond }
|
||||
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 5 * time.Millisecond }
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
@@ -702,7 +967,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
|
||||
cb.Payment.ID = req.GetPayoutId()
|
||||
cb.Payment.Status = "failed"
|
||||
cb.Operation.Status = "failed"
|
||||
cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit
|
||||
cb.Operation.Code = "10101"
|
||||
cb.Operation.Message = "Decline due to amount or frequency limit"
|
||||
cb.Payment.Sum.Currency = "RUB"
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
|
||||
case model.PayoutStatusFailed:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||
case model.PayoutStatusNeedsAttention:
|
||||
// Connector/gateway proto does not expose needs_attention yet; map it to failed externally.
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||
case model.PayoutStatusCancelled:
|
||||
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestPayoutStatusToProto_NeedsAttentionMapsToFailed(t *testing.T) {
|
||||
if got, want := payoutStatusToProto(model.PayoutStatusNeedsAttention), mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED; got != want {
|
||||
t.Fatalf("unexpected proto status: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
providerCodeDeclineAmountOrFrequencyLimit = "10101"
|
||||
)
|
||||
|
||||
type payoutFailureAction int
|
||||
|
||||
const (
|
||||
@@ -15,46 +13,136 @@ const (
|
||||
payoutFailureActionRetry
|
||||
)
|
||||
|
||||
type payoutRetryStrategy int
|
||||
|
||||
const (
|
||||
payoutRetryStrategyImmediate payoutRetryStrategy = iota + 1
|
||||
payoutRetryStrategyDelayed
|
||||
payoutRetryStrategyStatusRefresh
|
||||
)
|
||||
|
||||
type payoutFailureDecision struct {
|
||||
Action payoutFailureAction
|
||||
Strategy payoutRetryStrategy
|
||||
Reason string
|
||||
}
|
||||
|
||||
type payoutFailurePolicy struct {
|
||||
providerCodeActions map[string]payoutFailureAction
|
||||
providerCodeStrategies map[string]payoutRetryStrategy
|
||||
documentedProviderCodes map[string]struct{}
|
||||
}
|
||||
|
||||
func defaultPayoutFailurePolicy() payoutFailurePolicy {
|
||||
return payoutFailurePolicy{
|
||||
providerCodeActions: map[string]payoutFailureAction{
|
||||
providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry,
|
||||
type retryCodeBucket struct {
|
||||
strategy payoutRetryStrategy
|
||||
retryable bool
|
||||
codes []string
|
||||
}
|
||||
|
||||
var providerRetryOnlyCodeBuckets = []retryCodeBucket{
|
||||
// GTX "repeat request now / temporary issue" style codes.
|
||||
{
|
||||
strategy: payoutRetryStrategyImmediate,
|
||||
retryable: true,
|
||||
codes: []string{
|
||||
// General codes.
|
||||
"104", "108", "301", "320", "601", "602", "603", "3025", "3198",
|
||||
// External card PS codes.
|
||||
"10000", "10100", "10104", "10105", "10107", "10202", "102051", "10301", "105012", "10505", "10601", "10602", "10603",
|
||||
// External alternate PS codes.
|
||||
"20000", "20100", "20104", "20105", "20202", "20301", "20304", "20601", "20602", "20603",
|
||||
},
|
||||
},
|
||||
// GTX "retry later / limits / period restrictions" style codes.
|
||||
{
|
||||
strategy: payoutRetryStrategyDelayed,
|
||||
retryable: true,
|
||||
codes: []string{
|
||||
// General codes.
|
||||
"312", "314", "315", "316", "325", "2466",
|
||||
"3106", "3108", "3109", "3110", "3111", "3112",
|
||||
"3285", "3297", "3298",
|
||||
"3305", "3306", "3307", "3308", "3309", "3310", "3311", "3312", "3313", "3314", "3315", "3316", "3317", "3318", "3319", "3320", "3321", "3322", "3323", "3324", "3325", "3326", "3327", "3328", "3329", "3330", "3331", "3332", "3333", "3334", "3335", "3336", "3337", "3338", "3339", "3340",
|
||||
"3342", "3343", "3344", "3345", "3346", "3347", "3348", "3349", "3350", "3351", "3352", "3353", "3355", "3357",
|
||||
"3407", "3408", "3450", "3451", "3452", "3613",
|
||||
// External card PS codes.
|
||||
"10101", "10109", "10112", "10114", "101012", "101013", "101014",
|
||||
// External alternate PS codes.
|
||||
"20109", "20206", "20505", "201012", "201013", "201014",
|
||||
},
|
||||
},
|
||||
// GTX status refresh/polling conditions.
|
||||
{
|
||||
strategy: payoutRetryStrategyStatusRefresh,
|
||||
retryable: true,
|
||||
codes: []string{
|
||||
"3061", "3062",
|
||||
"9999", "19999", "20802", "29999",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var providerDocumentedNonRetryCodes = buildDocumentedNonRetryCodes(providerDocumentedCodes, providerRetryOnlyCodeBuckets)
|
||||
|
||||
var providerRetryCodeBuckets = func() []retryCodeBucket {
|
||||
buckets := make([]retryCodeBucket, 0, len(providerRetryOnlyCodeBuckets)+1)
|
||||
buckets = append(buckets, providerRetryOnlyCodeBuckets...)
|
||||
buckets = append(buckets, retryCodeBucket{
|
||||
strategy: payoutRetryStrategyImmediate,
|
||||
retryable: false,
|
||||
codes: providerDocumentedNonRetryCodes,
|
||||
})
|
||||
return buckets
|
||||
}()
|
||||
|
||||
func defaultPayoutFailurePolicy() payoutFailurePolicy {
|
||||
strategies := map[string]payoutRetryStrategy{}
|
||||
for _, bucket := range providerRetryCodeBuckets {
|
||||
if !bucket.retryable {
|
||||
continue
|
||||
}
|
||||
registerRetryStrategy(strategies, bucket.strategy, bucket.codes...)
|
||||
}
|
||||
|
||||
return payoutFailurePolicy{
|
||||
providerCodeStrategies: strategies,
|
||||
documentedProviderCodes: newCodeSet(providerDocumentedCodes),
|
||||
}
|
||||
}
|
||||
|
||||
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
|
||||
normalized := strings.TrimSpace(code)
|
||||
normalized := normalizeProviderCode(code)
|
||||
if normalized == "" {
|
||||
return payoutFailureDecision{
|
||||
Action: payoutFailureActionFail,
|
||||
Strategy: payoutRetryStrategyImmediate,
|
||||
Reason: "provider_failure",
|
||||
}
|
||||
}
|
||||
if action, ok := p.providerCodeActions[normalized]; ok {
|
||||
if strategy, ok := p.providerCodeStrategies[normalized]; ok {
|
||||
return payoutFailureDecision{
|
||||
Action: action,
|
||||
Action: payoutFailureActionRetry,
|
||||
Strategy: strategy,
|
||||
Reason: "provider_code_" + normalized,
|
||||
}
|
||||
}
|
||||
if _, ok := p.documentedProviderCodes[normalized]; ok {
|
||||
return payoutFailureDecision{
|
||||
Action: payoutFailureActionFail,
|
||||
Strategy: payoutRetryStrategyImmediate,
|
||||
Reason: "provider_code_" + normalized + "_documented_non_retry",
|
||||
}
|
||||
}
|
||||
return payoutFailureDecision{
|
||||
Action: payoutFailureActionFail,
|
||||
Reason: "provider_code_" + normalized,
|
||||
Strategy: payoutRetryStrategyImmediate,
|
||||
Reason: "provider_code_" + normalized + "_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
|
||||
return payoutFailureDecision{
|
||||
Action: payoutFailureActionRetry,
|
||||
Strategy: payoutRetryStrategyImmediate,
|
||||
Reason: "transport_failure",
|
||||
}
|
||||
}
|
||||
@@ -72,8 +160,40 @@ func payoutFailureReason(code, message string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func retryDelayForAttempt(attempt uint32) int {
|
||||
// Backoff in seconds by attempt number (attempt starts at 1).
|
||||
func retryDelayForAttempt(attempt uint32, strategy payoutRetryStrategy) int {
|
||||
strategy = normalizeRetryStrategy(strategy)
|
||||
|
||||
// Backoff in seconds by strategy and attempt number (attempt starts at 1).
|
||||
if strategy == payoutRetryStrategyStatusRefresh {
|
||||
switch {
|
||||
case attempt <= 1:
|
||||
return 10
|
||||
case attempt == 2:
|
||||
return 20
|
||||
case attempt == 3:
|
||||
return 40
|
||||
case attempt == 4:
|
||||
return 80
|
||||
default:
|
||||
return 160
|
||||
}
|
||||
}
|
||||
|
||||
if strategy == payoutRetryStrategyDelayed {
|
||||
switch {
|
||||
case attempt <= 1:
|
||||
return 30
|
||||
case attempt == 2:
|
||||
return 120
|
||||
case attempt == 3:
|
||||
return 600
|
||||
case attempt == 4:
|
||||
return 1800
|
||||
default:
|
||||
return 7200
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case attempt <= 1:
|
||||
return 5
|
||||
@@ -85,3 +205,86 @@ func retryDelayForAttempt(attempt uint32) int {
|
||||
return 60
|
||||
}
|
||||
}
|
||||
|
||||
func registerRetryStrategy(dst map[string]payoutRetryStrategy, strategy payoutRetryStrategy, codes ...string) {
|
||||
if dst == nil || len(codes) == 0 {
|
||||
return
|
||||
}
|
||||
strategy = normalizeRetryStrategy(strategy)
|
||||
for _, code := range codes {
|
||||
normalized := normalizeProviderCode(code)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
dst[normalized] = strategy
|
||||
}
|
||||
}
|
||||
|
||||
func newCodeSet(codes []string) map[string]struct{} {
|
||||
set := map[string]struct{}{}
|
||||
for _, code := range codes {
|
||||
normalized := normalizeProviderCode(code)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
set[normalized] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func buildDocumentedNonRetryCodes(documented []string, retryBuckets []retryCodeBucket) []string {
|
||||
documentedSet := newCodeSet(documented)
|
||||
retrySet := map[string]struct{}{}
|
||||
for _, bucket := range retryBuckets {
|
||||
for _, code := range bucket.codes {
|
||||
normalized := normalizeProviderCode(code)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
retrySet[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
nonRetry := make([]string, 0, len(documentedSet))
|
||||
for code := range documentedSet {
|
||||
if _, ok := retrySet[code]; ok {
|
||||
continue
|
||||
}
|
||||
nonRetry = append(nonRetry, code)
|
||||
}
|
||||
|
||||
sort.Slice(nonRetry, func(i, j int) bool {
|
||||
left, leftErr := strconv.Atoi(nonRetry[i])
|
||||
right, rightErr := strconv.Atoi(nonRetry[j])
|
||||
if leftErr != nil || rightErr != nil {
|
||||
return nonRetry[i] < nonRetry[j]
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
|
||||
return nonRetry
|
||||
}
|
||||
|
||||
func normalizeProviderCode(code string) string {
|
||||
return strings.TrimSpace(code)
|
||||
}
|
||||
|
||||
func normalizeRetryStrategy(strategy payoutRetryStrategy) payoutRetryStrategy {
|
||||
switch strategy {
|
||||
case payoutRetryStrategyDelayed, payoutRetryStrategyStatusRefresh:
|
||||
return strategy
|
||||
default:
|
||||
return payoutRetryStrategyImmediate
|
||||
}
|
||||
}
|
||||
|
||||
func (s payoutRetryStrategy) String() string {
|
||||
switch normalizeRetryStrategy(s) {
|
||||
case payoutRetryStrategyDelayed:
|
||||
return "delayed"
|
||||
case payoutRetryStrategyStatusRefresh:
|
||||
return "status_refresh"
|
||||
default:
|
||||
return "immediate"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package gateway
|
||||
|
||||
// providerDocumentedCodes is the normalized list of numeric response codes documented in
|
||||
// https://developers.gtxpoint.com/ru/ru_gate_statuses_and_response_codes.html
|
||||
// (all response-code tables).
|
||||
var providerDocumentedCodes = []string{
|
||||
"0",
|
||||
"100",
|
||||
"104",
|
||||
"108",
|
||||
"109",
|
||||
"301",
|
||||
"303",
|
||||
"309",
|
||||
"310",
|
||||
"311",
|
||||
"312",
|
||||
"313",
|
||||
"314",
|
||||
"315",
|
||||
"316",
|
||||
"320",
|
||||
"325",
|
||||
"402",
|
||||
"501",
|
||||
"502",
|
||||
"504",
|
||||
"601",
|
||||
"602",
|
||||
"603",
|
||||
"702",
|
||||
"903",
|
||||
"904",
|
||||
"1337",
|
||||
"1401",
|
||||
"1402",
|
||||
"1403",
|
||||
"1404",
|
||||
"1405",
|
||||
"1406",
|
||||
"1407",
|
||||
"1408",
|
||||
"1409",
|
||||
"1410",
|
||||
"1411",
|
||||
"1412",
|
||||
"1413",
|
||||
"1415",
|
||||
"1416",
|
||||
"1417",
|
||||
"1418",
|
||||
"1419",
|
||||
"1420",
|
||||
"1421",
|
||||
"1422",
|
||||
"1423",
|
||||
"1424",
|
||||
"1425",
|
||||
"1426",
|
||||
"1427",
|
||||
"1428",
|
||||
"1429",
|
||||
"1430",
|
||||
"1431",
|
||||
"1432",
|
||||
"1433",
|
||||
"1434",
|
||||
"1435",
|
||||
"1436",
|
||||
"1437",
|
||||
"1438",
|
||||
"1439",
|
||||
"1441",
|
||||
"1451",
|
||||
"1452",
|
||||
"1453",
|
||||
"1454",
|
||||
"1455",
|
||||
"1456",
|
||||
"1457",
|
||||
"1461",
|
||||
"1462",
|
||||
"1463",
|
||||
"1464",
|
||||
"1499",
|
||||
"2003",
|
||||
"2004",
|
||||
"2005",
|
||||
"2008",
|
||||
"2014",
|
||||
"2061",
|
||||
"2123",
|
||||
"2124",
|
||||
"2154",
|
||||
"2164",
|
||||
"2261",
|
||||
"2426",
|
||||
"2442",
|
||||
"2466",
|
||||
"2541",
|
||||
"2606",
|
||||
"2609",
|
||||
"2610",
|
||||
"2611",
|
||||
"2641",
|
||||
"2642",
|
||||
"2701",
|
||||
"2801",
|
||||
"2881",
|
||||
"2945",
|
||||
"2949",
|
||||
"3001",
|
||||
"3002",
|
||||
"3003",
|
||||
"3004",
|
||||
"3019",
|
||||
"3020",
|
||||
"3021",
|
||||
"3022",
|
||||
"3023",
|
||||
"3024",
|
||||
"3025",
|
||||
"3026",
|
||||
"3027",
|
||||
"3028",
|
||||
"3029",
|
||||
"3030",
|
||||
"3041",
|
||||
"3059",
|
||||
"3060",
|
||||
"3061",
|
||||
"3062",
|
||||
"3081",
|
||||
"3101",
|
||||
"3102",
|
||||
"3103",
|
||||
"3104",
|
||||
"3105",
|
||||
"3106",
|
||||
"3107",
|
||||
"3108",
|
||||
"3109",
|
||||
"3110",
|
||||
"3111",
|
||||
"3112",
|
||||
"3118",
|
||||
"3119",
|
||||
"3120",
|
||||
"3121",
|
||||
"3122",
|
||||
"3123",
|
||||
"3124",
|
||||
"3141",
|
||||
"3161",
|
||||
"3181",
|
||||
"3182",
|
||||
"3183",
|
||||
"3184",
|
||||
"3191",
|
||||
"3192",
|
||||
"3193",
|
||||
"3194",
|
||||
"3195",
|
||||
"3196",
|
||||
"3197",
|
||||
"3198",
|
||||
"3199",
|
||||
"3200",
|
||||
"3201",
|
||||
"3221",
|
||||
"3230",
|
||||
"3241",
|
||||
"3242",
|
||||
"3243",
|
||||
"3244",
|
||||
"3261",
|
||||
"3262",
|
||||
"3281",
|
||||
"3283",
|
||||
"3284",
|
||||
"3285",
|
||||
"3286",
|
||||
"3287",
|
||||
"3288",
|
||||
"3289",
|
||||
"3291",
|
||||
"3292",
|
||||
"3293",
|
||||
"3297",
|
||||
"3298",
|
||||
"3299",
|
||||
"3301",
|
||||
"3303",
|
||||
"3304",
|
||||
"3305",
|
||||
"3306",
|
||||
"3307",
|
||||
"3308",
|
||||
"3309",
|
||||
"3310",
|
||||
"3311",
|
||||
"3312",
|
||||
"3313",
|
||||
"3314",
|
||||
"3315",
|
||||
"3316",
|
||||
"3317",
|
||||
"3318",
|
||||
"3319",
|
||||
"3320",
|
||||
"3321",
|
||||
"3322",
|
||||
"3323",
|
||||
"3324",
|
||||
"3325",
|
||||
"3326",
|
||||
"3327",
|
||||
"3328",
|
||||
"3329",
|
||||
"3330",
|
||||
"3331",
|
||||
"3332",
|
||||
"3333",
|
||||
"3334",
|
||||
"3335",
|
||||
"3336",
|
||||
"3337",
|
||||
"3338",
|
||||
"3339",
|
||||
"3340",
|
||||
"3341",
|
||||
"3342",
|
||||
"3343",
|
||||
"3344",
|
||||
"3345",
|
||||
"3346",
|
||||
"3347",
|
||||
"3348",
|
||||
"3349",
|
||||
"3350",
|
||||
"3351",
|
||||
"3352",
|
||||
"3353",
|
||||
"3355",
|
||||
"3356",
|
||||
"3357",
|
||||
"3358",
|
||||
"3360",
|
||||
"3400",
|
||||
"3402",
|
||||
"3403",
|
||||
"3404",
|
||||
"3405",
|
||||
"3406",
|
||||
"3407",
|
||||
"3408",
|
||||
"3409",
|
||||
"3410",
|
||||
"3411",
|
||||
"3412",
|
||||
"3413",
|
||||
"3414",
|
||||
"3415",
|
||||
"3416",
|
||||
"3417",
|
||||
"3418",
|
||||
"3419",
|
||||
"3431",
|
||||
"3432",
|
||||
"3433",
|
||||
"3434",
|
||||
"3435",
|
||||
"3436",
|
||||
"3437",
|
||||
"3438",
|
||||
"3439",
|
||||
"3450",
|
||||
"3451",
|
||||
"3452",
|
||||
"3470",
|
||||
"3471",
|
||||
"3472",
|
||||
"3480",
|
||||
"3485",
|
||||
"3490",
|
||||
"3491",
|
||||
"3609",
|
||||
"3610",
|
||||
"3611",
|
||||
"3612",
|
||||
"3613",
|
||||
"9999",
|
||||
"10000",
|
||||
"10100",
|
||||
"10101",
|
||||
"10102",
|
||||
"10103",
|
||||
"10104",
|
||||
"10105",
|
||||
"10106",
|
||||
"10107",
|
||||
"10108",
|
||||
"10109",
|
||||
"10110",
|
||||
"10111",
|
||||
"10112",
|
||||
"10113",
|
||||
"10114",
|
||||
"10201",
|
||||
"10202",
|
||||
"10203",
|
||||
"10204",
|
||||
"10205",
|
||||
"10301",
|
||||
"10401",
|
||||
"10402",
|
||||
"10403",
|
||||
"10404",
|
||||
"10405",
|
||||
"10501",
|
||||
"10502",
|
||||
"10503",
|
||||
"10504",
|
||||
"10505",
|
||||
"10601",
|
||||
"10602",
|
||||
"10603",
|
||||
"10701",
|
||||
"10702",
|
||||
"10703",
|
||||
"10704",
|
||||
"10705",
|
||||
"10706",
|
||||
"10707",
|
||||
"10708",
|
||||
"10709",
|
||||
"10722",
|
||||
"10801",
|
||||
"10805",
|
||||
"10806",
|
||||
"10807",
|
||||
"10811",
|
||||
"10812",
|
||||
"19999",
|
||||
"20000",
|
||||
"20100",
|
||||
"20101",
|
||||
"20102",
|
||||
"20103",
|
||||
"20104",
|
||||
"20105",
|
||||
"20106",
|
||||
"20107",
|
||||
"20109",
|
||||
"20201",
|
||||
"20202",
|
||||
"20203",
|
||||
"20204",
|
||||
"20205",
|
||||
"20206",
|
||||
"20301",
|
||||
"20302",
|
||||
"20303",
|
||||
"20304",
|
||||
"20401",
|
||||
"20402",
|
||||
"20501",
|
||||
"20502",
|
||||
"20503",
|
||||
"20504",
|
||||
"20505",
|
||||
"20601",
|
||||
"20602",
|
||||
"20603",
|
||||
"20604",
|
||||
"20701",
|
||||
"20702",
|
||||
"20703",
|
||||
"20705",
|
||||
"20706",
|
||||
"20801",
|
||||
"20802",
|
||||
"29999",
|
||||
"30000",
|
||||
"30100",
|
||||
"30301",
|
||||
"30302",
|
||||
"30303",
|
||||
"30401",
|
||||
"101011",
|
||||
"101012",
|
||||
"101013",
|
||||
"101014",
|
||||
"101021",
|
||||
"102051",
|
||||
"105012",
|
||||
"108010",
|
||||
"201011",
|
||||
"201012",
|
||||
"201013",
|
||||
"201014",
|
||||
}
|
||||
@@ -2,6 +2,29 @@ package gateway
|
||||
|
||||
import "testing"
|
||||
|
||||
func retryBucketCodeSet() map[string]struct{} {
|
||||
set := map[string]struct{}{}
|
||||
for _, bucket := range providerRetryCodeBuckets {
|
||||
if !bucket.retryable {
|
||||
continue
|
||||
}
|
||||
for _, code := range bucket.codes {
|
||||
set[normalizeProviderCode(code)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func allBucketCodeSet() map[string]struct{} {
|
||||
set := map[string]struct{}{}
|
||||
for _, bucket := range providerRetryCodeBuckets {
|
||||
for _, code := range bucket.codes {
|
||||
set[normalizeProviderCode(code)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
|
||||
policy := defaultPayoutFailurePolicy()
|
||||
|
||||
@@ -9,21 +32,43 @@ func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
|
||||
name string
|
||||
code string
|
||||
action payoutFailureAction
|
||||
strategy payoutRetryStrategy
|
||||
}{
|
||||
{
|
||||
name: "retryable provider limit code",
|
||||
code: providerCodeDeclineAmountOrFrequencyLimit,
|
||||
name: "immediate retry strategy code",
|
||||
code: "10000",
|
||||
action: payoutFailureActionRetry,
|
||||
strategy: payoutRetryStrategyImmediate,
|
||||
},
|
||||
{
|
||||
name: "delayed retry strategy code",
|
||||
code: "10101",
|
||||
action: payoutFailureActionRetry,
|
||||
strategy: payoutRetryStrategyDelayed,
|
||||
},
|
||||
{
|
||||
name: "status refresh retry strategy code",
|
||||
code: "3061",
|
||||
action: payoutFailureActionRetry,
|
||||
strategy: payoutRetryStrategyStatusRefresh,
|
||||
},
|
||||
{
|
||||
name: "status refresh retry strategy payment details missing code",
|
||||
code: "3062",
|
||||
action: payoutFailureActionRetry,
|
||||
strategy: payoutRetryStrategyStatusRefresh,
|
||||
},
|
||||
{
|
||||
name: "unknown provider code",
|
||||
code: "99999",
|
||||
action: payoutFailureActionFail,
|
||||
strategy: payoutRetryStrategyImmediate,
|
||||
},
|
||||
{
|
||||
name: "empty provider code",
|
||||
code: "",
|
||||
action: payoutFailureActionFail,
|
||||
strategy: payoutRetryStrategyImmediate,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -35,6 +80,204 @@ func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
|
||||
if got.Action != tc.action {
|
||||
t.Fatalf("action mismatch: got=%v want=%v", got.Action, tc.action)
|
||||
}
|
||||
if got.Strategy != tc.strategy {
|
||||
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, tc.strategy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayoutFailurePolicy_DocumentRetryCoverage(t *testing.T) {
|
||||
policy := defaultPayoutFailurePolicy()
|
||||
|
||||
// Parsed from GTX response-code tables (General, RCS, external card PS, external alternate PS, merchant system):
|
||||
// 32 immediate + 84 delayed + 6 status-refresh = 122 retryable codes.
|
||||
if got, want := len(policy.providerCodeStrategies), 122; got != want {
|
||||
t.Fatalf("retry catalog size mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := len(policy.documentedProviderCodes), 395; got != want {
|
||||
t.Fatalf("documented code catalog size mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
code string
|
||||
strategy payoutRetryStrategy
|
||||
}{
|
||||
// Immediate retry examples.
|
||||
{code: "3025", strategy: payoutRetryStrategyImmediate},
|
||||
{code: "3198", strategy: payoutRetryStrategyImmediate},
|
||||
{code: "105012", strategy: payoutRetryStrategyImmediate},
|
||||
{code: "20603", strategy: payoutRetryStrategyImmediate},
|
||||
// Delayed retry examples, including previously missed high-range limits.
|
||||
{code: "3106", strategy: payoutRetryStrategyDelayed},
|
||||
{code: "3337", strategy: payoutRetryStrategyDelayed},
|
||||
{code: "3407", strategy: payoutRetryStrategyDelayed},
|
||||
{code: "3613", strategy: payoutRetryStrategyDelayed},
|
||||
{code: "201014", strategy: payoutRetryStrategyDelayed},
|
||||
// Status refresh examples.
|
||||
{code: "3061", strategy: payoutRetryStrategyStatusRefresh},
|
||||
{code: "3062", strategy: payoutRetryStrategyStatusRefresh},
|
||||
{code: "20802", strategy: payoutRetryStrategyStatusRefresh},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.code, func(t *testing.T) {
|
||||
t.Helper()
|
||||
got := policy.decideProviderFailure(tc.code)
|
||||
if got.Action != payoutFailureActionRetry {
|
||||
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionRetry)
|
||||
}
|
||||
if got.Strategy != tc.strategy {
|
||||
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, tc.strategy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayoutFailurePolicy_DocumentedCodeCoverageByPolicy(t *testing.T) {
|
||||
policy := defaultPayoutFailurePolicy()
|
||||
retrySet := retryBucketCodeSet()
|
||||
|
||||
if got, want := len(retrySet), len(policy.providerCodeStrategies); got != want {
|
||||
t.Fatalf("retry set size mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
|
||||
documentedNonRetry := 0
|
||||
for _, code := range providerDocumentedCodes {
|
||||
code := normalizeProviderCode(code)
|
||||
decision := policy.decideProviderFailure(code)
|
||||
|
||||
if _, isRetry := retrySet[code]; isRetry {
|
||||
if decision.Action != payoutFailureActionRetry {
|
||||
t.Fatalf("documented retry code %s unexpectedly classified as non-retry", code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
documentedNonRetry++
|
||||
if decision.Action != payoutFailureActionFail {
|
||||
t.Fatalf("documented non-retry code %s unexpectedly classified as retry", code)
|
||||
}
|
||||
if decision.Reason != "provider_code_"+code+"_documented_non_retry" {
|
||||
t.Fatalf("documented non-retry code %s has unexpected reason: %q", code, decision.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
if got, want := len(retrySet)+documentedNonRetry, len(providerDocumentedCodes); got != want {
|
||||
t.Fatalf("coverage mismatch: retry(%d)+non_retry(%d) != documented(%d)", len(retrySet), documentedNonRetry, len(providerDocumentedCodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRetryCodeBuckets_DoNotOverlapAndCoverDocumentedCodes(t *testing.T) {
|
||||
seen := map[string]int{}
|
||||
for bucketIdx, bucket := range providerRetryCodeBuckets {
|
||||
for _, rawCode := range bucket.codes {
|
||||
code := normalizeProviderCode(rawCode)
|
||||
if code == "" {
|
||||
t.Fatalf("empty code in bucket #%d", bucketIdx)
|
||||
}
|
||||
if prevIdx, ok := seen[code]; ok {
|
||||
t.Fatalf("overlap detected for code %s between bucket #%d and bucket #%d", code, prevIdx, bucketIdx)
|
||||
}
|
||||
seen[code] = bucketIdx
|
||||
}
|
||||
}
|
||||
|
||||
allBucketCodes := allBucketCodeSet()
|
||||
documented := newCodeSet(providerDocumentedCodes)
|
||||
if got, want := len(allBucketCodes), len(documented); got != want {
|
||||
t.Fatalf("union size mismatch: buckets=%d documented=%d", got, want)
|
||||
}
|
||||
|
||||
for code := range documented {
|
||||
if _, ok := allBucketCodes[code]; !ok {
|
||||
t.Fatalf("documented code %s is missing from providerRetryCodeBuckets union", code)
|
||||
}
|
||||
}
|
||||
for code := range allBucketCodes {
|
||||
if _, ok := documented[code]; !ok {
|
||||
t.Fatalf("bucket code %s is not present in documented code list", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayoutFailurePolicy_DecideProviderFailure_DocumentedNonRetryCode(t *testing.T) {
|
||||
policy := defaultPayoutFailurePolicy()
|
||||
|
||||
got := policy.decideProviderFailure("3059")
|
||||
if got.Action != payoutFailureActionFail {
|
||||
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionFail)
|
||||
}
|
||||
if got.Strategy != payoutRetryStrategyImmediate {
|
||||
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
|
||||
}
|
||||
if got.Reason != "provider_code_3059_documented_non_retry" {
|
||||
t.Fatalf("reason mismatch: got=%q", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayoutFailurePolicy_DecideProviderFailure_UnknownCode(t *testing.T) {
|
||||
policy := defaultPayoutFailurePolicy()
|
||||
|
||||
got := policy.decideProviderFailure("99999")
|
||||
if got.Action != payoutFailureActionFail {
|
||||
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionFail)
|
||||
}
|
||||
if got.Strategy != payoutRetryStrategyImmediate {
|
||||
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
|
||||
}
|
||||
if got.Reason != "provider_code_99999_unknown" {
|
||||
t.Fatalf("reason mismatch: got=%q", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayoutFailurePolicy_DecideTransportFailure(t *testing.T) {
|
||||
policy := defaultPayoutFailurePolicy()
|
||||
|
||||
got := policy.decideTransportFailure()
|
||||
if got.Action != payoutFailureActionRetry {
|
||||
t.Fatalf("action mismatch: got=%v want=%v", got.Action, payoutFailureActionRetry)
|
||||
}
|
||||
if got.Strategy != payoutRetryStrategyImmediate {
|
||||
t.Fatalf("strategy mismatch: got=%v want=%v", got.Strategy, payoutRetryStrategyImmediate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelayForAttempt_ByStrategy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
attempt uint32
|
||||
strategy payoutRetryStrategy
|
||||
wantDelay int
|
||||
}{
|
||||
{
|
||||
name: "immediate first attempt",
|
||||
attempt: 1,
|
||||
strategy: payoutRetryStrategyImmediate,
|
||||
wantDelay: 5,
|
||||
},
|
||||
{
|
||||
name: "delayed second attempt",
|
||||
attempt: 2,
|
||||
strategy: payoutRetryStrategyDelayed,
|
||||
wantDelay: 120,
|
||||
},
|
||||
{
|
||||
name: "status refresh third attempt",
|
||||
attempt: 3,
|
||||
strategy: payoutRetryStrategyStatusRefresh,
|
||||
wantDelay: 40,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Helper()
|
||||
if got := retryDelayForAttempt(tc.attempt, tc.strategy); got != tc.wantDelay {
|
||||
t.Fatalf("delay mismatch: got=%d want=%d", got, tc.wantDelay)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func isFinalStatus(t *model.CardPayout) bool {
|
||||
|
||||
func isFinalPayoutStatus(status model.PayoutStatus) bool {
|
||||
switch status {
|
||||
case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
|
||||
case model.PayoutStatusFailed, model.PayoutStatusNeedsAttention, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -35,6 +35,8 @@ func toOpStatus(t *model.CardPayout) (rail.OperationResult, error) {
|
||||
switch t.Status {
|
||||
case model.PayoutStatusFailed:
|
||||
return rail.OperationResultFailed, nil
|
||||
case model.PayoutStatusNeedsAttention:
|
||||
return rail.OperationResultFailed, nil
|
||||
case model.PayoutStatusSuccess:
|
||||
return rail.OperationResultSuccess, nil
|
||||
case model.PayoutStatusCancelled:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
)
|
||||
|
||||
func TestIsFinalPayoutStatus_NeedsAttentionIsFinal(t *testing.T) {
|
||||
if !isFinalPayoutStatus(model.PayoutStatusNeedsAttention) {
|
||||
t.Fatal("expected needs_attention to be final")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToOpStatus_NeedsAttentionMapsToFailed(t *testing.T) {
|
||||
status, err := toOpStatus(&model.CardPayout{Status: model.PayoutStatusNeedsAttention})
|
||||
if err != nil {
|
||||
t.Fatalf("toOpStatus returned error: %v", err)
|
||||
}
|
||||
if status != rail.OperationResultFailed {
|
||||
t.Fatalf("unexpected operation result: got=%q want=%q", status, rail.OperationResultFailed)
|
||||
}
|
||||
}
|
||||
@@ -166,25 +166,16 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
if apiResp.Operation.RequestID != "" {
|
||||
result.ProviderRequestID = apiResp.Operation.RequestID
|
||||
} else if apiResp.RequestID != "" {
|
||||
result.ProviderRequestID = apiResp.RequestID
|
||||
}
|
||||
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
|
||||
if result.ProviderStatus == "" {
|
||||
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
|
||||
}
|
||||
result.ProviderRequestID = providerRequestID(apiResp)
|
||||
result.ProviderStatus = providerStatus(apiResp)
|
||||
|
||||
if !result.Accepted {
|
||||
result.ErrorCode = apiResp.Code
|
||||
if result.ErrorCode == "" {
|
||||
errorCode, errorMessage := providerError(apiResp)
|
||||
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
|
||||
result.ErrorCode = errorCode
|
||||
if !result.Accepted && result.ErrorCode == "" {
|
||||
result.ErrorCode = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
result.ErrorMessage = apiResp.Message
|
||||
if result.ErrorMessage == "" {
|
||||
result.ErrorMessage = apiResp.Operation.Message
|
||||
}
|
||||
result.ErrorMessage = errorMessage
|
||||
}
|
||||
|
||||
c.logger.Info("Monetix tokenization response",
|
||||
@@ -288,25 +279,16 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
|
||||
}
|
||||
}
|
||||
|
||||
if apiResp.Operation.RequestID != "" {
|
||||
result.ProviderRequestID = apiResp.Operation.RequestID
|
||||
} else if apiResp.RequestID != "" {
|
||||
result.ProviderRequestID = apiResp.RequestID
|
||||
}
|
||||
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
|
||||
if result.ProviderStatus == "" {
|
||||
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
|
||||
}
|
||||
result.ProviderRequestID = providerRequestID(apiResp)
|
||||
result.ProviderStatus = providerStatus(apiResp)
|
||||
|
||||
if !result.Accepted {
|
||||
result.ErrorCode = apiResp.Code
|
||||
if result.ErrorCode == "" {
|
||||
errorCode, errorMessage := providerError(apiResp)
|
||||
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
|
||||
result.ErrorCode = errorCode
|
||||
if !result.Accepted && result.ErrorCode == "" {
|
||||
result.ErrorCode = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
result.ErrorMessage = apiResp.Message
|
||||
if result.ErrorMessage == "" {
|
||||
result.ErrorMessage = apiResp.Operation.Message
|
||||
}
|
||||
result.ErrorMessage = errorMessage
|
||||
}
|
||||
|
||||
if responseLog != nil {
|
||||
@@ -324,6 +306,32 @@ func normalizeExpiryYear(year int) int {
|
||||
return year
|
||||
}
|
||||
|
||||
func providerRequestID(resp APIResponse) string {
|
||||
return firstNonEmpty(resp.Operation.RequestID, resp.RequestID)
|
||||
}
|
||||
|
||||
func providerStatus(resp APIResponse) string {
|
||||
return firstNonEmpty(resp.Status, resp.Operation.Status)
|
||||
}
|
||||
|
||||
func providerError(resp APIResponse) (code, message string) {
|
||||
return firstNonEmpty(resp.Code, resp.Operation.Code), firstNonEmpty(resp.Message, resp.Operation.Message)
|
||||
}
|
||||
|
||||
func isProviderStatusError(status string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(status), "error")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeRequestExpiryYear(req any) {
|
||||
switch r := req.(type) {
|
||||
case *CardPayoutRequest:
|
||||
|
||||
@@ -175,6 +175,99 @@ func TestSendCardPayout_HTTPError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCardPayout_HTTPAcceptedBodyErrorStillAccepted(t *testing.T) {
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := `{"status":"error","code":"3062","message":"Payment details not received"}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
}
|
||||
client := NewClient(cfg, httpClient, zap.NewNop())
|
||||
|
||||
req := CardPayoutRequest{
|
||||
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
||||
Customer: Customer{
|
||||
ID: "cust-1",
|
||||
FirstName: "Jane",
|
||||
LastName: "Doe",
|
||||
IP: "203.0.113.10",
|
||||
},
|
||||
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
||||
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
||||
}
|
||||
|
||||
result, err := client.CreateCardPayout(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !result.Accepted {
|
||||
t.Fatalf("expected accepted response")
|
||||
}
|
||||
if result.ProviderStatus != "error" {
|
||||
t.Fatalf("expected provider status error, got %q", result.ProviderStatus)
|
||||
}
|
||||
if result.ErrorCode != "3062" {
|
||||
t.Fatalf("expected error code %q, got %q", "3062", result.ErrorCode)
|
||||
}
|
||||
if result.ErrorMessage != "Payment details not received" {
|
||||
t.Fatalf("expected error message, got %q", result.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCardPayout_HTTPErrorFallsBackToOperationCode(t *testing.T) {
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := `{"operation":{"code":"3061","message":"Transaction not found"}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
}
|
||||
client := NewClient(cfg, httpClient, zap.NewNop())
|
||||
|
||||
req := CardPayoutRequest{
|
||||
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
||||
Customer: Customer{
|
||||
ID: "cust-1",
|
||||
FirstName: "Jane",
|
||||
LastName: "Doe",
|
||||
IP: "203.0.113.10",
|
||||
},
|
||||
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
||||
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
||||
}
|
||||
|
||||
result, err := client.CreateCardPayout(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if result.Accepted {
|
||||
t.Fatalf("expected rejected response")
|
||||
}
|
||||
if result.ErrorCode != "3061" {
|
||||
t.Fatalf("expected error code %q, got %q", "3061", result.ErrorCode)
|
||||
}
|
||||
if result.ErrorMessage != "Transaction not found" {
|
||||
t.Fatalf("expected error message, got %q", result.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
type errorReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ const (
|
||||
PayoutStatusSuccess PayoutStatus = "success" // final success
|
||||
PayoutStatusFailed PayoutStatus = "failed" // final failure
|
||||
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
|
||||
PayoutStatusNeedsAttention PayoutStatus = "needs_attention" // final, manual review required
|
||||
)
|
||||
|
||||
@@ -45,9 +45,6 @@ gateway:
|
||||
treasury:
|
||||
execution_delay: 60s
|
||||
poll_interval: 60s
|
||||
telegram:
|
||||
allowed_chats: []
|
||||
users: []
|
||||
ledger:
|
||||
timeout: 5s
|
||||
limits:
|
||||
|
||||
@@ -50,10 +50,3 @@ treasury:
|
||||
limits:
|
||||
max_amount_per_operation: ""
|
||||
max_daily_amount: ""
|
||||
telegram:
|
||||
allowed_chats: []
|
||||
users:
|
||||
- telegram_user_id: "8273799472"
|
||||
ledger_account: "6972c738949b91ea0395e5fb"
|
||||
- telegram_user_id: "8273507566"
|
||||
ledger_account: "6995d6c118bca1d8baa5f2be"
|
||||
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.79.1
|
||||
google.golang.org/grpc v1.79.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -210,8 +210,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -3,7 +3,6 @@ package serverimp
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/internal/service/gateway"
|
||||
@@ -38,8 +37,6 @@ type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Gateway gatewayConfig `yaml:"gateway"`
|
||||
Treasury treasuryConfig `yaml:"treasury"`
|
||||
Ledger ledgerConfig `yaml:"ledger"` // deprecated: use treasury.ledger
|
||||
Telegram telegramConfig `yaml:"telegram"` // deprecated: use treasury.telegram
|
||||
}
|
||||
|
||||
type gatewayConfig struct {
|
||||
@@ -50,20 +47,9 @@ type gatewayConfig struct {
|
||||
SuccessReaction string `yaml:"success_reaction"`
|
||||
}
|
||||
|
||||
type telegramConfig struct {
|
||||
AllowedChats []string `yaml:"allowed_chats"`
|
||||
Users []telegramUserConfig `yaml:"users"`
|
||||
}
|
||||
|
||||
type telegramUserConfig struct {
|
||||
TelegramUserID string `yaml:"telegram_user_id"`
|
||||
LedgerAccount string `yaml:"ledger_account"`
|
||||
}
|
||||
|
||||
type treasuryConfig struct {
|
||||
ExecutionDelay time.Duration `yaml:"execution_delay"`
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
Telegram telegramConfig `yaml:"telegram"`
|
||||
Ledger ledgerConfig `yaml:"ledger"`
|
||||
Limits treasuryLimitsConfig `yaml:"limits"`
|
||||
}
|
||||
@@ -145,8 +131,6 @@ func (i *Imp) Start() error {
|
||||
if cfg.Messaging != nil {
|
||||
msgSettings = cfg.Messaging.Settings
|
||||
}
|
||||
treasuryTelegram := treasuryTelegramConfig(cfg, i.logger)
|
||||
treasuryLedger := treasuryLedgerConfig(cfg, i.logger)
|
||||
gwCfg := gateway.Config{
|
||||
Rail: cfg.Gateway.Rail,
|
||||
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
|
||||
@@ -159,12 +143,8 @@ func (i *Imp) Start() error {
|
||||
Treasury: gateway.TreasuryConfig{
|
||||
ExecutionDelay: cfg.Treasury.ExecutionDelay,
|
||||
PollInterval: cfg.Treasury.PollInterval,
|
||||
Telegram: gateway.TelegramConfig{
|
||||
AllowedChats: treasuryTelegram.AllowedChats,
|
||||
Users: telegramUsers(treasuryTelegram.Users),
|
||||
},
|
||||
Ledger: gateway.LedgerConfig{
|
||||
Timeout: treasuryLedger.Timeout,
|
||||
Timeout: cfg.Treasury.Ledger.Timeout,
|
||||
},
|
||||
Limits: gateway.TreasuryLimitsConfig{
|
||||
MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation,
|
||||
@@ -228,46 +208,3 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func telegramUsers(input []telegramUserConfig) []gateway.TelegramUserBinding {
|
||||
result := make([]gateway.TelegramUserBinding, 0, len(input))
|
||||
for _, next := range input {
|
||||
result = append(result, gateway.TelegramUserBinding{
|
||||
TelegramUserID: strings.TrimSpace(next.TelegramUserID),
|
||||
LedgerAccount: strings.TrimSpace(next.LedgerAccount),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func treasuryTelegramConfig(cfg *config, logger mlogger.Logger) telegramConfig {
|
||||
if cfg == nil {
|
||||
return telegramConfig{}
|
||||
}
|
||||
if len(cfg.Treasury.Telegram.Users) > 0 || len(cfg.Treasury.Telegram.AllowedChats) > 0 {
|
||||
return cfg.Treasury.Telegram
|
||||
}
|
||||
if len(cfg.Telegram.Users) > 0 || len(cfg.Telegram.AllowedChats) > 0 {
|
||||
if logger != nil {
|
||||
logger.Warn("Deprecated config path used: telegram.*; move these settings to treasury.telegram.*")
|
||||
}
|
||||
return cfg.Telegram
|
||||
}
|
||||
return cfg.Treasury.Telegram
|
||||
}
|
||||
|
||||
func treasuryLedgerConfig(cfg *config, logger mlogger.Logger) ledgerConfig {
|
||||
if cfg == nil {
|
||||
return ledgerConfig{}
|
||||
}
|
||||
if cfg.Treasury.Ledger.Timeout > 0 {
|
||||
return cfg.Treasury.Ledger
|
||||
}
|
||||
if cfg.Ledger.Timeout > 0 {
|
||||
if logger != nil {
|
||||
logger.Warn("Deprecated config path used: ledger.*; move these settings to treasury.ledger.*")
|
||||
}
|
||||
return cfg.Ledger
|
||||
}
|
||||
return cfg.Treasury.Ledger
|
||||
}
|
||||
|
||||
@@ -136,13 +136,22 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||
}
|
||||
transfer := resp.GetTransfer()
|
||||
operationID := strings.TrimSpace(transfer.GetOperationRef())
|
||||
if operationID == "" {
|
||||
s.logger.Warn("Submit operation transfer response missing operation_ref", append(logFields,
|
||||
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
|
||||
)...)
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{
|
||||
Error: connectorError(connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE, "submit_operation: operation_ref is missing in transfer response", op, ""),
|
||||
}}, nil
|
||||
}
|
||||
s.logger.Info("Submit operation transfer submitted", append(logFields,
|
||||
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
|
||||
zap.String("status", transfer.GetStatus().String()),
|
||||
)...)
|
||||
return &connectorv1.SubmitOperationResponse{
|
||||
Receipt: &connectorv1.OperationReceipt{
|
||||
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
OperationId: operationID,
|
||||
Status: transferStatusToOperation(transfer.GetStatus()),
|
||||
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
},
|
||||
@@ -224,7 +233,7 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
|
||||
return nil
|
||||
}
|
||||
op := &connectorv1.Operation{
|
||||
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
OperationId: strings.TrimSpace(transfer.GetOperationRef()),
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
Status: transferStatusToOperation(transfer.GetStatus()),
|
||||
Money: transfer.GetRequestedAmount(),
|
||||
|
||||
119
api/gateway/tgsettle/internal/service/gateway/connector_test.go
Normal file
119
api/gateway/tgsettle/internal/service/gateway/connector_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestSubmitOperation_UsesOperationRefAsOperationID(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
svc.chatID = "1"
|
||||
|
||||
req := &connectorv1.SubmitOperationRequest{
|
||||
Operation: &connectorv1.Operation{
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
IdempotencyKey: "idem-settlement-1",
|
||||
OperationRef: "payment-1:hop_2_settlement_fx_convert",
|
||||
IntentRef: "intent-1",
|
||||
Money: &moneyv1.Money{Amount: "1.00", Currency: "USDT"},
|
||||
From: &connectorv1.OperationParty{
|
||||
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: tgsettleConnectorID,
|
||||
AccountId: "wallet-src",
|
||||
}},
|
||||
},
|
||||
To: &connectorv1.OperationParty{
|
||||
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: tgsettleConnectorID,
|
||||
AccountId: "wallet-dst",
|
||||
}},
|
||||
},
|
||||
Params: structFromMap(map[string]interface{}{
|
||||
"payment_ref": "payment-1",
|
||||
"organization_ref": "org-1",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.SubmitOperation(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitOperation returned error: %v", err)
|
||||
}
|
||||
if resp.GetReceipt() == nil {
|
||||
t.Fatal("expected receipt")
|
||||
}
|
||||
if got := resp.GetReceipt().GetError(); got != nil {
|
||||
t.Fatalf("expected no connector error, got: %v", got)
|
||||
}
|
||||
if got, want := resp.GetReceipt().GetOperationId(), "payment-1:hop_2_settlement_fx_convert"; got != want {
|
||||
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resp.GetReceipt().GetProviderRef(), "idem-settlement-1"; got != want {
|
||||
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperation_UsesOperationRefIdentity(t *testing.T) {
|
||||
svc, repo, _ := newTestService(t)
|
||||
|
||||
record := &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-settlement-2",
|
||||
OperationRef: "payment-2:hop_2_settlement_fx_convert",
|
||||
PaymentIntentID: "pi-2",
|
||||
PaymentRef: "payment-2",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
|
||||
Status: storagemodel.PaymentStatusSuccess,
|
||||
}
|
||||
if err := repo.payments.Upsert(context.Background(), record); err != nil {
|
||||
t.Fatalf("failed to seed payment record: %v", err)
|
||||
}
|
||||
|
||||
resp, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
|
||||
OperationId: "payment-2:hop_2_settlement_fx_convert",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetOperation returned error: %v", err)
|
||||
}
|
||||
if resp.GetOperation() == nil {
|
||||
t.Fatal("expected operation")
|
||||
}
|
||||
if got, want := resp.GetOperation().GetOperationId(), "payment-2:hop_2_settlement_fx_convert"; got != want {
|
||||
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resp.GetOperation().GetProviderRef(), "idem-settlement-2"; got != want {
|
||||
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperation_DoesNotResolveByIdempotencyKey(t *testing.T) {
|
||||
svc, repo, _ := newTestService(t)
|
||||
|
||||
record := &storagemodel.PaymentRecord{
|
||||
IdempotencyKey: "idem-settlement-3",
|
||||
OperationRef: "payment-3:hop_2_settlement_fx_convert",
|
||||
PaymentIntentID: "pi-3",
|
||||
PaymentRef: "payment-3",
|
||||
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
|
||||
Status: storagemodel.PaymentStatusSuccess,
|
||||
}
|
||||
if err := repo.payments.Upsert(context.Background(), record); err != nil {
|
||||
t.Fatalf("failed to seed payment record: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
|
||||
OperationId: "idem-settlement-3",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
if status.Code(err) != codes.NotFound {
|
||||
t.Fatalf("unexpected error code: got=%s want=%s", status.Code(err), codes.NotFound)
|
||||
}
|
||||
}
|
||||
@@ -68,20 +68,9 @@ type Config struct {
|
||||
Treasury TreasuryConfig
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
AllowedChats []string
|
||||
Users []TelegramUserBinding
|
||||
}
|
||||
|
||||
type TelegramUserBinding struct {
|
||||
TelegramUserID string
|
||||
LedgerAccount string
|
||||
}
|
||||
|
||||
type TreasuryConfig struct {
|
||||
ExecutionDelay time.Duration
|
||||
PollInterval time.Duration
|
||||
Telegram TelegramConfig
|
||||
Ledger LedgerConfig
|
||||
Limits TreasuryLimitsConfig
|
||||
}
|
||||
@@ -181,39 +170,13 @@ func (s *Service) Shutdown() {
|
||||
}
|
||||
|
||||
func (s *Service) startTreasuryModule() {
|
||||
if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil {
|
||||
if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil || s.repo.TreasuryTelegramUsers() == nil {
|
||||
return
|
||||
}
|
||||
if s.cfg.DiscoveryRegistry == nil {
|
||||
s.logger.Warn("Treasury module disabled: discovery registry is unavailable")
|
||||
return
|
||||
}
|
||||
configuredUsers := s.cfg.Treasury.Telegram.Users
|
||||
if len(configuredUsers) == 0 {
|
||||
return
|
||||
}
|
||||
users := make([]treasurysvc.UserBinding, 0, len(configuredUsers))
|
||||
configuredUserIDs := make([]string, 0, len(configuredUsers))
|
||||
for _, binding := range configuredUsers {
|
||||
userID := strings.TrimSpace(binding.TelegramUserID)
|
||||
accountID := strings.TrimSpace(binding.LedgerAccount)
|
||||
if userID != "" {
|
||||
configuredUserIDs = append(configuredUserIDs, userID)
|
||||
}
|
||||
if userID == "" || accountID == "" {
|
||||
continue
|
||||
}
|
||||
users = append(users, treasurysvc.UserBinding{
|
||||
TelegramUserID: userID,
|
||||
LedgerAccount: accountID,
|
||||
})
|
||||
}
|
||||
if len(users) == 0 {
|
||||
s.logger.Warn("Treasury module disabled: no valid treasury.telegram.users bindings",
|
||||
zap.Int("configured_bindings", len(configuredUsers)),
|
||||
zap.Strings("configured_user_ids", configuredUserIDs))
|
||||
return
|
||||
}
|
||||
|
||||
ledgerTimeout := s.cfg.Treasury.Ledger.Timeout
|
||||
if ledgerTimeout <= 0 {
|
||||
@@ -241,10 +204,9 @@ func (s *Service) startTreasuryModule() {
|
||||
module, err := treasurysvc.NewModule(
|
||||
s.logger,
|
||||
s.repo.TreasuryRequests(),
|
||||
s.repo.TreasuryTelegramUsers(),
|
||||
ledgerClient,
|
||||
treasurysvc.Config{
|
||||
AllowedChats: s.cfg.Treasury.Telegram.AllowedChats,
|
||||
Users: users,
|
||||
ExecutionDelay: executionDelay,
|
||||
PollInterval: pollInterval,
|
||||
MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation,
|
||||
|
||||
@@ -81,6 +81,7 @@ type fakeRepo struct {
|
||||
tg *fakeTelegramStore
|
||||
pending *fakePendingStore
|
||||
treasury storage.TreasuryRequestsStore
|
||||
users storage.TreasuryTelegramUsersStore
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
||||
@@ -99,6 +100,10 @@ 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
|
||||
|
||||
@@ -14,5 +14,5 @@ func markdownCode(value string) string {
|
||||
}
|
||||
|
||||
func markdownCommand(command Command) string {
|
||||
return markdownCode(command.Slash())
|
||||
return command.Slash()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,16 @@ type TreasuryService interface {
|
||||
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
|
||||
@@ -65,9 +75,7 @@ type Router struct {
|
||||
send SendTextFunc
|
||||
tracker ScheduleTracker
|
||||
|
||||
allowedChats map[string]struct{}
|
||||
userAccounts map[string]string
|
||||
allowAnyChat bool
|
||||
users UserBindingResolver
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
@@ -75,43 +83,23 @@ func NewRouter(
|
||||
service TreasuryService,
|
||||
send SendTextFunc,
|
||||
tracker ScheduleTracker,
|
||||
allowedChats []string,
|
||||
userAccounts map[string]string,
|
||||
users UserBindingResolver,
|
||||
) *Router {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury_router")
|
||||
}
|
||||
allowed := map[string]struct{}{}
|
||||
for _, chatID := range allowedChats {
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
if chatID == "" {
|
||||
continue
|
||||
}
|
||||
allowed[chatID] = struct{}{}
|
||||
}
|
||||
users := map[string]string{}
|
||||
for userID, accountID := range userAccounts {
|
||||
userID = strings.TrimSpace(userID)
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if userID == "" || accountID == "" {
|
||||
continue
|
||||
}
|
||||
users[userID] = accountID
|
||||
}
|
||||
return &Router{
|
||||
logger: logger,
|
||||
service: service,
|
||||
dialogs: NewDialogs(),
|
||||
send: send,
|
||||
tracker: tracker,
|
||||
allowedChats: allowed,
|
||||
userAccounts: users,
|
||||
allowAnyChat: len(allowed) == 0,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Enabled() bool {
|
||||
return r != nil && r.service != nil && len(r.userAccounts) > 0
|
||||
return r != nil && r.service != nil && r.users != nil
|
||||
}
|
||||
|
||||
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
@@ -138,20 +126,28 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
|
||||
)
|
||||
}
|
||||
|
||||
if !r.allowAnyChat {
|
||||
if _, ok := r.allowedChats[chatID]; !ok {
|
||||
r.logUnauthorized(update)
|
||||
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
accountID, ok := r.userAccounts[userID]
|
||||
if !ok || strings.TrimSpace(accountID) == "" {
|
||||
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:
|
||||
@@ -507,6 +503,22 @@ func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID stri
|
||||
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"
|
||||
|
||||
@@ -12,6 +12,21 @@ import (
|
||||
|
||||
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
|
||||
}
|
||||
@@ -54,8 +69,15 @@ func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
[]string{"100"},
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
AllowedChatIDs: []string{"100"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
@@ -85,8 +107,15 @@ func TestRouterUnknownChatGetsDenied(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
[]string{"100"},
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
AllowedChatIDs: []string{"100"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
@@ -116,8 +145,14 @@ func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
@@ -151,8 +186,14 @@ func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
@@ -182,8 +223,14 @@ func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
@@ -213,8 +260,14 @@ func TestRouterHelpAuthorizedShowsHelp(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
@@ -244,8 +297,14 @@ func TestRouterStartUnauthorizedGetsDenied(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
@@ -275,8 +334,14 @@ func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
fakeUserBindingResolver{
|
||||
bindings: map[string]*UserBinding{
|
||||
"123": {
|
||||
TelegramUserID: "123",
|
||||
LedgerAccountID: "acct-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
|
||||
@@ -2,15 +2,7 @@ package treasury
|
||||
|
||||
import "time"
|
||||
|
||||
type UserBinding struct {
|
||||
TelegramUserID string
|
||||
LedgerAccount string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AllowedChats []string
|
||||
Users []UserBinding
|
||||
|
||||
ExecutionDelay time.Duration
|
||||
PollInterval time.Duration
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type Module struct {
|
||||
func NewModule(
|
||||
logger mlogger.Logger,
|
||||
repo storage.TreasuryRequestsStore,
|
||||
users storage.TreasuryTelegramUsersStore,
|
||||
ledgerClient ledger.Client,
|
||||
cfg Config,
|
||||
send bot.SendTextFunc,
|
||||
@@ -33,6 +34,9 @@ func NewModule(
|
||||
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,
|
||||
@@ -45,23 +49,13 @@ func NewModule(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := map[string]string{}
|
||||
for _, binding := range cfg.Users {
|
||||
userID := strings.TrimSpace(binding.TelegramUserID)
|
||||
accountID := strings.TrimSpace(binding.LedgerAccount)
|
||||
if userID == "" || accountID == "" {
|
||||
continue
|
||||
}
|
||||
users[userID] = accountID
|
||||
}
|
||||
|
||||
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, cfg.AllowedChats, users)
|
||||
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, &botUsersAdapter{store: users})
|
||||
return module, nil
|
||||
}
|
||||
|
||||
@@ -99,6 +93,28 @@ 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
|
||||
@@ -164,3 +180,26 @@ func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
return "TGSETTLE-" + strings.ToUpper(bson.NewObjectID().Hex()[:8])
|
||||
return "TG-TREASURY-" + strings.ToUpper(bson.NewObjectID().Hex())
|
||||
}
|
||||
|
||||
func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string {
|
||||
|
||||
@@ -23,6 +23,9 @@ 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"`
|
||||
|
||||
@@ -5,6 +5,7 @@ const (
|
||||
telegramConfirmationsCollection = "telegram_confirmations"
|
||||
pendingConfirmationsCollection = "pending_confirmations"
|
||||
treasuryRequestsCollection = "treasury_requests"
|
||||
treasuryTelegramUsersCollection = "treasury_telegram_users"
|
||||
)
|
||||
|
||||
func (*PaymentRecord) Collection() string {
|
||||
@@ -22,3 +23,7 @@ func (*PendingConfirmation) Collection() string {
|
||||
func (*TreasuryRequest) Collection() string {
|
||||
return treasuryRequestsCollection
|
||||
}
|
||||
|
||||
func (*TreasuryTelegramUser) Collection() string {
|
||||
return treasuryTelegramUsersCollection
|
||||
}
|
||||
|
||||
@@ -49,3 +49,11 @@ type TreasuryRequest struct {
|
||||
|
||||
Active bool `bson:"active,omitempty" json:"active,omitempty"`
|
||||
}
|
||||
|
||||
type TreasuryTelegramUser struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
|
||||
TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"`
|
||||
LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"`
|
||||
AllowedChatIDs []string `bson:"allowedChatIds,omitempty" json:"allowed_chat_ids,omitempty"`
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Repository struct {
|
||||
tg storage.TelegramConfirmationsStore
|
||||
pending storage.PendingConfirmationsStore
|
||||
treasury storage.TreasuryRequestsStore
|
||||
users storage.TreasuryTelegramUsersStore
|
||||
outbox gatewayoutbox.Store
|
||||
}
|
||||
|
||||
@@ -80,6 +81,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
||||
result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests"))
|
||||
return nil, err
|
||||
}
|
||||
treasuryUsersStore, err := store.NewTreasuryTelegramUsers(result.logger, result.db)
|
||||
if err != nil {
|
||||
result.logger.Error("Failed to initialise treasury telegram users store", zap.Error(err), zap.String("store", "treasury_telegram_users"))
|
||||
return nil, err
|
||||
}
|
||||
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
|
||||
if err != nil {
|
||||
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
|
||||
@@ -89,6 +95,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
||||
result.tg = tgStore
|
||||
result.pending = pendingStore
|
||||
result.treasury = treasuryStore
|
||||
result.users = treasuryUsersStore
|
||||
result.outbox = outboxStore
|
||||
result.logger.Info("Payment gateway MongoDB storage initialised")
|
||||
return result, nil
|
||||
@@ -110,6 +117,10 @@ func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore {
|
||||
return r.treasury
|
||||
}
|
||||
|
||||
func (r *Repository) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
|
||||
return r.users
|
||||
}
|
||||
|
||||
func (r *Repository) Outbox() gatewayoutbox.Store {
|
||||
return r.outbox
|
||||
}
|
||||
|
||||
@@ -103,14 +103,13 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
|
||||
return merrors.InvalidArgument("payment record is nil", "record")
|
||||
}
|
||||
record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey)
|
||||
record.PaymentIntentID = strings.TrimSpace(record.PaymentIntentID)
|
||||
record.QuoteRef = strings.TrimSpace(record.QuoteRef)
|
||||
record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg)
|
||||
record.TargetChatID = strings.TrimSpace(record.TargetChatID)
|
||||
record.IntentRef = strings.TrimSpace(record.IntentRef)
|
||||
record.OperationRef = strings.TrimSpace(record.OperationRef)
|
||||
if record.PaymentIntentID == "" {
|
||||
return merrors.InvalidArgument("intention reference is required", "payment_intent_ref")
|
||||
if record.IntentRef == "" {
|
||||
return merrors.InvalidArgument("intention reference is required", "intent_ref")
|
||||
}
|
||||
if record.IdempotencyKey == "" {
|
||||
return merrors.InvalidArgument("idempotency key is required", "idempotency_key")
|
||||
@@ -119,31 +118,36 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
|
||||
return merrors.InvalidArgument("intention reference key is required", "intent_ref")
|
||||
}
|
||||
|
||||
filter := repository.Filter(fieldIdempotencyKey, record.IdempotencyKey)
|
||||
err := p.repo.Insert(ctx, record, filter)
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field(fieldOperationRef), record.OperationRef).
|
||||
Set(repository.Field("paymentIntentId"), record.PaymentIntentID).
|
||||
Set(repository.Field("quoteRef"), record.QuoteRef).
|
||||
Set(repository.Field("intentRef"), record.IntentRef).
|
||||
Set(repository.Field("paymentRef"), record.PaymentRef).
|
||||
Set(repository.Field("outgoingLeg"), record.OutgoingLeg).
|
||||
Set(repository.Field("targetChatId"), record.TargetChatID).
|
||||
Set(repository.Field("requestedMoney"), record.RequestedMoney).
|
||||
Set(repository.Field("executedMoney"), record.ExecutedMoney).
|
||||
Set(repository.Field("status"), record.Status).
|
||||
Set(repository.Field("failureReason"), record.FailureReason).
|
||||
Set(repository.Field("executedAt"), record.ExecutedAt).
|
||||
Set(repository.Field("expiresAt"), record.ExpiresAt).
|
||||
Set(repository.Field("expiredAt"), record.ExpiredAt)
|
||||
_, err = p.repo.PatchMany(ctx, filter, patch)
|
||||
existing, err := p.FindByIdempotencyKey(ctx, record.IdempotencyKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
record.ID = existing.ID
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
err = p.repo.Upsert(ctx, record)
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
// Concurrent insert by idempotency key: resolve existing ID and retry replace-by-ID.
|
||||
existing, lookupErr := p.FindByIdempotencyKey(ctx, record.IdempotencyKey)
|
||||
if lookupErr != nil {
|
||||
err = lookupErr
|
||||
} else if existing != nil {
|
||||
record.ID = existing.ID
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
err = p.repo.Upsert(ctx, record)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
p.logger.Warn("Failed to upsert payment record",
|
||||
zap.String("idempotency_key", record.IdempotencyKey),
|
||||
zap.String("payment_intent_id", record.PaymentIntentID),
|
||||
zap.String("intent_ref", record.IntentRef),
|
||||
zap.String("quote_ref", record.QuoteRef),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
245
api/gateway/tgsettle/storage/mongo/store/payments_test.go
Normal file
245
api/gateway/tgsettle/storage/mongo/store/payments_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type fakePaymentsRepo struct {
|
||||
repository.Repository
|
||||
|
||||
records map[string]*model.PaymentRecord
|
||||
findErrByCall map[int]error
|
||||
duplicateWhenZeroID bool
|
||||
findCalls int
|
||||
upsertCalls int
|
||||
upsertIDs []bson.ObjectID
|
||||
upsertIdempotencyKey []string
|
||||
}
|
||||
|
||||
func (f *fakePaymentsRepo) FindOneByFilter(_ context.Context, query repository.FilterQuery, result storable.Storable) error {
|
||||
f.findCalls++
|
||||
if err, ok := f.findErrByCall[f.findCalls]; ok {
|
||||
return err
|
||||
}
|
||||
|
||||
rec, ok := result.(*model.PaymentRecord)
|
||||
if !ok {
|
||||
return merrors.InvalidDataType("expected *model.PaymentRecord")
|
||||
}
|
||||
|
||||
doc := query.BuildQuery()
|
||||
if key := stringField(doc, fieldIdempotencyKey); key != "" {
|
||||
stored, ok := f.records[key]
|
||||
if !ok {
|
||||
return merrors.NoData("payment not found by filter")
|
||||
}
|
||||
*rec = *stored
|
||||
return nil
|
||||
}
|
||||
if operationRef := stringField(doc, fieldOperationRef); operationRef != "" {
|
||||
for _, stored := range f.records {
|
||||
if strings.TrimSpace(stored.OperationRef) == operationRef {
|
||||
*rec = *stored
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return merrors.NoData("payment not found by operation ref")
|
||||
}
|
||||
|
||||
return merrors.NoData("payment not found")
|
||||
}
|
||||
|
||||
func (f *fakePaymentsRepo) Upsert(_ context.Context, obj storable.Storable) error {
|
||||
f.upsertCalls++
|
||||
|
||||
rec, ok := obj.(*model.PaymentRecord)
|
||||
if !ok {
|
||||
return merrors.InvalidDataType("expected *model.PaymentRecord")
|
||||
}
|
||||
f.upsertIDs = append(f.upsertIDs, rec.ID)
|
||||
f.upsertIdempotencyKey = append(f.upsertIdempotencyKey, rec.IdempotencyKey)
|
||||
|
||||
if f.duplicateWhenZeroID && rec.ID.IsZero() {
|
||||
if _, exists := f.records[rec.IdempotencyKey]; exists {
|
||||
return mongo.WriteException{
|
||||
WriteErrors: mongo.WriteErrors{
|
||||
{
|
||||
Code: 11000,
|
||||
Message: "E11000 duplicate key error collection: tgsettle_gateway.payments",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyRec := *rec
|
||||
if copyRec.ID.IsZero() {
|
||||
copyRec.ID = bson.NewObjectID()
|
||||
}
|
||||
if copyRec.CreatedAt.IsZero() {
|
||||
copyRec.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
copyRec.UpdatedAt = time.Now().UTC()
|
||||
if f.records == nil {
|
||||
f.records = map[string]*model.PaymentRecord{}
|
||||
}
|
||||
f.records[copyRec.IdempotencyKey] = ©Rec
|
||||
*rec = copyRec
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPaymentsUpsert_ReusesExistingIDFromIdempotencyLookup(t *testing.T) {
|
||||
key := "idem-existing"
|
||||
existingID := bson.NewObjectID()
|
||||
existingCreatedAt := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
repo := &fakePaymentsRepo{
|
||||
records: map[string]*model.PaymentRecord{
|
||||
key: {
|
||||
Base: storable.Base{
|
||||
ID: existingID,
|
||||
CreatedAt: existingCreatedAt,
|
||||
UpdatedAt: existingCreatedAt,
|
||||
},
|
||||
IdempotencyKey: key,
|
||||
IntentRef: "pi-old",
|
||||
},
|
||||
},
|
||||
duplicateWhenZeroID: true,
|
||||
}
|
||||
store := &Payments{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
record := &model.PaymentRecord{
|
||||
IdempotencyKey: key,
|
||||
IntentRef: "pi-new",
|
||||
QuoteRef: "quote-new",
|
||||
}
|
||||
|
||||
if err := store.Upsert(context.Background(), record); err != nil {
|
||||
t.Fatalf("upsert failed: %v", err)
|
||||
}
|
||||
|
||||
if repo.upsertCalls != 1 {
|
||||
t.Fatalf("expected one upsert call, got %d", repo.upsertCalls)
|
||||
}
|
||||
if len(repo.upsertIDs) != 1 || repo.upsertIDs[0] != existingID {
|
||||
t.Fatalf("expected upsert to reuse existing id %s, got %+v", existingID.Hex(), repo.upsertIDs)
|
||||
}
|
||||
if record.ID != existingID {
|
||||
t.Fatalf("record ID mismatch: got %s want %s", record.ID.Hex(), existingID.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentsUpsert_RetriesAfterDuplicateKeyRace(t *testing.T) {
|
||||
key := "idem-race"
|
||||
existingID := bson.NewObjectID()
|
||||
|
||||
repo := &fakePaymentsRepo{
|
||||
records: map[string]*model.PaymentRecord{
|
||||
key: {
|
||||
Base: storable.Base{
|
||||
ID: existingID,
|
||||
CreatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC),
|
||||
},
|
||||
IdempotencyKey: key,
|
||||
IntentRef: "pi-existing",
|
||||
},
|
||||
},
|
||||
findErrByCall: map[int]error{
|
||||
1: merrors.NoData("payment not found by filter"),
|
||||
},
|
||||
duplicateWhenZeroID: true,
|
||||
}
|
||||
store := &Payments{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
record := &model.PaymentRecord{
|
||||
IdempotencyKey: key,
|
||||
IntentRef: "pi-new",
|
||||
QuoteRef: "quote-new",
|
||||
}
|
||||
|
||||
if err := store.Upsert(context.Background(), record); err != nil {
|
||||
t.Fatalf("upsert failed: %v", err)
|
||||
}
|
||||
|
||||
if repo.upsertCalls != 2 {
|
||||
t.Fatalf("expected two upsert calls, got %d", repo.upsertCalls)
|
||||
}
|
||||
if len(repo.upsertIDs) != 2 {
|
||||
t.Fatalf("expected two upsert IDs, got %d", len(repo.upsertIDs))
|
||||
}
|
||||
if !repo.upsertIDs[0].IsZero() {
|
||||
t.Fatalf("expected first upsert to use zero id due stale read, got %s", repo.upsertIDs[0].Hex())
|
||||
}
|
||||
if repo.upsertIDs[1] != existingID {
|
||||
t.Fatalf("expected retry to use existing id %s, got %s", existingID.Hex(), repo.upsertIDs[1].Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentsUpsert_PropagatesNoSuchTransactionAfterDuplicate(t *testing.T) {
|
||||
key := "idem-nosuchtx"
|
||||
|
||||
repo := &fakePaymentsRepo{
|
||||
records: map[string]*model.PaymentRecord{
|
||||
key: {
|
||||
Base: storable.Base{
|
||||
ID: bson.NewObjectID(),
|
||||
CreatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC),
|
||||
},
|
||||
IdempotencyKey: key,
|
||||
IntentRef: "pi-existing",
|
||||
},
|
||||
},
|
||||
findErrByCall: map[int]error{
|
||||
1: merrors.NoData("payment not found by filter"),
|
||||
2: mongo.CommandError{
|
||||
Code: 251,
|
||||
Name: "NoSuchTransaction",
|
||||
Message: "Transaction with { txnNumber: 2 } has been aborted.",
|
||||
},
|
||||
},
|
||||
duplicateWhenZeroID: true,
|
||||
}
|
||||
store := &Payments{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
record := &model.PaymentRecord{
|
||||
IdempotencyKey: key,
|
||||
IntentRef: "pi-new",
|
||||
QuoteRef: "quote-new",
|
||||
}
|
||||
|
||||
err := store.Upsert(context.Background(), record)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "NoSuchTransaction") {
|
||||
t.Fatalf("expected NoSuchTransaction error, got %v", err)
|
||||
}
|
||||
if repo.upsertCalls != 1 {
|
||||
t.Fatalf("expected one upsert attempt before lookup failure, got %d", repo.upsertCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func stringField(doc bson.D, key string) string {
|
||||
for _, entry := range doc {
|
||||
if entry.Key != key {
|
||||
continue
|
||||
}
|
||||
res, _ := entry.Value.(string)
|
||||
return strings.TrimSpace(res)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -296,20 +296,48 @@ func (t *TreasuryRequests) Update(ctx context.Context, record *model.TreasuryReq
|
||||
Set(repository.Field("operationType"), record.OperationType).
|
||||
Set(repository.Field("telegramUserId"), record.TelegramUserID).
|
||||
Set(repository.Field("ledgerAccountId"), record.LedgerAccountID).
|
||||
Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode).
|
||||
Set(repository.Field("organizationRef"), record.OrganizationRef).
|
||||
Set(repository.Field("chatId"), record.ChatID).
|
||||
Set(repository.Field("amount"), record.Amount).
|
||||
Set(repository.Field("currency"), record.Currency).
|
||||
Set(repository.Field(fieldTreasuryStatus), record.Status).
|
||||
Set(repository.Field("confirmedAt"), record.ConfirmedAt).
|
||||
Set(repository.Field("scheduledAt"), record.ScheduledAt).
|
||||
Set(repository.Field("executedAt"), record.ExecutedAt).
|
||||
Set(repository.Field("cancelledAt"), record.CancelledAt).
|
||||
Set(repository.Field(fieldTreasuryIdempotencyKey), record.IdempotencyKey).
|
||||
Set(repository.Field("ledgerReference"), record.LedgerReference).
|
||||
Set(repository.Field("errorMessage"), record.ErrorMessage).
|
||||
Set(repository.Field(fieldTreasuryActive), record.Active)
|
||||
if record.LedgerAccountCode != "" {
|
||||
patch = patch.Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode)
|
||||
} else {
|
||||
patch = patch.Unset(repository.Field("ledgerAccountCode"))
|
||||
}
|
||||
if !record.ConfirmedAt.IsZero() {
|
||||
patch = patch.Set(repository.Field("confirmedAt"), record.ConfirmedAt)
|
||||
} else {
|
||||
patch = patch.Unset(repository.Field("confirmedAt"))
|
||||
}
|
||||
if !record.ScheduledAt.IsZero() {
|
||||
patch = patch.Set(repository.Field("scheduledAt"), record.ScheduledAt)
|
||||
} else {
|
||||
patch = patch.Unset(repository.Field("scheduledAt"))
|
||||
}
|
||||
if !record.ExecutedAt.IsZero() {
|
||||
patch = patch.Set(repository.Field("executedAt"), record.ExecutedAt)
|
||||
} else {
|
||||
patch = patch.Unset(repository.Field("executedAt"))
|
||||
}
|
||||
if !record.CancelledAt.IsZero() {
|
||||
patch = patch.Set(repository.Field("cancelledAt"), record.CancelledAt)
|
||||
} else {
|
||||
patch = patch.Unset(repository.Field("cancelledAt"))
|
||||
}
|
||||
if record.LedgerReference != "" {
|
||||
patch = patch.Set(repository.Field("ledgerReference"), record.LedgerReference)
|
||||
} else {
|
||||
patch = patch.Unset(repository.Field("ledgerReference"))
|
||||
}
|
||||
if record.ErrorMessage != "" {
|
||||
patch = patch.Set(repository.Field("errorMessage"), record.ErrorMessage)
|
||||
} else {
|
||||
patch = patch.Unset(repository.Field("errorMessage"))
|
||||
}
|
||||
if _, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, record.RequestID), patch); err != nil {
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.logger.Warn("Failed to update treasury request", zap.Error(err), zap.String("request_id", record.RequestID))
|
||||
|
||||
@@ -15,6 +15,7 @@ type Repository interface {
|
||||
TelegramConfirmations() TelegramConfirmationsStore
|
||||
PendingConfirmations() PendingConfirmationsStore
|
||||
TreasuryRequests() TreasuryRequestsStore
|
||||
TreasuryTelegramUsers() TreasuryTelegramUsersStore
|
||||
}
|
||||
|
||||
type PaymentsStore interface {
|
||||
@@ -46,3 +47,7 @@ type TreasuryRequestsStore interface {
|
||||
Update(ctx context.Context, record *model.TreasuryRequest) error
|
||||
ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]model.TreasuryRequest, error)
|
||||
}
|
||||
|
||||
type TreasuryTelegramUsersStore interface {
|
||||
FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ require (
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.79.1
|
||||
google.golang.org/grpc v1.79.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -213,8 +213,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -45,6 +45,9 @@ const (
|
||||
type StepShell struct {
|
||||
StepRef string `bson:"stepRef" json:"stepRef"`
|
||||
StepCode string `bson:"stepCode" json:"stepCode"`
|
||||
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
|
||||
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
|
||||
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
|
||||
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
||||
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
|
||||
}
|
||||
@@ -53,6 +56,9 @@ type StepShell struct {
|
||||
type StepExecution struct {
|
||||
StepRef string `bson:"stepRef" json:"stepRef"`
|
||||
StepCode string `bson:"stepCode" json:"stepCode"`
|
||||
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
|
||||
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
|
||||
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
|
||||
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
||||
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
|
||||
State StepState `bson:"state" json:"state"`
|
||||
|
||||
@@ -143,10 +143,16 @@ func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
|
||||
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].report_visibility is invalid")
|
||||
}
|
||||
userLabel := strings.TrimSpace(shell[i].UserLabel)
|
||||
railValue := model.ParseRail(string(shell[i].Rail))
|
||||
gatewayID := strings.TrimSpace(shell[i].Gateway)
|
||||
instanceID := strings.TrimSpace(shell[i].InstanceID)
|
||||
|
||||
out = append(out, StepExecution{
|
||||
StepRef: stepRef,
|
||||
StepCode: stepCode,
|
||||
Rail: railValue,
|
||||
Gateway: gatewayID,
|
||||
InstanceID: instanceID,
|
||||
ReportVisibility: visibility,
|
||||
UserLabel: userLabel,
|
||||
State: StepStatePending,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -42,7 +43,15 @@ func TestCreate_OK(t *testing.T) {
|
||||
QuoteSnapshot: quote,
|
||||
Steps: []StepShell{
|
||||
{StepRef: " s1 ", StepCode: " reserve_funds ", ReportVisibility: model.ReportVisibilityHidden},
|
||||
{StepRef: "s2", StepCode: "submit_gateway", ReportVisibility: model.ReportVisibilityUser, UserLabel: " Card payout "},
|
||||
{
|
||||
StepRef: "s2",
|
||||
StepCode: "submit_gateway",
|
||||
Rail: discovery.RailProviderSettlement,
|
||||
Gateway: "payment_gateway_settlement",
|
||||
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
|
||||
ReportVisibility: model.ReportVisibilityUser,
|
||||
UserLabel: " Card payout ",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -111,6 +120,15 @@ func TestCreate_OK(t *testing.T) {
|
||||
if got, want := payment.StepExecutions[1].UserLabel, "Card payout"; got != want {
|
||||
t.Fatalf("unexpected second step user label: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.StepExecutions[1].Rail, model.Rail(discovery.RailProviderSettlement); got != want {
|
||||
t.Fatalf("unexpected second step rail: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.StepExecutions[1].Gateway, "payment_gateway_settlement"; got != want {
|
||||
t.Fatalf("unexpected second step gateway: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payment.StepExecutions[1].InstanceID, "04a54fec-20f4-4250-a715-eb9886e13e12"; got != want {
|
||||
t.Fatalf("unexpected second step instance_id: got=%q want=%q", got, want)
|
||||
}
|
||||
|
||||
// Verify immutable snapshot semantics by ensuring clones were created.
|
||||
payment.IntentSnapshot.Ref = "changed"
|
||||
|
||||
@@ -221,6 +221,9 @@ func toStepShells(graph *xplan.Graph) []agg.StepShell {
|
||||
out = append(out, agg.StepShell{
|
||||
StepRef: graph.Steps[i].StepRef,
|
||||
StepCode: graph.Steps[i].StepCode,
|
||||
Rail: graph.Steps[i].Rail,
|
||||
Gateway: graph.Steps[i].Gateway,
|
||||
InstanceID: graph.Steps[i].InstanceID,
|
||||
ReportVisibility: graph.Steps[i].Visibility,
|
||||
UserLabel: graph.Steps[i].UserLabel,
|
||||
})
|
||||
|
||||
@@ -408,6 +408,15 @@ func stepExecutionEqual(left, right agg.StepExecution) bool {
|
||||
if left.StepRef != right.StepRef || left.StepCode != right.StepCode {
|
||||
return false
|
||||
}
|
||||
if left.Rail != right.Rail {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(left.Gateway) != strings.TrimSpace(right.Gateway) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(left.InstanceID) != strings.TrimSpace(right.InstanceID) {
|
||||
return false
|
||||
}
|
||||
if left.State != right.State || left.Attempt != right.Attempt {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
@@ -144,6 +145,16 @@ func (s *svc) normalizeStepExecutions(
|
||||
stepCode = stepsByRef[stepRef].StepCode
|
||||
}
|
||||
exec.StepCode = stepCode
|
||||
step := stepsByRef[stepRef]
|
||||
if exec.Rail == discovery.RailUnspecified {
|
||||
exec.Rail = step.Rail
|
||||
}
|
||||
if strings.TrimSpace(exec.Gateway) == "" {
|
||||
exec.Gateway = strings.TrimSpace(step.Gateway)
|
||||
}
|
||||
if strings.TrimSpace(exec.InstanceID) == "" {
|
||||
exec.InstanceID = strings.TrimSpace(step.InstanceID)
|
||||
}
|
||||
exec.ReportVisibility = effectiveStepVisibility(exec.ReportVisibility, stepsByRef[stepRef].Visibility)
|
||||
exec.UserLabel = firstNonEmpty(exec.UserLabel, stepsByRef[stepRef].UserLabel)
|
||||
cloned := cloneStepExecution(exec)
|
||||
@@ -158,6 +169,9 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste
|
||||
exec.FailureCode = strings.TrimSpace(exec.FailureCode)
|
||||
exec.FailureMsg = strings.TrimSpace(exec.FailureMsg)
|
||||
exec.UserLabel = strings.TrimSpace(exec.UserLabel)
|
||||
exec.Gateway = strings.TrimSpace(exec.Gateway)
|
||||
exec.InstanceID = strings.TrimSpace(exec.InstanceID)
|
||||
exec.Rail = model.ParseRail(string(exec.Rail))
|
||||
exec.ReportVisibility = model.NormalizeReportVisibility(exec.ReportVisibility)
|
||||
exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
|
||||
if exec.StepRef == "" {
|
||||
@@ -197,6 +211,9 @@ func seedMissingExecutions(
|
||||
executionsByRef[stepRef] = &agg.StepExecution{
|
||||
StepRef: step.StepRef,
|
||||
StepCode: step.StepCode,
|
||||
Rail: step.Rail,
|
||||
Gateway: strings.TrimSpace(step.Gateway),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
ReportVisibility: effectiveStepVisibility(model.ReportVisibilityUnspecified, step.Visibility),
|
||||
UserLabel: strings.TrimSpace(step.UserLabel),
|
||||
State: agg.StepStatePending,
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
cons "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
paymentgatewaynotifications "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
|
||||
@@ -412,6 +411,9 @@ func buildObserveCandidate(step agg.StepExecution) (runningObserveCandidate, boo
|
||||
}
|
||||
}
|
||||
}
|
||||
if candidate.gatewayInstanceID == "" {
|
||||
candidate.gatewayInstanceID = strings.TrimSpace(step.InstanceID)
|
||||
}
|
||||
if candidate.stepRef == "" || candidate.transferRef == "" {
|
||||
return runningObserveCandidate{}, false
|
||||
}
|
||||
@@ -475,7 +477,7 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment
|
||||
StepRef: candidate.stepRef,
|
||||
OperationRef: firstNonEmpty(strings.TrimSpace(transfer.GetOperationRef()), candidate.operationRef),
|
||||
TransferRef: strings.TrimSpace(candidate.transferRef),
|
||||
GatewayInstanceID: firstNonEmpty(candidate.gatewayInstanceID, strings.TrimSpace(gateway.InstanceID), strings.TrimSpace(gateway.ID)),
|
||||
GatewayInstanceID: resolvedObserveGatewayID(candidate.gatewayInstanceID, gateway),
|
||||
Status: status,
|
||||
}
|
||||
switch status {
|
||||
@@ -517,39 +519,106 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment
|
||||
}
|
||||
|
||||
func (s *Service) resolveObserveGateway(ctx context.Context, payment *agg.Payment, candidate runningObserveCandidate) (*model.GatewayInstanceDescriptor, error) {
|
||||
if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" {
|
||||
if s == nil || s.gatewayRegistry == nil {
|
||||
return nil, errors.New("observe polling: gateway registry is unavailable")
|
||||
}
|
||||
items, err := s.gatewayRegistry.List(ctx)
|
||||
if err == nil {
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
if item == nil || !item.IsEnabled {
|
||||
continue
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(item.ID), gatewayID) && !strings.EqualFold(strings.TrimSpace(item.InstanceID), gatewayID) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.InvokeURI) == "" {
|
||||
continue
|
||||
hint, hasHint := observeStepGatewayHint(payment, candidate.stepRef)
|
||||
expectedRail := model.Rail(discovery.RailUnspecified)
|
||||
if hasHint {
|
||||
expectedRail = hint.rail
|
||||
}
|
||||
if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" {
|
||||
if item := findEnabledGatewayDescriptor(items, gatewayID, expectedRail); item != nil {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
if hasHint {
|
||||
if item := findEnabledGatewayDescriptor(items, hint.instanceID, hint.rail); item != nil {
|
||||
return item, nil
|
||||
}
|
||||
if item := findEnabledGatewayDescriptor(items, hint.gatewayID, hint.rail); item != nil {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("observe polling: gateway instance not found")
|
||||
}
|
||||
|
||||
executor := gatewayCryptoExecutor{
|
||||
gatewayRegistry: s.gatewayRegistry,
|
||||
type observeStepHint struct {
|
||||
rail model.Rail
|
||||
gatewayID string
|
||||
instanceID string
|
||||
}
|
||||
|
||||
func observeStepGatewayHint(payment *agg.Payment, stepRef string) (observeStepHint, bool) {
|
||||
if payment == nil {
|
||||
return observeStepHint{}, false
|
||||
}
|
||||
step := xplan.Step{
|
||||
Rail: discovery.RailCrypto,
|
||||
key := strings.TrimSpace(stepRef)
|
||||
if key == "" {
|
||||
return observeStepHint{}, false
|
||||
}
|
||||
if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" {
|
||||
step.InstanceID = gatewayID
|
||||
step.Gateway = gatewayID
|
||||
} else if gateway, instanceID, ok := sourceCryptoHop(payment); ok {
|
||||
step.Gateway = strings.TrimSpace(gateway)
|
||||
step.InstanceID = strings.TrimSpace(instanceID)
|
||||
for i := range payment.StepExecutions {
|
||||
step := payment.StepExecutions[i]
|
||||
if !strings.EqualFold(strings.TrimSpace(step.StepRef), key) {
|
||||
continue
|
||||
}
|
||||
return executor.resolveGateway(ctx, step)
|
||||
hint := observeStepHint{
|
||||
rail: model.ParseRail(string(step.Rail)),
|
||||
gatewayID: strings.TrimSpace(step.Gateway),
|
||||
instanceID: strings.TrimSpace(step.InstanceID),
|
||||
}
|
||||
if hint.gatewayID == "" && hint.instanceID == "" {
|
||||
return observeStepHint{}, false
|
||||
}
|
||||
return hint, true
|
||||
}
|
||||
return observeStepHint{}, false
|
||||
}
|
||||
|
||||
func findEnabledGatewayDescriptor(items []*model.GatewayInstanceDescriptor, identifier string, rail model.Rail) *model.GatewayInstanceDescriptor {
|
||||
key := strings.TrimSpace(identifier)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
if item == nil || !item.IsEnabled || strings.TrimSpace(item.InvokeURI) == "" {
|
||||
continue
|
||||
}
|
||||
if rail != model.Rail(discovery.RailUnspecified) && model.ParseRail(string(item.Rail)) != rail {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(item.ID), key) || strings.EqualFold(strings.TrimSpace(item.InstanceID), key) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvedObserveGatewayID(candidateGatewayID string, gateway *model.GatewayInstanceDescriptor) string {
|
||||
candidateID := strings.TrimSpace(candidateGatewayID)
|
||||
if candidateID != "" && gatewayIdentifierMatches(gateway, candidateID) {
|
||||
return candidateID
|
||||
}
|
||||
if gateway == nil {
|
||||
return ""
|
||||
}
|
||||
return firstNonEmpty(strings.TrimSpace(gateway.InstanceID), strings.TrimSpace(gateway.ID))
|
||||
}
|
||||
|
||||
func gatewayIdentifierMatches(gateway *model.GatewayInstanceDescriptor, identifier string) bool {
|
||||
if gateway == nil {
|
||||
return false
|
||||
}
|
||||
key := strings.TrimSpace(identifier)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(gateway.ID), key) || strings.EqualFold(strings.TrimSpace(gateway.InstanceID), key)
|
||||
}
|
||||
|
||||
func mapTransferStatus(status chainv1.TransferStatus) (gatewayStatus erecon.GatewayStatus, terminal bool, ok bool) {
|
||||
|
||||
@@ -3,14 +3,15 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"testing"
|
||||
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
@@ -412,6 +413,30 @@ func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningObserveCandidates_UsesPlannedStepInstanceWhenExternalRefGatewayMissing(t *testing.T) {
|
||||
payment := &agg.Payment{
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "hop_2_settlement_observe",
|
||||
StepCode: "hop.2.settlement.observe",
|
||||
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
|
||||
State: agg.StepStateRunning,
|
||||
ExternalRefs: []agg.ExternalRef{
|
||||
{Kind: erecon.ExternalRefKindTransfer, Ref: "trf-2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
candidates := runningObserveCandidates(payment)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidate count mismatch: got=%d want=1", len(candidates))
|
||||
}
|
||||
if got, want := candidates[0].gatewayInstanceID, "04a54fec-20f4-4250-a715-eb9886e13e12"; got != want {
|
||||
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *testing.T) {
|
||||
svc := &Service{
|
||||
gatewayRegistry: &fakeGatewayRegistry{
|
||||
@@ -466,5 +491,192 @@ func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveObserveGateway_UsesPlannedStepGatewayWhenExternalRefInstanceIsStale(t *testing.T) {
|
||||
svc := &Service{
|
||||
gatewayRegistry: &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "payment_gateway_settlement",
|
||||
InstanceID: "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4",
|
||||
Rail: discovery.RailProviderSettlement,
|
||||
InvokeURI: "grpc://tgsettle-gateway-new",
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "crypto_rail_gateway_tron_mainnet",
|
||||
InstanceID: "fbef2c3b-ff66-447e-8bba-fa666a955855",
|
||||
Rail: discovery.RailCrypto,
|
||||
InvokeURI: "grpc://tron-gateway",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payment := &agg.Payment{
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "hop_2_settlement_observe",
|
||||
StepCode: "hop.2.settlement.observe",
|
||||
Rail: discovery.RailProviderSettlement,
|
||||
Gateway: "payment_gateway_settlement",
|
||||
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gateway, err := svc.resolveObserveGateway(context.Background(), payment, runningObserveCandidate{
|
||||
stepRef: "hop_2_settlement_observe",
|
||||
transferRef: "trf-1",
|
||||
gatewayInstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveObserveGateway returned error: %v", err)
|
||||
}
|
||||
if gateway == nil {
|
||||
t.Fatal("expected gateway")
|
||||
}
|
||||
if got, want := gateway.ID, "payment_gateway_settlement"; got != want {
|
||||
t.Fatalf("gateway id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := gateway.InstanceID, "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4"; got != want {
|
||||
t.Fatalf("gateway instance mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveObserveGateway_FailsWhenPlannedGatewayMetadataIsMissing(t *testing.T) {
|
||||
svc := &Service{
|
||||
gatewayRegistry: &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto_rail_gateway_tron_mainnet",
|
||||
InstanceID: "fbef2c3b-ff66-447e-8bba-fa666a955855",
|
||||
Rail: discovery.RailCrypto,
|
||||
InvokeURI: "grpc://tron-gateway",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payment := &agg.Payment{
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{
|
||||
Index: 1,
|
||||
Rail: "CRYPTO",
|
||||
Gateway: "crypto_rail_gateway_tron_mainnet",
|
||||
InstanceID: "fbef2c3b-ff66-447e-8bba-fa666a955855",
|
||||
Role: paymenttypes.QuoteRouteHopRoleSource,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gateway, err := svc.resolveObserveGateway(context.Background(), payment, runningObserveCandidate{
|
||||
stepRef: "hop_2_settlement_observe",
|
||||
transferRef: "trf-1",
|
||||
gatewayInstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected gateway resolution error")
|
||||
}
|
||||
if gateway != nil {
|
||||
t.Fatal("expected nil gateway on resolution failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollObserveCandidate_UsesResolvedGatewayAfterInstanceRotation(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
transferRef := "b6874b55-20b0-425d-9e47-d430964b1616:hop_2_settlement_fx_convert"
|
||||
operationRef := "69aabf823555e083d23b2964:hop_2_settlement_fx_convert"
|
||||
|
||||
var requestedTransferRef string
|
||||
client := &chainclient.Fake{
|
||||
GetTransferFn: func(_ context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
|
||||
requestedTransferRef = req.GetTransferRef()
|
||||
return &chainv1.GetTransferResponse{
|
||||
Transfer: &chainv1.Transfer{
|
||||
TransferRef: req.GetTransferRef(),
|
||||
OperationRef: operationRef,
|
||||
Status: chainv1.TransferStatus_TRANSFER_SUCCESS,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
resolver := &fakeGatewayInvokeResolver{client: client}
|
||||
v2 := &fakeExternalRuntimeV2{}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
v2: v2,
|
||||
gatewayInvokeResolver: resolver,
|
||||
gatewayRegistry: &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "payment_gateway_settlement",
|
||||
InstanceID: "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4",
|
||||
Rail: discovery.RailProviderSettlement,
|
||||
InvokeURI: "grpc://tgsettle-gateway-new",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payment := &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "69aabf823555e083d23b2964",
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "hop_2_settlement_observe",
|
||||
StepCode: "hop.2.settlement.observe",
|
||||
Rail: discovery.RailProviderSettlement,
|
||||
Gateway: "payment_gateway_settlement",
|
||||
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
|
||||
State: agg.StepStateRunning,
|
||||
ExternalRefs: []agg.ExternalRef{
|
||||
{
|
||||
GatewayInstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
|
||||
Kind: erecon.ExternalRefKindTransfer,
|
||||
Ref: transferRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
candidates := runningObserveCandidates(payment)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidate count mismatch: got=%d want=1", len(candidates))
|
||||
}
|
||||
|
||||
svc.pollObserveCandidate(context.Background(), payment, candidates[0])
|
||||
|
||||
if got, want := resolver.lastInvokeURI, "grpc://tgsettle-gateway-new"; got != want {
|
||||
t.Fatalf("invoke uri mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := requestedTransferRef, transferRef; got != want {
|
||||
t.Fatalf("transfer_ref lookup mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if v2.reconcileInput == nil || v2.reconcileInput.Event.Gateway == nil {
|
||||
t.Fatal("expected reconcile gateway event")
|
||||
}
|
||||
gw := v2.reconcileInput.Event.Gateway
|
||||
if got, want := gw.StepRef, "hop_2_settlement_observe"; got != want {
|
||||
t.Fatalf("step_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := gw.Status, erecon.GatewayStatusSuccess; got != want {
|
||||
t.Fatalf("status mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := gw.OperationRef, operationRef; got != want {
|
||||
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := gw.GatewayInstanceID, "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4"; got != want {
|
||||
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
var _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil)
|
||||
var _ psvc.Service = (*fakeExternalRuntimeV2)(nil)
|
||||
|
||||
@@ -23,6 +23,7 @@ class PaymentIntentDTO {
|
||||
final String? feeTreatment;
|
||||
|
||||
final Map<String, String>? attributes;
|
||||
final String? comment;
|
||||
final CustomerDTO? customer;
|
||||
|
||||
const PaymentIntentDTO({
|
||||
@@ -33,10 +34,12 @@ class PaymentIntentDTO {
|
||||
this.fx,
|
||||
this.settlementMode,
|
||||
this.attributes,
|
||||
this.comment,
|
||||
this.customer,
|
||||
this.feeTreatment,
|
||||
});
|
||||
|
||||
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => _$PaymentIntentDTOFromJson(json);
|
||||
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) =>
|
||||
_$PaymentIntentDTOFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$PaymentIntentDTOToJson(this);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ extension PaymentIntentMapper on PaymentIntent {
|
||||
fx: fx?.toDTO(),
|
||||
settlementMode: settlementModeToValue(settlementMode),
|
||||
attributes: attributes,
|
||||
comment: comment,
|
||||
customer: customer?.toDTO(),
|
||||
feeTreatment: feeTreatmentToValue(feeTreatment),
|
||||
);
|
||||
@@ -30,6 +31,7 @@ extension PaymentIntentDTOMapper on PaymentIntentDTO {
|
||||
fx: fx?.toDomain(),
|
||||
settlementMode: settlementModeFromValue(settlementMode),
|
||||
attributes: attributes,
|
||||
comment: comment,
|
||||
customer: customer?.toDomain(),
|
||||
feeTreatment: feeTreatmentFromValue(feeTreatment),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ class PaymentIntent {
|
||||
final FeeTreatment feeTreatment;
|
||||
final SettlementMode settlementMode;
|
||||
final Map<String, String>? attributes;
|
||||
final String? comment;
|
||||
final Customer? customer;
|
||||
|
||||
const PaymentIntent({
|
||||
@@ -29,6 +30,7 @@ class PaymentIntent {
|
||||
this.fx,
|
||||
this.settlementMode = SettlementMode.unspecified,
|
||||
this.attributes,
|
||||
this.comment,
|
||||
this.customer,
|
||||
required this.feeTreatment,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class OperationDocumentInfo {
|
||||
final String operationRef;
|
||||
class OperationDocumentRef {
|
||||
final String gatewayService;
|
||||
final String operationRef;
|
||||
|
||||
const OperationDocumentInfo({
|
||||
required this.operationRef,
|
||||
const OperationDocumentRef({
|
||||
required this.gatewayService,
|
||||
required this.operationRef,
|
||||
});
|
||||
}
|
||||
@@ -57,6 +57,7 @@ void main() {
|
||||
),
|
||||
amount: MoneyDTO(amount: '10', currency: 'USD'),
|
||||
settlementMode: 'fix_received',
|
||||
comment: 'invoice-7',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -70,6 +71,7 @@ void main() {
|
||||
final intent = json['intent'] as Map<String, dynamic>;
|
||||
expect(intent['kind'], equals('payout'));
|
||||
expect(intent['settlement_mode'], equals('fix_received'));
|
||||
expect(intent['comment'], equals('invoice-7'));
|
||||
expect(intent.containsKey('settlement_currency'), isFalse);
|
||||
|
||||
final source = intent['source'] as Map<String, dynamic>;
|
||||
|
||||
@@ -25,6 +25,7 @@ class PayoutRoutes {
|
||||
static const walletTopUp = 'payout-wallet-top-up';
|
||||
|
||||
static const paymentTypeQuery = 'paymentType';
|
||||
static const destinationLedgerAccountRefQuery = 'destinationLedgerAccountRef';
|
||||
static const reportPaymentIdQuery = 'paymentId';
|
||||
|
||||
static const dashboardPath = '/dashboard';
|
||||
@@ -40,7 +41,6 @@ class PayoutRoutes {
|
||||
static const editWalletPath = '/methods/edit';
|
||||
static const walletTopUpPath = '/wallet/top-up';
|
||||
|
||||
|
||||
static String nameFor(PayoutDestination destination) {
|
||||
switch (destination) {
|
||||
case PayoutDestination.dashboard:
|
||||
@@ -126,9 +126,13 @@ class PayoutRoutes {
|
||||
|
||||
static Map<String, String> buildQueryParameters({
|
||||
PaymentType? paymentType,
|
||||
String? destinationLedgerAccountRef,
|
||||
}) {
|
||||
final params = <String, String>{
|
||||
if (paymentType != null) paymentTypeQuery: paymentType.name,
|
||||
if (destinationLedgerAccountRef != null &&
|
||||
destinationLedgerAccountRef.trim().isNotEmpty)
|
||||
destinationLedgerAccountRefQuery: destinationLedgerAccountRef.trim(),
|
||||
};
|
||||
return params;
|
||||
}
|
||||
@@ -140,35 +144,44 @@ class PayoutRoutes {
|
||||
? null
|
||||
: PaymentType.values.firstWhereOrNull((type) => type.name == raw);
|
||||
|
||||
static String? destinationLedgerAccountRefFromState(GoRouterState state) =>
|
||||
destinationLedgerAccountRefFromRaw(
|
||||
state.uri.queryParameters[destinationLedgerAccountRefQuery],
|
||||
);
|
||||
|
||||
static String? destinationLedgerAccountRefFromRaw(String? raw) {
|
||||
final value = raw?.trim();
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
extension PayoutNavigation on BuildContext {
|
||||
void goToPayout(PayoutDestination destination) => goNamed(PayoutRoutes.nameFor(destination));
|
||||
void goToPayout(PayoutDestination destination) =>
|
||||
goNamed(PayoutRoutes.nameFor(destination));
|
||||
|
||||
void pushToPayout(PayoutDestination destination) => pushNamed(PayoutRoutes.nameFor(destination));
|
||||
void pushToPayout(PayoutDestination destination) =>
|
||||
pushNamed(PayoutRoutes.nameFor(destination));
|
||||
|
||||
void goToPayment({
|
||||
PaymentType? paymentType,
|
||||
}) =>
|
||||
goNamed(
|
||||
String? destinationLedgerAccountRef,
|
||||
}) => goNamed(
|
||||
PayoutRoutes.payment,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(
|
||||
paymentType: paymentType,
|
||||
destinationLedgerAccountRef: destinationLedgerAccountRef,
|
||||
),
|
||||
);
|
||||
|
||||
void goToReportPayment(String paymentId) => goNamed(
|
||||
PayoutRoutes.reportPayment,
|
||||
queryParameters: {
|
||||
PayoutRoutes.reportPaymentIdQuery: paymentId,
|
||||
},
|
||||
queryParameters: {PayoutRoutes.reportPaymentIdQuery: paymentId},
|
||||
);
|
||||
|
||||
void pushToReportPayment(String paymentId) => pushNamed(
|
||||
PayoutRoutes.reportPayment,
|
||||
queryParameters: {
|
||||
PayoutRoutes.reportPaymentIdQuery: paymentId,
|
||||
},
|
||||
queryParameters: {PayoutRoutes.reportPaymentIdQuery: paymentId},
|
||||
);
|
||||
|
||||
void pushToWalletTopUp() => pushNamed(PayoutRoutes.walletTopUp);
|
||||
|
||||
@@ -228,7 +228,8 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
onGoToPaymentWithoutRecipient: (type) =>
|
||||
_startPayment(context, recipient: null, paymentType: type),
|
||||
onTopUp: (wallet) => _openWalletTopUp(context, wallet),
|
||||
onWalletTap: (wallet) => _openWalletEdit(context, wallet),
|
||||
onLedgerAddFunds: (account) => _openLedgerAddFunds(context, account),
|
||||
onWalletTap: (wallet) => _openWalletTopUp(context, wallet),
|
||||
onLedgerTap: (account) => _openLedgerEdit(context, account),
|
||||
),
|
||||
),
|
||||
@@ -306,6 +307,8 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
child: PaymentPage(
|
||||
onBack: (_) => _popOrGo(context),
|
||||
initialPaymentType: PayoutRoutes.paymentTypeFromState(state),
|
||||
initialDestinationLedgerAccountRef:
|
||||
PayoutRoutes.destinationLedgerAccountRefFromState(state),
|
||||
fallbackDestination: fallbackDestination,
|
||||
),
|
||||
);
|
||||
@@ -382,12 +385,6 @@ void _openEditRecipient(BuildContext context, {required Recipient recipient}) {
|
||||
context.pushNamed(PayoutRoutes.editRecipient);
|
||||
}
|
||||
|
||||
void _openWalletEdit(BuildContext context, Wallet wallet) {
|
||||
context.read<PaymentSourceController>().selectWallet(wallet);
|
||||
context.read<WalletsController>().selectWallet(wallet);
|
||||
context.pushToEditWallet();
|
||||
}
|
||||
|
||||
void _openLedgerEdit(BuildContext context, LedgerAccount account) {
|
||||
context.read<PaymentSourceController>().selectLedgerByRef(
|
||||
account.ledgerAccountRef,
|
||||
@@ -395,6 +392,20 @@ void _openLedgerEdit(BuildContext context, LedgerAccount account) {
|
||||
context.pushToEditWallet();
|
||||
}
|
||||
|
||||
void _openLedgerAddFunds(BuildContext context, LedgerAccount account) {
|
||||
context.read<PaymentSourceController>().selectLedgerByRef(
|
||||
account.ledgerAccountRef,
|
||||
);
|
||||
context.read<RecipientsProvider>().setCurrentObject(null);
|
||||
context.pushNamed(
|
||||
PayoutRoutes.payment,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(
|
||||
paymentType: PaymentType.ledger,
|
||||
destinationLedgerAccountRef: account.ledgerAccountRef,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openWalletTopUp(BuildContext context, Wallet wallet) {
|
||||
context.read<WalletsController>().selectWallet(wallet);
|
||||
context.pushToWalletTopUp();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
||||
class BalanceActionsUiController extends ChangeNotifier {
|
||||
int? _hoveredButtonIndex;
|
||||
|
||||
int? get hoveredButtonIndex => _hoveredButtonIndex;
|
||||
|
||||
bool isExpanded(int index) => _hoveredButtonIndex == index;
|
||||
|
||||
void onHoverChanged(int index, bool hovered) {
|
||||
final next = hovered
|
||||
? index
|
||||
: (_hoveredButtonIndex == index ? null : _hoveredButtonIndex);
|
||||
if (next == _hoveredButtonIndex) return;
|
||||
_hoveredButtonIndex = next;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/provider/ledger.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
|
||||
import 'package:pweb/models/dashboard/balance_item.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||
|
||||
|
||||
class BalanceCarouselController extends ChangeNotifier {
|
||||
BalanceCarouselController()
|
||||
: pageController = PageController(
|
||||
viewportFraction: WalletCardConfig.viewportFraction,
|
||||
);
|
||||
|
||||
class BalanceCarouselController with ChangeNotifier {
|
||||
WalletsController? _walletsController;
|
||||
List<BalanceItem> _items = const <BalanceItem>[BalanceItem.addAction()];
|
||||
int _index = 0;
|
||||
final PageController pageController;
|
||||
|
||||
List<BalanceItem> get items => _items;
|
||||
int get index => _index;
|
||||
@@ -31,6 +39,7 @@ class BalanceCarouselController with ChangeNotifier {
|
||||
|
||||
_items = nextItems;
|
||||
_index = nextIndex;
|
||||
_syncPageController();
|
||||
|
||||
if (hasItemsChanged || hasIndexChanged) {
|
||||
notifyListeners();
|
||||
@@ -49,9 +58,24 @@ class BalanceCarouselController with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void goBack() => onPageChanged(_index - 1);
|
||||
void goBack() => animateTo(_index - 1);
|
||||
|
||||
void goForward() => onPageChanged(_index + 1);
|
||||
void goForward() => animateTo(_index + 1);
|
||||
|
||||
void animateTo(int index) {
|
||||
final target = _clampIndex(index, _items.length);
|
||||
if (!pageController.hasClients) {
|
||||
onPageChanged(target);
|
||||
return;
|
||||
}
|
||||
pageController.animateToPage(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
void syncPageController() => _syncPageController();
|
||||
|
||||
int _resolveNextIndex(
|
||||
List<BalanceItem> nextItems,
|
||||
@@ -73,14 +97,19 @@ class BalanceCarouselController with ChangeNotifier {
|
||||
String? _currentWalletRef(List<BalanceItem> items, int index) {
|
||||
if (items.isEmpty || index < 0 || index >= items.length) return null;
|
||||
final current = items[index];
|
||||
if (!current.isWallet) return null;
|
||||
return current.wallet?.id;
|
||||
return switch (current) {
|
||||
WalletBalanceItem(:final wallet) => wallet.id,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
int? _walletIndexByRef(List<BalanceItem> items, String? walletRef) {
|
||||
if (walletRef == null || walletRef.isEmpty) return null;
|
||||
final idx = items.indexWhere(
|
||||
(item) => item.isWallet && item.wallet?.id == walletRef,
|
||||
(item) => switch (item) {
|
||||
WalletBalanceItem(:final wallet) => wallet.id == walletRef,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
if (idx < 0) return null;
|
||||
return idx;
|
||||
@@ -97,17 +126,17 @@ class BalanceCarouselController with ChangeNotifier {
|
||||
for (var i = 0; i < left.length; i++) {
|
||||
final a = left[i];
|
||||
final b = right[i];
|
||||
if (a.type != b.type) return false;
|
||||
if (a.runtimeType != b.runtimeType) return false;
|
||||
if (_itemIdentity(a) != _itemIdentity(b)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
String _itemIdentity(BalanceItem item) => switch (item.type) {
|
||||
BalanceItemType.wallet => item.wallet?.id ?? '',
|
||||
BalanceItemType.ledger => item.account?.ledgerAccountRef ?? '',
|
||||
BalanceItemType.addAction => 'add',
|
||||
String _itemIdentity(BalanceItem item) => switch (item) {
|
||||
WalletBalanceItem(:final wallet) => wallet.id,
|
||||
LedgerBalanceItem(:final account) => account.ledgerAccountRef,
|
||||
AddBalanceActionItem() => 'add',
|
||||
};
|
||||
|
||||
void _syncSelectedWallet() {
|
||||
@@ -115,10 +144,23 @@ class BalanceCarouselController with ChangeNotifier {
|
||||
if (walletsController == null || _items.isEmpty) return;
|
||||
|
||||
final current = _items[_index];
|
||||
if (!current.isWallet || current.wallet == null) return;
|
||||
|
||||
final wallet = current.wallet!;
|
||||
if (current is! WalletBalanceItem) return;
|
||||
final wallet = current.wallet;
|
||||
if (walletsController.selectedWallet?.id == wallet.id) return;
|
||||
walletsController.selectWallet(wallet);
|
||||
}
|
||||
|
||||
void _syncPageController() {
|
||||
if (!pageController.hasClients || _items.isEmpty) return;
|
||||
final current = pageController.page?.round();
|
||||
final target = _clampIndex(_index, _items.length);
|
||||
if (current == target) return;
|
||||
pageController.jumpToPage(target);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/controllers/payment/source.dart';
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
|
||||
import 'package:pweb/app/router/payout_routes.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class BalanceActionButtonState {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const BalanceActionButtonState({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
}
|
||||
|
||||
class BalanceActionsState {
|
||||
final BalanceActionButtonState topLeading;
|
||||
final BalanceActionButtonState topTrailing;
|
||||
final BalanceActionButtonState bottom;
|
||||
|
||||
const BalanceActionsState({
|
||||
required this.topLeading,
|
||||
required this.topTrailing,
|
||||
required this.bottom,
|
||||
});
|
||||
}
|
||||
|
||||
class BalanceSourceActionsController {
|
||||
const BalanceSourceActionsController();
|
||||
|
||||
BalanceActionsState wallet({
|
||||
required BuildContext context,
|
||||
required String walletRef,
|
||||
required VoidCallback onAddFunds,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return BalanceActionsState(
|
||||
topLeading: BalanceActionButtonState(
|
||||
label: l10n.operationfryTitle,
|
||||
icon: Icons.history_rounded,
|
||||
onPressed: () => _openWalletOperationHistory(context, walletRef),
|
||||
),
|
||||
topTrailing: BalanceActionButtonState(
|
||||
label: l10n.send,
|
||||
icon: Icons.send_rounded,
|
||||
onPressed: () => _sendWalletPayout(context, walletRef),
|
||||
),
|
||||
bottom: BalanceActionButtonState(
|
||||
label: '${l10n.details} / ${l10n.addFunds}',
|
||||
icon: Icons.account_balance_wallet_rounded,
|
||||
onPressed: onAddFunds,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BalanceActionsState ledger({
|
||||
required BuildContext context,
|
||||
required String ledgerAccountRef,
|
||||
required VoidCallback onAddFunds,
|
||||
required VoidCallback onWalletDetails,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return BalanceActionsState(
|
||||
topLeading: BalanceActionButtonState(
|
||||
label: '${l10n.operationfryTitle} / ${l10n.details}',
|
||||
icon: Icons.receipt_long_rounded,
|
||||
onPressed: onWalletDetails,
|
||||
),
|
||||
topTrailing: BalanceActionButtonState(
|
||||
label: l10n.send,
|
||||
icon: Icons.send_rounded,
|
||||
onPressed: () => _sendLedgerPayout(context, ledgerAccountRef),
|
||||
),
|
||||
bottom: BalanceActionButtonState(
|
||||
label: l10n.addFunds,
|
||||
icon: Icons.add_card_rounded,
|
||||
onPressed: onAddFunds,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openWalletOperationHistory(BuildContext context, String walletRef) {
|
||||
context.read<PaymentSourceController>().selectWalletByRef(walletRef);
|
||||
context.pushNamed(PayoutRoutes.editWallet);
|
||||
}
|
||||
|
||||
void _sendWalletPayout(BuildContext context, String walletRef) {
|
||||
context.read<PaymentSourceController>().selectWalletByRef(walletRef);
|
||||
context.pushNamed(
|
||||
PayoutRoutes.payment,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(
|
||||
paymentType: PaymentType.wallet,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendLedgerPayout(BuildContext context, String ledgerAccountRef) {
|
||||
context.read<PaymentSourceController>().selectLedgerByRef(ledgerAccountRef);
|
||||
context.pushNamed(
|
||||
PayoutRoutes.payment,
|
||||
queryParameters: PayoutRoutes.buildQueryParameters(
|
||||
paymentType: PaymentType.ledger,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
|
||||
class BalanceCopyState {
|
||||
final String label;
|
||||
final String payload;
|
||||
|
||||
const BalanceCopyState({required this.label, required this.payload});
|
||||
|
||||
bool get canCopy => payload.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
class BalanceSourceCopyController {
|
||||
const BalanceSourceCopyController();
|
||||
|
||||
BalanceCopyState wallet(String? depositAddress) {
|
||||
return BalanceCopyState(
|
||||
label: 'Copy Deposit Address',
|
||||
payload: depositAddress?.trim() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
BalanceCopyState ledger(String? accountCode) {
|
||||
return BalanceCopyState(
|
||||
label: 'Copy Deposit Address',
|
||||
payload: accountCode?.trim() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> copy(BalanceCopyState state) async {
|
||||
if (!state.canCopy) return false;
|
||||
await Clipboard.setData(ClipboardData(text: state.payload));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation_document.dart';
|
||||
import 'package:pshared/models/payment/execution_operation.dart';
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
import 'package:pshared/provider/payment/payments.dart';
|
||||
import 'package:pweb/models/documents/operation.dart';
|
||||
|
||||
import 'package:pweb/utils/report/operations/document_rule.dart';
|
||||
|
||||
|
||||
class PaymentDetailsController extends ChangeNotifier {
|
||||
PaymentDetailsController({required String paymentId})
|
||||
: _paymentId = paymentId;
|
||||
@@ -20,7 +21,7 @@ class PaymentDetailsController extends ChangeNotifier {
|
||||
bool get isLoading => _payments?.isLoading ?? false;
|
||||
Exception? get error => _payments?.error;
|
||||
|
||||
OperationDocumentRequestModel? operationDocumentRequest(
|
||||
OperationDocumentRef? operationDocumentRequest(
|
||||
PaymentExecutionOperation operation,
|
||||
) {
|
||||
final current = _payment;
|
||||
@@ -33,7 +34,7 @@ class PaymentDetailsController extends ChangeNotifier {
|
||||
|
||||
if (!isOperationDocumentEligible(operation.code)) return null;
|
||||
|
||||
return OperationDocumentRequestModel(
|
||||
return OperationDocumentRef(
|
||||
gatewayService: gatewayService,
|
||||
operationRef: operationRef,
|
||||
);
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/controllers/payment/source.dart';
|
||||
import 'package:pshared/models/money.dart';
|
||||
import 'package:pshared/models/payment/asset.dart';
|
||||
import 'package:pshared/models/payment/chain_network.dart';
|
||||
import 'package:pshared/models/payment/methods/data.dart';
|
||||
import 'package:pshared/models/payment/methods/ledger.dart';
|
||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
import 'package:pshared/models/payment/quote/status_type.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
|
||||
import 'package:pweb/models/payment/multiple_payouts/state.dart';
|
||||
import 'package:pweb/providers/multiple_payouts.dart';
|
||||
import 'package:pweb/services/payments/csv_input.dart';
|
||||
|
||||
|
||||
class MultiplePayoutsController extends ChangeNotifier {
|
||||
final CsvInputService _csvInput;
|
||||
MultiplePayoutsProvider? _provider;
|
||||
PaymentSourceController? _sourceController;
|
||||
_PickState _pickState = _PickState.idle;
|
||||
Exception? _uiError;
|
||||
String? _lastSourceKey;
|
||||
|
||||
MultiplePayoutsController({required CsvInputService csvInput})
|
||||
: _csvInput = csvInput;
|
||||
@@ -37,6 +43,7 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
_sourceController?.removeListener(_onSourceChanged);
|
||||
_sourceController = sourceController;
|
||||
_sourceController?.addListener(_onSourceChanged);
|
||||
_lastSourceKey = _currentSourceKey;
|
||||
shouldNotify = true;
|
||||
}
|
||||
if (shouldNotify) {
|
||||
@@ -60,16 +67,16 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
_provider?.quoteStatusType ?? QuoteStatusType.missing;
|
||||
Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
|
||||
|
||||
bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null;
|
||||
bool get canSend => (_provider?.canSend ?? false) && _selectedSource != null;
|
||||
Money? get aggregateDebitAmount =>
|
||||
_provider?.aggregateDebitAmountFor(_selectedWallet);
|
||||
_provider?.aggregateDebitAmountForCurrency(_selectedSourceCurrencyCode);
|
||||
Money? get requestedSentAmount => _provider?.requestedSentAmount;
|
||||
Money? get aggregateSettlementAmount =>
|
||||
_provider?.aggregateSettlementAmountFor(_selectedWallet);
|
||||
Money? get aggregateSettlementAmount => _provider
|
||||
?.aggregateSettlementAmountForCurrency(_selectedSourceCurrencyCode);
|
||||
Money? get aggregateFeeAmount =>
|
||||
_provider?.aggregateFeeAmountFor(_selectedWallet);
|
||||
_provider?.aggregateFeeAmountForCurrency(_selectedSourceCurrencyCode);
|
||||
double? get aggregateFeePercent =>
|
||||
_provider?.aggregateFeePercentFor(_selectedWallet);
|
||||
_provider?.aggregateFeePercentForCurrency(_selectedSourceCurrencyCode);
|
||||
|
||||
Future<void> pickAndQuote() async {
|
||||
if (_pickState == _PickState.picking) return;
|
||||
@@ -84,15 +91,16 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
try {
|
||||
final picked = await _csvInput.pickCsv();
|
||||
if (picked == null) return;
|
||||
final wallet = _selectedWallet;
|
||||
if (wallet == null) {
|
||||
_setUiError(StateError('Select source wallet first'));
|
||||
final source = _selectedSource;
|
||||
if (source == null) {
|
||||
_setUiError(StateError('Select source of funds first'));
|
||||
return;
|
||||
}
|
||||
await provider.quoteFromCsv(
|
||||
fileName: picked.name,
|
||||
content: picked.content,
|
||||
sourceWallet: wallet,
|
||||
sourceMethod: source.method,
|
||||
sourceCurrencyCode: source.currencyCode,
|
||||
);
|
||||
} catch (e) {
|
||||
_setUiError(e);
|
||||
@@ -131,10 +139,78 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _onSourceChanged() {
|
||||
final currentSourceKey = _currentSourceKey;
|
||||
final sourceChanged = currentSourceKey != _lastSourceKey;
|
||||
_lastSourceKey = currentSourceKey;
|
||||
if (sourceChanged) {
|
||||
unawaited(_requoteWithUploadedRows());
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Wallet? get _selectedWallet => _sourceController?.selectedWallet;
|
||||
String? get _selectedSourceCurrencyCode =>
|
||||
_sourceController?.selectedCurrencyCode;
|
||||
String? get _currentSourceKey {
|
||||
final source = _sourceController;
|
||||
if (source == null ||
|
||||
source.selectedType == null ||
|
||||
source.selectedRef == null) {
|
||||
return null;
|
||||
}
|
||||
return '${source.selectedType!.name}:${source.selectedRef!}';
|
||||
}
|
||||
|
||||
({PaymentMethodData method, String currencyCode})? get _selectedSource {
|
||||
final source = _sourceController;
|
||||
if (source == null) return null;
|
||||
|
||||
final currencyCode = source.selectedCurrencyCode;
|
||||
if (currencyCode == null || currencyCode.isEmpty) return null;
|
||||
|
||||
final wallet = source.selectedWallet;
|
||||
if (wallet != null) {
|
||||
final hasAsset = (wallet.tokenSymbol ?? '').isNotEmpty;
|
||||
final asset = hasAsset
|
||||
? PaymentAsset(
|
||||
chain: wallet.network ?? ChainNetwork.unspecified,
|
||||
tokenSymbol: wallet.tokenSymbol!,
|
||||
contractAddress: wallet.contractAddress,
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
method: ManagedWalletPaymentMethod(
|
||||
managedWalletRef: wallet.id,
|
||||
asset: asset,
|
||||
),
|
||||
currencyCode: currencyCode,
|
||||
);
|
||||
}
|
||||
|
||||
final ledger = source.selectedLedgerAccount;
|
||||
if (ledger != null) {
|
||||
return (
|
||||
method: LedgerPaymentMethod(ledgerAccountRef: ledger.ledgerAccountRef),
|
||||
currencyCode: currencyCode,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _requoteWithUploadedRows() async {
|
||||
final provider = _provider;
|
||||
if (provider == null) return;
|
||||
if (provider.selectedFileName == null || provider.rows.isEmpty) return;
|
||||
|
||||
final source = _selectedSource;
|
||||
if (source == null) return;
|
||||
|
||||
_clearUiError(notify: false);
|
||||
await provider.requoteUploadedRows(
|
||||
sourceMethod: source.method,
|
||||
sourceCurrencyCode: source.currencyCode,
|
||||
);
|
||||
}
|
||||
|
||||
void _setUiError(Object error) {
|
||||
_uiError = error is Exception ? error : Exception(error.toString());
|
||||
|
||||
@@ -638,7 +638,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"noFee": "No fee",
|
||||
"noFee": "None",
|
||||
|
||||
"recipientWillReceive": "Recipient will receive: {amount}",
|
||||
"@recipientWillReceive": {
|
||||
|
||||
@@ -638,7 +638,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"noFee": "Нет комиссии",
|
||||
"noFee": "Без оплаты",
|
||||
|
||||
"recipientWillReceive": "Получатель получит: {amount}",
|
||||
"@recipientWillReceive": {
|
||||
|
||||
26
frontend/pweb/lib/models/dashboard/balance_item.dart
Normal file
26
frontend/pweb/lib/models/dashboard/balance_item.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
sealed class BalanceItem {
|
||||
const BalanceItem();
|
||||
|
||||
const factory BalanceItem.wallet(Wallet wallet) = WalletBalanceItem;
|
||||
const factory BalanceItem.ledger(LedgerAccount account) = LedgerBalanceItem;
|
||||
const factory BalanceItem.addAction() = AddBalanceActionItem;
|
||||
}
|
||||
|
||||
final class WalletBalanceItem extends BalanceItem {
|
||||
final Wallet wallet;
|
||||
|
||||
const WalletBalanceItem(this.wallet);
|
||||
}
|
||||
|
||||
final class LedgerBalanceItem extends BalanceItem {
|
||||
final LedgerAccount account;
|
||||
|
||||
const LedgerBalanceItem(this.account);
|
||||
}
|
||||
|
||||
final class AddBalanceActionItem extends BalanceItem {
|
||||
const AddBalanceActionItem();
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
class OperationDocumentRequestModel {
|
||||
final String gatewayService;
|
||||
final String operationRef;
|
||||
|
||||
const OperationDocumentRequestModel({
|
||||
required this.gatewayService,
|
||||
required this.operationRef,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
enum PaymentState {
|
||||
success,
|
||||
failed,
|
||||
cancelled,
|
||||
processing,
|
||||
unknown,
|
||||
}
|
||||
|
||||
PaymentState paymentStateFromRaw(String? raw) {
|
||||
final trimmed = (raw ?? '').trim().toUpperCase();
|
||||
final normalized = trimmed.startsWith('PAYMENT_STATE_')
|
||||
? trimmed.substring('PAYMENT_STATE_'.length)
|
||||
: trimmed;
|
||||
|
||||
switch (normalized) {
|
||||
case 'SUCCESS':
|
||||
return PaymentState.success;
|
||||
case 'FAILED':
|
||||
return PaymentState.failed;
|
||||
case 'CANCELLED':
|
||||
return PaymentState.cancelled;
|
||||
case 'PROCESSING':
|
||||
return PaymentState.processing;
|
||||
default:
|
||||
return PaymentState.unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/dashboard/balance/actions_ui.dart';
|
||||
import 'package:pweb/controllers/dashboard/balance/source_actions.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/actions/hover_expandable_action_button.dart';
|
||||
|
||||
|
||||
class BalanceActionsBar extends StatefulWidget {
|
||||
final BalanceActionsState state;
|
||||
|
||||
const BalanceActionsBar({super.key, required this.state});
|
||||
|
||||
@override
|
||||
State<BalanceActionsBar> createState() => _BalanceActionsBarState();
|
||||
}
|
||||
|
||||
class _BalanceActionsBarState extends State<BalanceActionsBar> {
|
||||
static const double _buttonHeight = 34.0;
|
||||
static const double _buttonGap = 6.0;
|
||||
static const double _iconSize = 18.0;
|
||||
static const double _textGap = 8.0;
|
||||
static const double _horizontalPadding = 6.0;
|
||||
|
||||
final BalanceActionsUiController _uiController = BalanceActionsUiController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textStyle = Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w400,
|
||||
color: colorScheme.onSecondary,
|
||||
fontSize: 14,
|
||||
);
|
||||
final buttons = <BalanceActionButtonState>[
|
||||
widget.state.topLeading,
|
||||
widget.state.topTrailing,
|
||||
widget.state.bottom,
|
||||
];
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: _uiController,
|
||||
builder: (context, _) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerRight,
|
||||
minWidth: 0,
|
||||
maxWidth: double.infinity,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < buttons.length; i++) ...[
|
||||
HoverExpandableActionButton(
|
||||
height: _buttonHeight,
|
||||
icon: buttons[i].icon,
|
||||
label: buttons[i].label,
|
||||
iconSize: _iconSize,
|
||||
textStyle: textStyle,
|
||||
expanded: _uiController.isExpanded(i),
|
||||
textGap: _textGap,
|
||||
horizontalPadding: _horizontalPadding,
|
||||
onHoverChanged: (hovered) =>
|
||||
_uiController.onHoverChanged(i, hovered),
|
||||
onPressed: buttons[i].onPressed,
|
||||
),
|
||||
if (i != buttons.length - 1)
|
||||
const SizedBox(height: _buttonGap),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class HoverExpandableActionButton extends StatelessWidget {
|
||||
final double height;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final double iconSize;
|
||||
final TextStyle? textStyle;
|
||||
final bool expanded;
|
||||
final double textGap;
|
||||
final double horizontalPadding;
|
||||
final ValueChanged<bool> onHoverChanged;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const HoverExpandableActionButton({
|
||||
super.key,
|
||||
required this.height,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.iconSize,
|
||||
required this.textStyle,
|
||||
required this.expanded,
|
||||
required this.textGap,
|
||||
required this.horizontalPadding,
|
||||
required this.onHoverChanged,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => onHoverChanged(true),
|
||||
onExit: (_) => onHoverChanged(false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryFixed,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: onPressed,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: iconSize, color: colorScheme.onSecondary),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.centerRight,
|
||||
child: expanded
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(left: textGap),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.visible,
|
||||
style: textStyle,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,17 @@ class BalanceAmount extends StatelessWidget {
|
||||
final isMasked = wallets.isBalanceMasked(wallet.id);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isMasked ? '•••• $currencyBalance' : '${amountToString(wallet.balance)} $currencyBalance',
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
isMasked
|
||||
? '•••• $currencyBalance'
|
||||
: '${amountToString(wallet.balance)} $currencyBalance',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _iconSpacing),
|
||||
@@ -43,7 +48,7 @@ class BalanceAmount extends StatelessWidget {
|
||||
child: Icon(
|
||||
isMasked ? Icons.visibility_off : Icons.visibility,
|
||||
size: _iconSize,
|
||||
color: colorScheme.onSurface,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
|
||||
enum BalanceItemType { wallet, ledger, addAction }
|
||||
|
||||
class BalanceItem {
|
||||
final BalanceItemType type;
|
||||
final Wallet? wallet;
|
||||
final LedgerAccount? account;
|
||||
|
||||
const BalanceItem.wallet(this.wallet) : type = BalanceItemType.wallet, account = null;
|
||||
|
||||
const BalanceItem.ledger(this.account) : type = BalanceItemType.ledger, wallet = null;
|
||||
|
||||
const BalanceItem.addAction() : type = BalanceItemType.addAction, wallet = null, account = null;
|
||||
|
||||
bool get isWallet => type == BalanceItemType.wallet;
|
||||
bool get isLedger => type == BalanceItemType.ledger;
|
||||
bool get isAdd => type == BalanceItemType.addAction;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
import 'package:pshared/models/payment/chain_network.dart';
|
||||
import 'package:pshared/utils/l10n/chain.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
||||
import 'package:pweb/widgets/refresh_balance/wallet.dart';
|
||||
|
||||
|
||||
class WalletCard extends StatelessWidget {
|
||||
final Wallet wallet;
|
||||
final VoidCallback onTopUp;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const WalletCard({
|
||||
super.key,
|
||||
required this.wallet,
|
||||
required this.onTopUp,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified)
|
||||
? null
|
||||
: wallet.network!.localizedName(context);
|
||||
final symbol = wallet.tokenSymbol?.trim();
|
||||
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.onSecondary,
|
||||
elevation: WalletCardConfig.elevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
|
||||
onTap: onTap,
|
||||
child: SizedBox.expand(
|
||||
child: Padding(
|
||||
padding: WalletCardConfig.contentPadding,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BalanceHeader(
|
||||
title: wallet.name,
|
||||
subtitle: networkLabel,
|
||||
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
BalanceAmount(
|
||||
wallet: wallet,
|
||||
onToggleMask: () {
|
||||
context.read<WalletsController>().toggleBalanceMask(wallet.id);
|
||||
},
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
WalletBalanceRefreshButton(
|
||||
walletRef: wallet.id,
|
||||
),
|
||||
BalanceAddFunds(onTopUp: onTopUp),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/add/card.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/card.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/ledger.dart';
|
||||
|
||||
class BalanceCarousel extends StatefulWidget {
|
||||
final List<BalanceItem> items;
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onIndexChanged;
|
||||
final ValueChanged<Wallet> onTopUp;
|
||||
final ValueChanged<Wallet> onWalletTap;
|
||||
final ValueChanged<LedgerAccount> onLedgerTap;
|
||||
|
||||
const BalanceCarousel({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.currentIndex,
|
||||
required this.onIndexChanged,
|
||||
required this.onTopUp,
|
||||
required this.onWalletTap,
|
||||
required this.onLedgerTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BalanceCarousel> createState() => _BalanceCarouselState();
|
||||
}
|
||||
|
||||
class _BalanceCarouselState extends State<BalanceCarousel> {
|
||||
late final PageController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = PageController(
|
||||
initialPage: widget.currentIndex,
|
||||
viewportFraction: WalletCardConfig.viewportFraction,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BalanceCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!mounted) return;
|
||||
if (_controller.hasClients) {
|
||||
final currentPage = _controller.page?.round();
|
||||
if (currentPage != widget.currentIndex) {
|
||||
_controller.jumpToPage(widget.currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _goToPage(int index) {
|
||||
if (!_controller.hasClients) return;
|
||||
_controller.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final safeIndex = widget.currentIndex.clamp(0, widget.items.length - 1);
|
||||
final scrollBehavior = ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: const {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
},
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: WalletCardConfig.cardHeight,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.grab,
|
||||
child: ScrollConfiguration(
|
||||
behavior: scrollBehavior,
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
onPageChanged: widget.onIndexChanged,
|
||||
itemCount: widget.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.items[index];
|
||||
final Widget card = switch (item.type) {
|
||||
BalanceItemType.wallet => WalletCard(
|
||||
wallet: item.wallet!,
|
||||
onTopUp: () => widget.onTopUp(item.wallet!),
|
||||
onTap: () => widget.onWalletTap(item.wallet!),
|
||||
),
|
||||
BalanceItemType.ledger => LedgerAccountCard(
|
||||
account: item.account!,
|
||||
onTap: () => widget.onLedgerTap(item.account!),
|
||||
),
|
||||
BalanceItemType.addAction => const AddBalanceCard(),
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: WalletCardConfig.cardPadding,
|
||||
child: card,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: safeIndex > 0 ? () => _goToPage(safeIndex - 1) : null,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
CarouselIndicator(itemCount: widget.items.length, index: safeIndex),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: safeIndex < widget.items.length - 1
|
||||
? () => _goToPage(safeIndex + 1)
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/models/dashboard/balance_item.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/add/card.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/cards/ledger.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/cards/wallet.dart';
|
||||
|
||||
|
||||
class BalanceCarouselCardItem extends StatelessWidget {
|
||||
final BalanceItem item;
|
||||
final ValueChanged<Wallet> onTopUp;
|
||||
final ValueChanged<LedgerAccount> onLedgerAddFunds;
|
||||
final ValueChanged<Wallet> onWalletTap;
|
||||
final ValueChanged<LedgerAccount> onLedgerTap;
|
||||
|
||||
const BalanceCarouselCardItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onTopUp,
|
||||
required this.onLedgerAddFunds,
|
||||
required this.onWalletTap,
|
||||
required this.onLedgerTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = switch (item) {
|
||||
WalletBalanceItem(:final wallet) => WalletCard(
|
||||
wallet: wallet,
|
||||
onTopUp: () => onTopUp(wallet),
|
||||
onTap: () => onWalletTap(wallet),
|
||||
),
|
||||
LedgerBalanceItem(:final account) => LedgerAccountCard(
|
||||
account: account,
|
||||
onTap: () => onLedgerTap(account),
|
||||
onAddFunds: () => onLedgerAddFunds(account),
|
||||
),
|
||||
AddBalanceActionItem() => const AddBalanceCard(),
|
||||
};
|
||||
|
||||
return Padding(padding: WalletCardConfig.cardPadding, child: card);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/carousel/card_item.dart';
|
||||
|
||||
|
||||
class BalanceCarouselCardsView extends StatelessWidget {
|
||||
final BalanceCarouselController controller;
|
||||
final ValueChanged<Wallet> onTopUp;
|
||||
final ValueChanged<LedgerAccount> onLedgerAddFunds;
|
||||
final ValueChanged<Wallet> onWalletTap;
|
||||
final ValueChanged<LedgerAccount> onLedgerTap;
|
||||
final double height;
|
||||
|
||||
const BalanceCarouselCardsView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onTopUp,
|
||||
required this.onLedgerAddFunds,
|
||||
required this.onWalletTap,
|
||||
required this.onLedgerTap,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scrollBehavior = ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: const {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
},
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.grab,
|
||||
child: ScrollConfiguration(
|
||||
behavior: scrollBehavior,
|
||||
child: PageView.builder(
|
||||
controller: controller.pageController,
|
||||
onPageChanged: controller.onPageChanged,
|
||||
itemCount: controller.items.length,
|
||||
itemBuilder: (context, index) => BalanceCarouselCardItem(
|
||||
item: controller.items[index],
|
||||
onTopUp: onTopUp,
|
||||
onLedgerAddFunds: onLedgerAddFunds,
|
||||
onWalletTap: onWalletTap,
|
||||
onLedgerTap: onLedgerTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/carousel/cards_view.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/carousel/navigation.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||
|
||||
|
||||
class BalanceCarousel extends StatelessWidget {
|
||||
final BalanceCarouselController controller;
|
||||
final ValueChanged<Wallet> onTopUp;
|
||||
final ValueChanged<LedgerAccount> onLedgerAddFunds;
|
||||
final ValueChanged<Wallet> onWalletTap;
|
||||
final ValueChanged<LedgerAccount> onLedgerTap;
|
||||
|
||||
const BalanceCarousel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onTopUp,
|
||||
required this.onLedgerAddFunds,
|
||||
required this.onWalletTap,
|
||||
required this.onLedgerTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (controller.items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
controller.syncPageController();
|
||||
});
|
||||
|
||||
final safeIndex = controller.index.clamp(0, controller.items.length - 1);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardHeight = WalletCardConfig.cardHeightForWidth(
|
||||
constraints.maxWidth,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
BalanceCarouselCardsView(
|
||||
controller: controller,
|
||||
onTopUp: onTopUp,
|
||||
onLedgerAddFunds: onLedgerAddFunds,
|
||||
onWalletTap: onWalletTap,
|
||||
onLedgerTap: onLedgerTap,
|
||||
height: cardHeight,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BalanceCarouselNavigation(controller: controller, index: safeIndex),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
|
||||
|
||||
|
||||
class BalanceCarouselNavigation extends StatelessWidget {
|
||||
final BalanceCarouselController controller;
|
||||
final int index;
|
||||
|
||||
const BalanceCarouselNavigation({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: index > 0 ? controller.goBack : null,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
CarouselIndicator(itemCount: controller.items.length, index: index),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: index < controller.items.length - 1
|
||||
? controller.goForward
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,21 @@ import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
abstract class WalletCardConfig {
|
||||
static const double cardHeight = 145.0;
|
||||
static const double elevation = 4.0;
|
||||
static const double borderRadius = 16.0;
|
||||
static const double viewportFraction = 0.9;
|
||||
static const double viewportFraction = 0.96;
|
||||
|
||||
static const EdgeInsets cardPadding = EdgeInsets.symmetric(horizontal: 8);
|
||||
static const EdgeInsets contentPadding = EdgeInsets.all(16);
|
||||
static const EdgeInsets cardPadding = EdgeInsets.symmetric(horizontal: 6);
|
||||
static const EdgeInsets contentPadding = EdgeInsets.symmetric(
|
||||
horizontal: 28,
|
||||
vertical: 16,
|
||||
);
|
||||
|
||||
static const double dotSize = 8.0;
|
||||
static const EdgeInsets dotMargin = EdgeInsets.symmetric(horizontal: 4);
|
||||
|
||||
static double cardHeightForWidth(double width) {
|
||||
final adaptiveHeight = width * 0.18;
|
||||
return adaptiveHeight.clamp(150.0, 230.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,47 +20,52 @@ class BalanceHeader extends StatelessWidget {
|
||||
final subtitleText = subtitle?.trim();
|
||||
final badgeText = badge?.trim();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitleText != null && subtitleText.isNotEmpty)
|
||||
Text(
|
||||
subtitleText,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (badgeText != null && badgeText.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
color: colorScheme.primaryFixed,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
badgeText,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (subtitleText != null && subtitleText.isNotEmpty)
|
||||
Text(
|
||||
subtitleText,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primaryFixed,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
||||
import 'package:pweb/widgets/refresh_balance/ledger.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class LedgerAccountCard extends StatelessWidget {
|
||||
final LedgerAccount account;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const LedgerAccountCard({super.key, required this.account, this.onTap});
|
||||
|
||||
String _formatBalance() {
|
||||
final money = account.balance?.balance;
|
||||
if (money == null) return '--';
|
||||
|
||||
final amount = parseMoneyAmount(money.amount, fallback: double.nan);
|
||||
if (amount.isNaN) {
|
||||
return '${money.amount} ${money.currency}';
|
||||
}
|
||||
|
||||
try {
|
||||
final currency = currencyStringToCode(money.currency);
|
||||
final symbol = currencyCodeToSymbol(currency);
|
||||
if (symbol.trim().isEmpty) {
|
||||
return '${amountToString(amount)} ${money.currency}';
|
||||
}
|
||||
return '${amountToString(amount)} $symbol';
|
||||
} catch (_) {
|
||||
return '${amountToString(amount)} ${money.currency}';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatMaskedBalance() {
|
||||
final currency = account.currency.trim();
|
||||
if (currency.isEmpty) return '••••';
|
||||
try {
|
||||
final symbol = currencyCodeToSymbol(currencyStringToCode(currency));
|
||||
if (symbol.trim().isEmpty) {
|
||||
return '•••• $currency';
|
||||
}
|
||||
return '•••• $symbol';
|
||||
} catch (_) {
|
||||
return '•••• $currency';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final accountName = account.name.trim();
|
||||
final accountCode = account.accountCode.trim();
|
||||
final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger;
|
||||
final subtitle = accountCode.isNotEmpty ? accountCode : null;
|
||||
final badge = account.currency.trim().isEmpty
|
||||
? null
|
||||
: account.currency.toUpperCase();
|
||||
|
||||
return Card(
|
||||
color: colorScheme.onSecondary,
|
||||
elevation: WalletCardConfig.elevation,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: WalletCardConfig.contentPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BalanceHeader(title: title, subtitle: subtitle, badge: badge),
|
||||
Row(
|
||||
children: [
|
||||
Consumer<LedgerBalanceMaskController>(
|
||||
builder: (context, controller, _) {
|
||||
final isMasked = controller.isBalanceMasked(
|
||||
account.ledgerAccountRef,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
isMasked
|
||||
? _formatMaskedBalance()
|
||||
: _formatBalance(),
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () => controller.toggleBalanceMask(
|
||||
account.ledgerAccountRef,
|
||||
),
|
||||
child: Icon(
|
||||
isMasked
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
size: 24,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
LedgerBalanceRefreshButton(
|
||||
ledgerAccountRef: account.ledgerAccountRef,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
|
||||
import 'package:pweb/pages/payout_page/wallet/edit/fields/ledger/balance_formatter.dart';
|
||||
|
||||
|
||||
class LedgerBalanceAmount extends StatelessWidget {
|
||||
final LedgerAccount account;
|
||||
|
||||
const LedgerBalanceAmount({super.key, required this.account});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Consumer<LedgerBalanceMaskController>(
|
||||
builder: (context, controller, _) {
|
||||
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef);
|
||||
final balance = isMasked
|
||||
? LedgerBalanceFormatter.formatMasked(account)
|
||||
: LedgerBalanceFormatter.format(account);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
balance,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
controller.toggleBalanceMask(account.ledgerAccountRef);
|
||||
},
|
||||
child: Icon(
|
||||
isMasked ? Icons.visibility_off : Icons.visibility,
|
||||
size: 24,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/provider/ledger.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
|
||||
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
|
||||
|
||||
|
||||
class BalanceWidgetProviders extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/dashboard/balance/source_actions.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart';
|
||||
|
||||
|
||||
class LedgerSourceActions extends StatelessWidget {
|
||||
final String ledgerAccountRef;
|
||||
final VoidCallback onAddFunds;
|
||||
final VoidCallback onWalletDetails;
|
||||
|
||||
const LedgerSourceActions({
|
||||
super.key,
|
||||
required this.ledgerAccountRef,
|
||||
required this.onAddFunds,
|
||||
required this.onWalletDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const controller = BalanceSourceActionsController();
|
||||
final state = controller.ledger(
|
||||
context: context,
|
||||
ledgerAccountRef: ledgerAccountRef,
|
||||
onAddFunds: onAddFunds,
|
||||
onWalletDetails: onWalletDetails,
|
||||
);
|
||||
|
||||
return BalanceActionsBar(state: state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/dashboard/balance/source_actions.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart';
|
||||
|
||||
|
||||
class WalletSourceActions extends StatelessWidget {
|
||||
final String walletRef;
|
||||
final VoidCallback onAddFunds;
|
||||
|
||||
const WalletSourceActions({
|
||||
super.key,
|
||||
required this.walletRef,
|
||||
required this.onAddFunds,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const controller = BalanceSourceActionsController();
|
||||
final state = controller.wallet(
|
||||
context: context,
|
||||
walletRef: walletRef,
|
||||
onAddFunds: onAddFunds,
|
||||
);
|
||||
|
||||
return BalanceActionsBar(state: state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/chain_network.dart';
|
||||
import 'package:pshared/models/payment/source_type.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
import 'package:pshared/utils/l10n/chain.dart';
|
||||
|
||||
import 'package:pweb/controllers/dashboard/balance/source_copy.dart';
|
||||
import 'package:pweb/models/state/visibility.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/ledger_amount.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/actions/ledger.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/actions/wallet.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/card_layout.dart';
|
||||
import 'package:pweb/widgets/refresh_balance/ledger.dart';
|
||||
import 'package:pweb/widgets/refresh_balance/wallet.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class BalanceSourceCard extends StatelessWidget {
|
||||
final PaymentSourceType _type;
|
||||
final Wallet? _wallet;
|
||||
final LedgerAccount? _ledgerAccount;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onAddFunds;
|
||||
static const BalanceSourceCopyController _copyController =
|
||||
BalanceSourceCopyController();
|
||||
|
||||
const BalanceSourceCard.wallet({
|
||||
super.key,
|
||||
required Wallet wallet,
|
||||
required this.onTap,
|
||||
required this.onAddFunds,
|
||||
}) : _type = PaymentSourceType.wallet,
|
||||
_wallet = wallet,
|
||||
_ledgerAccount = null;
|
||||
|
||||
const BalanceSourceCard.ledger({
|
||||
super.key,
|
||||
required LedgerAccount account,
|
||||
required this.onTap,
|
||||
required this.onAddFunds,
|
||||
}) : _type = PaymentSourceType.ledger,
|
||||
_wallet = null,
|
||||
_ledgerAccount = account;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => switch (_type) {
|
||||
PaymentSourceType.wallet => _buildWalletCard(context, _wallet!),
|
||||
PaymentSourceType.ledger => _buildLedgerCard(context, _ledgerAccount!),
|
||||
};
|
||||
|
||||
Widget _buildWalletCard(BuildContext context, Wallet wallet) {
|
||||
final networkLabel =
|
||||
(wallet.network == null || wallet.network == ChainNetwork.unspecified)
|
||||
? null
|
||||
: wallet.network!.localizedName(context);
|
||||
final symbol = wallet.tokenSymbol?.trim();
|
||||
final copyState = _copyController.wallet(wallet.depositAddress);
|
||||
|
||||
return BalanceSourceCardLayout(
|
||||
title: wallet.name,
|
||||
subtitle: networkLabel,
|
||||
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
|
||||
onTap: null,
|
||||
copyLabel: copyState.label,
|
||||
canCopy: copyState.canCopy,
|
||||
onCopy: copyState.canCopy
|
||||
? () async {
|
||||
final copied = await _copyController.copy(copyState);
|
||||
if (!copied || !context.mounted) return;
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(loc.addressCopied)));
|
||||
}
|
||||
: null,
|
||||
refreshButton: WalletBalanceRefreshButton(
|
||||
walletRef: wallet.id,
|
||||
iconOnly: VisibilityState.hidden,
|
||||
),
|
||||
actions: WalletSourceActions(
|
||||
walletRef: wallet.id,
|
||||
onAddFunds: onAddFunds,
|
||||
),
|
||||
amount: BalanceAmount(
|
||||
wallet: wallet,
|
||||
onToggleMask: () {
|
||||
context.read<WalletsController>().toggleBalanceMask(wallet.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLedgerCard(BuildContext context, LedgerAccount account) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final accountName = account.name.trim();
|
||||
final accountCode = account.accountCode.trim();
|
||||
final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger;
|
||||
final badge = account.currency.trim().isEmpty
|
||||
? null
|
||||
: account.currency.toUpperCase();
|
||||
final copyState = _copyController.ledger(accountCode);
|
||||
|
||||
return BalanceSourceCardLayout(
|
||||
title: title,
|
||||
subtitle: null,
|
||||
badge: badge,
|
||||
onTap: onTap,
|
||||
copyLabel: copyState.label,
|
||||
canCopy: copyState.canCopy,
|
||||
onCopy: copyState.canCopy
|
||||
? () async {
|
||||
final copied = await _copyController.copy(copyState);
|
||||
if (!copied || !context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(loc.addressCopied)));
|
||||
}
|
||||
: null,
|
||||
refreshButton: LedgerBalanceRefreshButton(
|
||||
ledgerAccountRef: account.ledgerAccountRef,
|
||||
iconOnly: VisibilityState.hidden,
|
||||
),
|
||||
actions: LedgerSourceActions(
|
||||
ledgerAccountRef: account.ledgerAccountRef,
|
||||
onAddFunds: onAddFunds,
|
||||
onWalletDetails: onTap,
|
||||
),
|
||||
amount: LedgerBalanceAmount(account: account),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/layout/wide_body.dart';
|
||||
|
||||
|
||||
class BalanceSourceCardLayout extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? badge;
|
||||
final Widget amount;
|
||||
final Widget refreshButton;
|
||||
final Widget actions;
|
||||
final VoidCallback? onTap;
|
||||
final String copyLabel;
|
||||
final bool canCopy;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const BalanceSourceCardLayout({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.badge,
|
||||
required this.amount,
|
||||
required this.refreshButton,
|
||||
required this.actions,
|
||||
required this.onTap,
|
||||
required this.copyLabel,
|
||||
required this.canCopy,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final borderRadius = BorderRadius.circular(WalletCardConfig.borderRadius);
|
||||
|
||||
return Card(
|
||||
color: colorScheme.onSecondary,
|
||||
elevation: WalletCardConfig.elevation,
|
||||
shape: RoundedRectangleBorder(borderRadius: borderRadius),
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: onTap,
|
||||
child: SizedBox.expand(
|
||||
child: Padding(
|
||||
padding: WalletCardConfig.contentPadding,
|
||||
child: BalanceSourceBody(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
badge: badge,
|
||||
amount: amount,
|
||||
refreshButton: refreshButton,
|
||||
actions: actions,
|
||||
copyLabel: copyLabel,
|
||||
canCopy: canCopy,
|
||||
onCopy: onCopy,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart';
|
||||
|
||||
|
||||
class LedgerAccountCard extends StatelessWidget {
|
||||
final LedgerAccount account;
|
||||
final VoidCallback onAddFunds;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const LedgerAccountCard({
|
||||
super.key,
|
||||
required this.account,
|
||||
required this.onAddFunds,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BalanceSourceCard.ledger(
|
||||
account: account,
|
||||
onTap: onTap ?? () {},
|
||||
onAddFunds: onAddFunds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart';
|
||||
|
||||
|
||||
class WalletCard extends StatelessWidget {
|
||||
final Wallet wallet;
|
||||
final VoidCallback onTopUp;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const WalletCard({
|
||||
super.key,
|
||||
required this.wallet,
|
||||
required this.onTopUp,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BalanceSourceCard.wallet(
|
||||
wallet: wallet,
|
||||
onTap: onTap,
|
||||
onAddFunds: onTopUp,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class BalanceAmountWithRefresh extends StatelessWidget {
|
||||
final Widget amount;
|
||||
final Widget refreshButton;
|
||||
|
||||
const BalanceAmountWithRefresh({
|
||||
super.key,
|
||||
required this.amount,
|
||||
required this.refreshButton,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButtonTheme(
|
||||
data: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(30, 30),
|
||||
maximumSize: const Size(40, 40),
|
||||
padding: EdgeInsets.zero,
|
||||
foregroundColor: colorScheme.primary,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
child: refreshButton,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
amount,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class BalanceCopyableField extends StatelessWidget {
|
||||
final String label;
|
||||
final bool canCopy;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const BalanceCopyableField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.canCopy,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.primaryFixed, width: 0.6),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: canCopy ? onCopy : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.copy_rounded,
|
||||
size: 16,
|
||||
color: canCopy
|
||||
? colorScheme.primaryFixed
|
||||
: colorScheme.primary.withValues(alpha: 0.35),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: canCopy
|
||||
? colorScheme.primaryFixed
|
||||
: colorScheme.primary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/layout/amount_with_refresh.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/source/layout/copyable_field.dart';
|
||||
|
||||
|
||||
class BalanceSourceBody extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? badge;
|
||||
final Widget amount;
|
||||
final Widget refreshButton;
|
||||
final Widget actions;
|
||||
final String copyLabel;
|
||||
final bool canCopy;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const BalanceSourceBody({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.badge,
|
||||
required this.amount,
|
||||
required this.refreshButton,
|
||||
required this.actions,
|
||||
required this.copyLabel,
|
||||
required this.canCopy,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sideMaxWidth = constraints.maxWidth * 0.30;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: sideMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
BalanceHeader(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
badge: badge,
|
||||
),
|
||||
SizedBox(height: constraints.maxHeight * 0.06),
|
||||
BalanceCopyableField(
|
||||
label: copyLabel,
|
||||
canCopy: canCopy,
|
||||
onCopy: onCopy,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: BalanceAmountWithRefresh(
|
||||
amount: amount,
|
||||
refreshButton: refreshButton,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: sideMaxWidth),
|
||||
child: SizedBox(height: constraints.maxHeight, child: actions),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,22 @@ import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/provider/ledger.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/carousel/carousel.dart';
|
||||
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class BalanceWidget extends StatelessWidget {
|
||||
final ValueChanged<Wallet> onTopUp;
|
||||
final ValueChanged<LedgerAccount> onLedgerAddFunds;
|
||||
final ValueChanged<Wallet> onWalletTap;
|
||||
final ValueChanged<LedgerAccount> onLedgerTap;
|
||||
|
||||
const BalanceWidget({
|
||||
super.key,
|
||||
required this.onTopUp,
|
||||
required this.onLedgerAddFunds,
|
||||
required this.onWalletTap,
|
||||
required this.onLedgerTap,
|
||||
});
|
||||
@@ -45,10 +47,9 @@ class BalanceWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
final carouselWidget = BalanceCarousel(
|
||||
items: carousel.items,
|
||||
currentIndex: carousel.index,
|
||||
onIndexChanged: carousel.onPageChanged,
|
||||
controller: carousel,
|
||||
onTopUp: onTopUp,
|
||||
onLedgerAddFunds: onLedgerAddFunds,
|
||||
onWalletTap: onWalletTap,
|
||||
onLedgerTap: onLedgerTap,
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ class DashboardPage extends StatefulWidget {
|
||||
final ValueChanged<Recipient> onRecipientSelected;
|
||||
final void Function(PaymentType type) onGoToPaymentWithoutRecipient;
|
||||
final ValueChanged<Wallet> onTopUp;
|
||||
final ValueChanged<LedgerAccount> onLedgerAddFunds;
|
||||
final ValueChanged<Wallet> onWalletTap;
|
||||
final ValueChanged<LedgerAccount> onLedgerTap;
|
||||
|
||||
@@ -35,6 +36,7 @@ class DashboardPage extends StatefulWidget {
|
||||
required this.onRecipientSelected,
|
||||
required this.onGoToPaymentWithoutRecipient,
|
||||
required this.onTopUp,
|
||||
required this.onLedgerAddFunds,
|
||||
required this.onWalletTap,
|
||||
required this.onLedgerTap,
|
||||
});
|
||||
@@ -90,6 +92,7 @@ class _DashboardPageState extends State<DashboardPage> {
|
||||
BalanceWidgetProviders(
|
||||
child: BalanceWidget(
|
||||
onTopUp: widget.onTopUp,
|
||||
onLedgerAddFunds: widget.onLedgerAddFunds,
|
||||
onWalletTap: widget.onWalletTap,
|
||||
onLedgerTap: widget.onLedgerTap,
|
||||
),
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/controllers/payments/amount_field.dart';
|
||||
import 'package:pweb/models/payment/amount/mode.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/amount/mode/selector.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
@@ -6,7 +6,8 @@ import 'package:pshared/controllers/payment/source.dart';
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
|
||||
import 'package:pweb/controllers/payments/amount_field.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/amount/feild.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/amount/field.dart';
|
||||
|
||||
|
||||
class PaymentAmountWidget extends StatelessWidget {
|
||||
const PaymentAmountWidget({super.key});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user