3 Commits

Author SHA1 Message Date
Arseni
97b16542c2 ledger top up functionality and few small fixes for project architechture and design 2026-03-05 21:49:23 +03:00
Arseni
39c04beb21 Merge remote-tracking branch 'origin/main' into SEND066
merge main into SEND066
2026-03-05 21:12:43 +03:00
Arseni
d6a3a0cc5b solyanka iz fix for payout page design, ledger wallet now clickable 2026-03-05 15:48:52 +03:00
93 changed files with 1486 additions and 2598 deletions

View File

@@ -1,31 +1,10 @@
# Sendico Development Environment - Makefile # Sendico Development Environment - Makefile
# Docker Compose + Makefile build system # 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 backend-up backend-down backend-rebuild .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
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
SERVICE ?= 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 # Colors
GREEN := \033[0;32m GREEN := \033[0;32m
@@ -52,9 +31,6 @@ help:
@echo "$(YELLOW)Selective Operations:$(NC)" @echo "$(YELLOW)Selective Operations:$(NC)"
@echo " make infra-up Start infrastructure only (mongo, nats, vault)" @echo " make infra-up Start infrastructure only (mongo, nats, vault)"
@echo " make services-up Start application services only" @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 " make list-services List all available services"
@echo "" @echo ""
@echo "$(YELLOW)Build Groups:$(NC)" @echo "$(YELLOW)Build Groups:$(NC)"
@@ -253,21 +229,6 @@ services-up:
dev-bff \ dev-bff \
dev-frontend 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 check
status: status:
@$(COMPOSE) ps @$(COMPOSE) ps

View File

@@ -24,7 +24,6 @@ Financial services platform providing payment orchestration, ledger accounting,
| FX Ingestor | `api/fx/ingestor/` | FX rate ingestion | | FX Ingestor | `api/fx/ingestor/` | FX rate ingestion |
| Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway | | Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway |
| Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway | | Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway |
| Gateway Aurora | `api/gateway/aurora/` | Card payouts simulator |
| Gateway MNTX | `api/gateway/mntx/` | Card payouts | | Gateway MNTX | `api/gateway/mntx/` | Card payouts |
| Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX | | Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
| Notification | `api/notification/` | Notifications | | Notification | `api/notification/` | Notifications |
@@ -32,16 +31,6 @@ Financial services platform providing payment orchestration, ledger accounting,
| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery | | Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery |
| Frontend | `frontend/pweb/` | Flutter web UI | | Frontend | `frontend/pweb/` | Flutter web UI |
Gateway note: current dev compose workflows (`make services-up`, `make build-gateways`) use 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
Development uses Docker Compose via the Makefile. Run `make help` for all available commands. Development uses Docker Compose via the Makefile. Run `make help` for all available commands.
@@ -65,8 +54,6 @@ make status # Show service status
make logs # View all logs make logs # View all logs
make logs SERVICE=dev-ledger # View logs for a specific service make logs SERVICE=dev-ledger # View logs for a specific service
make rebuild SERVICE=dev-ledger # Rebuild and restart 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 make clean # Remove all containers and volumes
``` ```
@@ -75,10 +62,6 @@ make clean # Remove all containers and volumes
```bash ```bash
make infra-up # Start infrastructure only (MongoDB, NATS, Vault) make infra-up # Start infrastructure only (MongoDB, NATS, Vault)
make services-up # Start application services only (assumes infra is running) 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 ### Build Groups
@@ -86,8 +69,8 @@ make list-services # Show service names, ports, and descriptions
```bash ```bash
make build-core # discovery, ledger, fees, documents make build-core # discovery, ledger, fees, documents
make build-fx # oracle, ingestor make build-fx # oracle, ingestor
make build-payments # orchestrator, quotation, methods make build-payments # orchestrator
make build-gateways # chain, tron, aurora, tgsettle make build-gateways # chain, tron, mntx, tgsettle
make build-api # notification, callbacks, bff make build-api # notification, callbacks, bff
make build-frontend # Flutter web UI make build-frontend # Flutter web UI
``` ```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ replace github.com/tech/sendico/gateway/common => ../common
require ( require (
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/prometheus/client_golang v1.23.2 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/shopspring/decimal v1.4.0
github.com/tech/sendico/gateway/common v0.1.0 github.com/tech/sendico/gateway/common v0.1.0
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
@@ -35,7 +36,6 @@ require (
github.com/nats-io/nats.go v1.49.0 // indirect github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // 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/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect

View File

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

View File

@@ -101,68 +101,6 @@ 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) { func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
cfg := monetix.Config{ cfg := monetix.Config{
AllowedCurrencies: []string{"RUB"}, AllowedCurrencies: []string{"RUB"},
@@ -587,7 +525,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
n := calls.Add(1) n := calls.Add(1)
resp := monetix.APIResponse{} resp := monetix.APIResponse{}
if n == 1 { if n == 1 {
resp.Code = "10101" resp.Code = providerCodeDeclineAmountOrFrequencyLimit
resp.Message = "Decline due to amount or frequency limit" resp.Message = "Decline due to amount or frequency limit"
body, _ := json.Marshal(resp) body, _ := json.Marshal(resp)
return &http.Response{ return &http.Response{
@@ -616,7 +554,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
) )
defer processor.stopRetries() defer processor.stopRetries()
processor.dispatchThrottleInterval = 0 processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 10 * time.Millisecond } processor.retryDelayFn = func(uint32) time.Duration { return 10 * time.Millisecond }
req := validCardPayoutRequest() req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req) resp, err := processor.Submit(context.Background(), req)
@@ -643,157 +581,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
} }
} }
func TestCardPayoutProcessor_Submit_ProviderRetryUsesDelayedStrategy(t *testing.T) { func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(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: "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
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{ cfg := monetix.Config{
BaseURL: "https://monetix.test", BaseURL: "https://monetix.test",
SecretKey: "secret", SecretKey: "secret",
@@ -807,7 +595,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttentio
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
_ = calls.Add(1) _ = calls.Add(1)
resp := monetix.APIResponse{ resp := monetix.APIResponse{
Code: "10101", Code: providerCodeDeclineAmountOrFrequencyLimit,
Message: "Decline due to amount or frequency limit", Message: "Decline due to amount or frequency limit",
} }
body, _ := json.Marshal(resp) body, _ := json.Marshal(resp)
@@ -829,7 +617,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttentio
) )
defer processor.stopRetries() defer processor.stopRetries()
processor.dispatchThrottleInterval = 0 processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return time.Millisecond } processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
req := validCardPayoutRequest() req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req) resp, err := processor.Submit(context.Background(), req)
@@ -843,14 +631,14 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttentio
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
for { for {
state, ok := repo.payouts.Get(req.GetPayoutId()) state, ok := repo.payouts.Get(req.GetPayoutId())
if ok && state != nil && state.Status == model.PayoutStatusNeedsAttention { if ok && state != nil && state.Status == model.PayoutStatusFailed {
if !strings.Contains(state.FailureReason, "10101") { if !strings.Contains(state.FailureReason, providerCodeDeclineAmountOrFrequencyLimit) {
t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason) t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason)
} }
break break
} }
if time.Now().After(deadline) { if time.Now().After(deadline) {
t.Fatalf("timeout waiting for terminal needs_attention status") t.Fatalf("timeout waiting for terminal failed status")
} }
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
@@ -859,59 +647,6 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttentio
} }
} }
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) { func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
cfg := monetix.Config{ cfg := monetix.Config{
BaseURL: "https://monetix.test", BaseURL: "https://monetix.test",
@@ -952,7 +687,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
) )
defer processor.stopRetries() defer processor.stopRetries()
processor.dispatchThrottleInterval = 0 processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 5 * time.Millisecond } processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond }
req := validCardPayoutRequest() req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req) resp, err := processor.Submit(context.Background(), req)
@@ -967,7 +702,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
cb.Payment.ID = req.GetPayoutId() cb.Payment.ID = req.GetPayoutId()
cb.Payment.Status = "failed" cb.Payment.Status = "failed"
cb.Operation.Status = "failed" cb.Operation.Status = "failed"
cb.Operation.Code = "10101" cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit
cb.Operation.Message = "Decline due to amount or frequency limit" cb.Operation.Message = "Decline due to amount or frequency limit"
cb.Payment.Sum.Currency = "RUB" cb.Payment.Sum.Currency = "RUB"

View File

@@ -69,9 +69,6 @@ func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
case model.PayoutStatusFailed: case model.PayoutStatusFailed:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED 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: case model.PayoutStatusCancelled:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
default: default:

View File

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

View File

@@ -1,11 +1,13 @@
package gateway package gateway
import ( import (
"sort"
"strconv"
"strings" "strings"
) )
const (
providerCodeDeclineAmountOrFrequencyLimit = "10101"
)
type payoutFailureAction int type payoutFailureAction int
const ( const (
@@ -13,137 +15,47 @@ const (
payoutFailureActionRetry payoutFailureActionRetry
) )
type payoutRetryStrategy int
const (
payoutRetryStrategyImmediate payoutRetryStrategy = iota + 1
payoutRetryStrategyDelayed
payoutRetryStrategyStatusRefresh
)
type payoutFailureDecision struct { type payoutFailureDecision struct {
Action payoutFailureAction Action payoutFailureAction
Strategy payoutRetryStrategy Reason string
Reason string
} }
type payoutFailurePolicy struct { type payoutFailurePolicy struct {
providerCodeStrategies map[string]payoutRetryStrategy providerCodeActions map[string]payoutFailureAction
documentedProviderCodes map[string]struct{}
} }
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 { func defaultPayoutFailurePolicy() payoutFailurePolicy {
strategies := map[string]payoutRetryStrategy{}
for _, bucket := range providerRetryCodeBuckets {
if !bucket.retryable {
continue
}
registerRetryStrategy(strategies, bucket.strategy, bucket.codes...)
}
return payoutFailurePolicy{ return payoutFailurePolicy{
providerCodeStrategies: strategies, providerCodeActions: map[string]payoutFailureAction{
documentedProviderCodes: newCodeSet(providerDocumentedCodes), providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry,
},
} }
} }
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision { func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
normalized := normalizeProviderCode(code) normalized := strings.TrimSpace(code)
if normalized == "" { if normalized == "" {
return payoutFailureDecision{ return payoutFailureDecision{
Action: payoutFailureActionFail, Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate, Reason: "provider_failure",
Reason: "provider_failure",
} }
} }
if strategy, ok := p.providerCodeStrategies[normalized]; ok { if action, ok := p.providerCodeActions[normalized]; ok {
return payoutFailureDecision{ return payoutFailureDecision{
Action: payoutFailureActionRetry, Action: action,
Strategy: strategy, Reason: "provider_code_" + normalized,
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{ return payoutFailureDecision{
Action: payoutFailureActionFail, Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate, Reason: "provider_code_" + normalized,
Reason: "provider_code_" + normalized + "_unknown",
} }
} }
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision { func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
return payoutFailureDecision{ return payoutFailureDecision{
Action: payoutFailureActionRetry, Action: payoutFailureActionRetry,
Strategy: payoutRetryStrategyImmediate, Reason: "transport_failure",
Reason: "transport_failure",
} }
} }
@@ -160,40 +72,8 @@ func payoutFailureReason(code, message string) string {
} }
} }
func retryDelayForAttempt(attempt uint32, strategy payoutRetryStrategy) int { func retryDelayForAttempt(attempt uint32) int {
strategy = normalizeRetryStrategy(strategy) // Backoff in seconds by attempt number (attempt starts at 1).
// 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 { switch {
case attempt <= 1: case attempt <= 1:
return 5 return 5
@@ -205,86 +85,3 @@ func retryDelayForAttempt(attempt uint32, strategy payoutRetryStrategy) int {
return 60 return 60
} }
} }
func registerRetryStrategy(dst map[string]payoutRetryStrategy, strategy payoutRetryStrategy, codes ...string) {
if dst == nil || len(codes) == 0 {
return
}
strategy = normalizeRetryStrategy(strategy)
for _, code := range codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
dst[normalized] = strategy
}
}
func newCodeSet(codes []string) map[string]struct{} {
set := map[string]struct{}{}
for _, code := range codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
set[normalized] = struct{}{}
}
return set
}
func buildDocumentedNonRetryCodes(documented []string, retryBuckets []retryCodeBucket) []string {
documentedSet := newCodeSet(documented)
retrySet := map[string]struct{}{}
for _, bucket := range retryBuckets {
for _, code := range bucket.codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
retrySet[normalized] = struct{}{}
}
}
nonRetry := make([]string, 0, len(documentedSet))
for code := range documentedSet {
if _, ok := retrySet[code]; ok {
continue
}
nonRetry = append(nonRetry, code)
}
sort.Slice(nonRetry, func(i, j int) bool {
left, leftErr := strconv.Atoi(nonRetry[i])
right, rightErr := strconv.Atoi(nonRetry[j])
if leftErr != nil || rightErr != nil {
return nonRetry[i] < nonRetry[j]
}
return left < right
})
return nonRetry
}
func normalizeProviderCode(code string) string {
return strings.TrimSpace(code)
}
func normalizeRetryStrategy(strategy payoutRetryStrategy) payoutRetryStrategy {
switch strategy {
case payoutRetryStrategyDelayed, payoutRetryStrategyStatusRefresh:
return strategy
default:
return payoutRetryStrategyImmediate
}
}
func (s payoutRetryStrategy) String() string {
switch normalizeRetryStrategy(s) {
case payoutRetryStrategyDelayed:
return "delayed"
case payoutRetryStrategyStatusRefresh:
return "status_refresh"
default:
return "immediate"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -166,16 +166,25 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
} }
} }
result.ProviderRequestID = providerRequestID(apiResp) if apiResp.Operation.RequestID != "" {
result.ProviderStatus = providerStatus(apiResp) 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)
}
errorCode, errorMessage := providerError(apiResp) if !result.Accepted {
if !result.Accepted || isProviderStatusError(result.ProviderStatus) { result.ErrorCode = apiResp.Code
result.ErrorCode = errorCode if result.ErrorCode == "" {
if !result.Accepted && result.ErrorCode == "" {
result.ErrorCode = http.StatusText(resp.StatusCode) result.ErrorCode = http.StatusText(resp.StatusCode)
} }
result.ErrorMessage = errorMessage result.ErrorMessage = apiResp.Message
if result.ErrorMessage == "" {
result.ErrorMessage = apiResp.Operation.Message
}
} }
c.logger.Info("Monetix tokenization response", c.logger.Info("Monetix tokenization response",
@@ -279,16 +288,25 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
} }
} }
result.ProviderRequestID = providerRequestID(apiResp) if apiResp.Operation.RequestID != "" {
result.ProviderStatus = providerStatus(apiResp) 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)
}
errorCode, errorMessage := providerError(apiResp) if !result.Accepted {
if !result.Accepted || isProviderStatusError(result.ProviderStatus) { result.ErrorCode = apiResp.Code
result.ErrorCode = errorCode if result.ErrorCode == "" {
if !result.Accepted && result.ErrorCode == "" {
result.ErrorCode = http.StatusText(resp.StatusCode) result.ErrorCode = http.StatusText(resp.StatusCode)
} }
result.ErrorMessage = errorMessage result.ErrorMessage = apiResp.Message
if result.ErrorMessage == "" {
result.ErrorMessage = apiResp.Operation.Message
}
} }
if responseLog != nil { if responseLog != nil {
@@ -306,32 +324,6 @@ func normalizeExpiryYear(year int) int {
return year 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) { func normalizeRequestExpiryYear(req any) {
switch r := req.(type) { switch r := req.(type) {
case *CardPayoutRequest: case *CardPayoutRequest:

View File

@@ -175,99 +175,6 @@ 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 { type errorReadCloser struct {
err error err error
} }

View File

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

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0 go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.79.2 google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View File

@@ -210,8 +210,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -119,30 +119,25 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
return merrors.InvalidArgument("intention reference key is required", "intent_ref") return merrors.InvalidArgument("intention reference key is required", "intent_ref")
} }
existing, err := p.FindByIdempotencyKey(ctx, record.IdempotencyKey) filter := repository.Filter(fieldIdempotencyKey, record.IdempotencyKey)
if err != nil { err := p.repo.Insert(ctx, record, filter)
return err if errors.Is(err, merrors.ErrDataConflict) {
} patch := repository.Patch().
if existing != nil { Set(repository.Field(fieldOperationRef), record.OperationRef).
record.ID = existing.ID Set(repository.Field("paymentIntentId"), record.PaymentIntentID).
if record.CreatedAt.IsZero() { Set(repository.Field("quoteRef"), record.QuoteRef).
record.CreatedAt = existing.CreatedAt Set(repository.Field("intentRef"), record.IntentRef).
} Set(repository.Field("paymentRef"), record.PaymentRef).
} Set(repository.Field("outgoingLeg"), record.OutgoingLeg).
Set(repository.Field("targetChatId"), record.TargetChatID).
err = p.repo.Upsert(ctx, record) Set(repository.Field("requestedMoney"), record.RequestedMoney).
if mongo.IsDuplicateKeyError(err) { Set(repository.Field("executedMoney"), record.ExecutedMoney).
// Concurrent insert by idempotency key: resolve existing ID and retry replace-by-ID. Set(repository.Field("status"), record.Status).
existing, lookupErr := p.FindByIdempotencyKey(ctx, record.IdempotencyKey) Set(repository.Field("failureReason"), record.FailureReason).
if lookupErr != nil { Set(repository.Field("executedAt"), record.ExecutedAt).
err = lookupErr Set(repository.Field("expiresAt"), record.ExpiresAt).
} else if existing != nil { Set(repository.Field("expiredAt"), record.ExpiredAt)
record.ID = existing.ID _, err = p.repo.PatchMany(ctx, filter, patch)
if record.CreatedAt.IsZero() {
record.CreatedAt = existing.CreatedAt
}
err = p.repo.Upsert(ctx, record)
}
} }
if err != nil { if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {

View File

@@ -1,251 +0,0 @@
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] = &copyRec
*rec = copyRec
return nil
}
func TestPaymentsUpsert_ReusesExistingIDFromIdempotencyLookup(t *testing.T) {
key := "idem-existing"
existingID := bson.NewObjectID()
existingCreatedAt := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC)
repo := &fakePaymentsRepo{
records: map[string]*model.PaymentRecord{
key: {
Base: storable.Base{
ID: existingID,
CreatedAt: existingCreatedAt,
UpdatedAt: existingCreatedAt,
},
IdempotencyKey: key,
PaymentIntentID: "pi-old",
IntentRef: "intent-old",
},
},
duplicateWhenZeroID: true,
}
store := &Payments{logger: zap.NewNop(), repo: repo}
record := &model.PaymentRecord{
IdempotencyKey: key,
PaymentIntentID: "pi-new",
QuoteRef: "quote-new",
IntentRef: "intent-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,
PaymentIntentID: "pi-existing",
IntentRef: "intent-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,
PaymentIntentID: "pi-new",
QuoteRef: "quote-new",
IntentRef: "intent-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,
PaymentIntentID: "pi-existing",
IntentRef: "intent-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,
PaymentIntentID: "pi-new",
QuoteRef: "quote-new",
IntentRef: "intent-new",
}
err := store.Upsert(context.Background(), record)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "NoSuchTransaction") {
t.Fatalf("expected NoSuchTransaction error, got %v", err)
}
if repo.upsertCalls != 1 {
t.Fatalf("expected one upsert attempt before lookup failure, got %d", repo.upsertCalls)
}
}
func stringField(doc bson.D, key string) string {
for _, entry := range doc {
if entry.Key != key {
continue
}
res, _ := entry.Value.(string)
return strings.TrimSpace(res)
}
return ""
}

View File

@@ -1,87 +0,0 @@
package store
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/gateway/tgsettle/storage"
"github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
const (
treasuryTelegramUsersCollection = "treasury_telegram_users"
fieldTreasuryTelegramUserID = "telegramUserId"
)
type TreasuryTelegramUsers struct {
logger mlogger.Logger
repo repository.Repository
}
func NewTreasuryTelegramUsers(logger mlogger.Logger, db *mongo.Database) (*TreasuryTelegramUsers, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("treasury_telegram_users").With(zap.String("collection", treasuryTelegramUsersCollection))
repo := repository.CreateMongoRepository(db, treasuryTelegramUsersCollection)
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: fieldTreasuryTelegramUserID, Sort: ri.Asc}},
Unique: true,
}); err != nil {
logger.Error("Failed to create treasury telegram users user_id index", zap.Error(err), zap.String("index_field", fieldTreasuryTelegramUserID))
return nil, err
}
return &TreasuryTelegramUsers{
logger: logger,
repo: repo,
}, nil
}
func (t *TreasuryTelegramUsers) FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error) {
telegramUserID = strings.TrimSpace(telegramUserID)
if telegramUserID == "" {
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
}
var result model.TreasuryTelegramUser
err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryTelegramUserID, telegramUserID), &result)
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
t.logger.Warn("Failed to load treasury telegram user", zap.Error(err), zap.String("telegram_user_id", telegramUserID))
}
return nil, err
}
result.TelegramUserID = strings.TrimSpace(result.TelegramUserID)
result.LedgerAccountID = strings.TrimSpace(result.LedgerAccountID)
if len(result.AllowedChatIDs) > 0 {
normalized := make([]string, 0, len(result.AllowedChatIDs))
for _, next := range result.AllowedChatIDs {
next = strings.TrimSpace(next)
if next == "" {
continue
}
normalized = append(normalized, next)
}
result.AllowedChatIDs = normalized
}
if result.TelegramUserID == "" || result.LedgerAccountID == "" {
return nil, nil
}
return &result, nil
}
var _ storage.TreasuryTelegramUsersStore = (*TreasuryTelegramUsers)(nil)

View File

@@ -23,7 +23,6 @@ class PaymentIntentDTO {
final String? feeTreatment; final String? feeTreatment;
final Map<String, String>? attributes; final Map<String, String>? attributes;
final String? comment;
final CustomerDTO? customer; final CustomerDTO? customer;
const PaymentIntentDTO({ const PaymentIntentDTO({
@@ -34,12 +33,10 @@ class PaymentIntentDTO {
this.fx, this.fx,
this.settlementMode, this.settlementMode,
this.attributes, this.attributes,
this.comment,
this.customer, this.customer,
this.feeTreatment, this.feeTreatment,
}); });
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => _$PaymentIntentDTOFromJson(json);
_$PaymentIntentDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentIntentDTOToJson(this); Map<String, dynamic> toJson() => _$PaymentIntentDTOToJson(this);
} }

View File

@@ -1,11 +1,11 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/operation.dart'; import 'package:pshared/data/dto/payment/operation.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart'; import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'payment.g.dart'; part 'payment.g.dart';
@JsonSerializable() @JsonSerializable()
class PaymentDTO { class PaymentDTO {
final String? paymentRef; final String? paymentRef;
@@ -13,6 +13,7 @@ class PaymentDTO {
final String? state; final String? state;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
final PaymentIntentDTO? intent;
final List<PaymentOperationDTO> operations; final List<PaymentOperationDTO> operations;
final PaymentQuoteDTO? lastQuote; final PaymentQuoteDTO? lastQuote;
final Map<String, String>? metadata; final Map<String, String>? metadata;
@@ -24,6 +25,7 @@ class PaymentDTO {
this.state, this.state,
this.failureCode, this.failureCode,
this.failureReason, this.failureReason,
this.intent,
this.operations = const <PaymentOperationDTO>[], this.operations = const <PaymentOperationDTO>[],
this.lastQuote, this.lastQuote,
this.metadata, this.metadata,

View File

@@ -16,7 +16,6 @@ extension PaymentIntentMapper on PaymentIntent {
fx: fx?.toDTO(), fx: fx?.toDTO(),
settlementMode: settlementModeToValue(settlementMode), settlementMode: settlementModeToValue(settlementMode),
attributes: attributes, attributes: attributes,
comment: comment,
customer: customer?.toDTO(), customer: customer?.toDTO(),
feeTreatment: feeTreatmentToValue(feeTreatment), feeTreatment: feeTreatmentToValue(feeTreatment),
); );
@@ -31,7 +30,6 @@ extension PaymentIntentDTOMapper on PaymentIntentDTO {
fx: fx?.toDomain(), fx: fx?.toDomain(),
settlementMode: settlementModeFromValue(settlementMode), settlementMode: settlementModeFromValue(settlementMode),
attributes: attributes, attributes: attributes,
comment: comment,
customer: customer?.toDomain(), customer: customer?.toDomain(),
feeTreatment: feeTreatmentFromValue(feeTreatment), feeTreatment: feeTreatmentFromValue(feeTreatment),
); );

View File

@@ -1,10 +1,10 @@
import 'package:pshared/data/dto/payment/payment.dart'; import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/operation.dart'; import 'package:pshared/data/mapper/payment/operation.dart';
import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/data/mapper/payment/quote.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/state.dart';
extension PaymentDTOMapper on PaymentDTO { extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment( Payment toDomain() => Payment(
paymentRef: paymentRef, paymentRef: paymentRef,
@@ -13,6 +13,7 @@ extension PaymentDTOMapper on PaymentDTO {
orchestrationState: paymentOrchestrationStateFromValue(state), orchestrationState: paymentOrchestrationStateFromValue(state),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
intent: intent?.toDomain(),
operations: operations.map((item) => item.toDomain()).toList(), operations: operations.map((item) => item.toDomain()).toList(),
lastQuote: lastQuote?.toDomain(), lastQuote: lastQuote?.toDomain(),
metadata: metadata, metadata: metadata,
@@ -27,6 +28,7 @@ extension PaymentMapper on Payment {
state: state ?? paymentOrchestrationStateToValue(orchestrationState), state: state ?? paymentOrchestrationStateToValue(orchestrationState),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
intent: intent?.toDTO(),
operations: operations.map((item) => item.toDTO()).toList(), operations: operations.map((item) => item.toDTO()).toList(),
lastQuote: lastQuote?.toDTO(), lastQuote: lastQuote?.toDTO(),
metadata: metadata, metadata: metadata,

View File

@@ -17,7 +17,6 @@ class PaymentIntent {
final FeeTreatment feeTreatment; final FeeTreatment feeTreatment;
final SettlementMode settlementMode; final SettlementMode settlementMode;
final Map<String, String>? attributes; final Map<String, String>? attributes;
final String? comment;
final Customer? customer; final Customer? customer;
const PaymentIntent({ const PaymentIntent({
@@ -30,7 +29,6 @@ class PaymentIntent {
this.fx, this.fx,
this.settlementMode = SettlementMode.unspecified, this.settlementMode = SettlementMode.unspecified,
this.attributes, this.attributes,
this.comment,
this.customer, this.customer,
required this.feeTreatment, required this.feeTreatment,
}); });

View File

@@ -1,9 +1,9 @@
class OperationDocumentInfo { class OperationDocumentRef {
final String operationRef;
final String gatewayService; final String gatewayService;
final String operationRef;
const OperationDocumentInfo({ const OperationDocumentRef({
required this.operationRef,
required this.gatewayService, required this.gatewayService,
required this.operationRef,
}); });
} }

View File

@@ -1,4 +1,5 @@
import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/quote/quote.dart';
import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/state.dart';
@@ -9,6 +10,7 @@ class Payment {
final PaymentOrchestrationState orchestrationState; final PaymentOrchestrationState orchestrationState;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
final PaymentIntent? intent;
final List<PaymentExecutionOperation> operations; final List<PaymentExecutionOperation> operations;
final PaymentQuote? lastQuote; final PaymentQuote? lastQuote;
final Map<String, String>? metadata; final Map<String, String>? metadata;
@@ -21,6 +23,7 @@ class Payment {
required this.orchestrationState, required this.orchestrationState,
required this.failureCode, required this.failureCode,
required this.failureReason, required this.failureReason,
this.intent,
required this.operations, required this.operations,
required this.lastQuote, required this.lastQuote,
required this.metadata, required this.metadata,

View File

@@ -57,7 +57,6 @@ void main() {
), ),
amount: MoneyDTO(amount: '10', currency: 'USD'), amount: MoneyDTO(amount: '10', currency: 'USD'),
settlementMode: 'fix_received', settlementMode: 'fix_received',
comment: 'invoice-7',
), ),
); );
@@ -71,7 +70,6 @@ void main() {
final intent = json['intent'] as Map<String, dynamic>; final intent = json['intent'] as Map<String, dynamic>;
expect(intent['kind'], equals('payout')); expect(intent['kind'], equals('payout'));
expect(intent['settlement_mode'], equals('fix_received')); expect(intent['settlement_mode'], equals('fix_received'));
expect(intent['comment'], equals('invoice-7'));
expect(intent.containsKey('settlement_currency'), isFalse); expect(intent.containsKey('settlement_currency'), isFalse);
final source = intent['source'] as Map<String, dynamic>; final source = intent['source'] as Map<String, dynamic>;

View File

@@ -25,6 +25,7 @@ class PayoutRoutes {
static const walletTopUp = 'payout-wallet-top-up'; static const walletTopUp = 'payout-wallet-top-up';
static const paymentTypeQuery = 'paymentType'; static const paymentTypeQuery = 'paymentType';
static const destinationLedgerAccountRefQuery = 'destinationLedgerAccountRef';
static const reportPaymentIdQuery = 'paymentId'; static const reportPaymentIdQuery = 'paymentId';
static const dashboardPath = '/dashboard'; static const dashboardPath = '/dashboard';
@@ -40,7 +41,6 @@ class PayoutRoutes {
static const editWalletPath = '/methods/edit'; static const editWalletPath = '/methods/edit';
static const walletTopUpPath = '/wallet/top-up'; static const walletTopUpPath = '/wallet/top-up';
static String nameFor(PayoutDestination destination) { static String nameFor(PayoutDestination destination) {
switch (destination) { switch (destination) {
case PayoutDestination.dashboard: case PayoutDestination.dashboard:
@@ -126,9 +126,13 @@ class PayoutRoutes {
static Map<String, String> buildQueryParameters({ static Map<String, String> buildQueryParameters({
PaymentType? paymentType, PaymentType? paymentType,
String? destinationLedgerAccountRef,
}) { }) {
final params = <String, String>{ final params = <String, String>{
if (paymentType != null) paymentTypeQuery: paymentType.name, if (paymentType != null) paymentTypeQuery: paymentType.name,
if (destinationLedgerAccountRef != null &&
destinationLedgerAccountRef.trim().isNotEmpty)
destinationLedgerAccountRefQuery: destinationLedgerAccountRef.trim(),
}; };
return params; return params;
} }
@@ -140,35 +144,44 @@ class PayoutRoutes {
? null ? null
: PaymentType.values.firstWhereOrNull((type) => type.name == raw); : 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 { 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({ void goToPayment({
PaymentType? paymentType, PaymentType? paymentType,
}) => String? destinationLedgerAccountRef,
goNamed( }) => goNamed(
PayoutRoutes.payment, PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters( queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: paymentType, paymentType: paymentType,
destinationLedgerAccountRef: destinationLedgerAccountRef,
), ),
); );
void goToReportPayment(String paymentId) => goNamed( void goToReportPayment(String paymentId) => goNamed(
PayoutRoutes.reportPayment, PayoutRoutes.reportPayment,
queryParameters: { queryParameters: {PayoutRoutes.reportPaymentIdQuery: paymentId},
PayoutRoutes.reportPaymentIdQuery: paymentId,
},
); );
void pushToReportPayment(String paymentId) => pushNamed( void pushToReportPayment(String paymentId) => pushNamed(
PayoutRoutes.reportPayment, PayoutRoutes.reportPayment,
queryParameters: { queryParameters: {PayoutRoutes.reportPaymentIdQuery: paymentId},
PayoutRoutes.reportPaymentIdQuery: paymentId,
},
); );
void pushToWalletTopUp() => pushNamed(PayoutRoutes.walletTopUp); void pushToWalletTopUp() => pushNamed(PayoutRoutes.walletTopUp);

View File

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/ledger.dart'; import 'package:pshared/provider/ledger.dart';
@@ -227,7 +228,9 @@ RouteBase payoutShellRoute() => ShellRoute(
onGoToPaymentWithoutRecipient: (type) => onGoToPaymentWithoutRecipient: (type) =>
_startPayment(context, recipient: null, paymentType: type), _startPayment(context, recipient: null, paymentType: type),
onTopUp: (wallet) => _openWalletTopUp(context, wallet), onTopUp: (wallet) => _openWalletTopUp(context, wallet),
onLedgerAddFunds: (account) => _openLedgerAddFunds(context, account),
onWalletTap: (wallet) => _openWalletEdit(context, wallet), onWalletTap: (wallet) => _openWalletEdit(context, wallet),
onLedgerTap: (account) => _openLedgerEdit(context, account),
), ),
), ),
), ),
@@ -304,6 +307,8 @@ RouteBase payoutShellRoute() => ShellRoute(
child: PaymentPage( child: PaymentPage(
onBack: (_) => _popOrGo(context), onBack: (_) => _popOrGo(context),
initialPaymentType: PayoutRoutes.paymentTypeFromState(state), initialPaymentType: PayoutRoutes.paymentTypeFromState(state),
initialDestinationLedgerAccountRef:
PayoutRoutes.destinationLedgerAccountRefFromState(state),
fallbackDestination: fallbackDestination, fallbackDestination: fallbackDestination,
), ),
); );
@@ -340,17 +345,9 @@ RouteBase payoutShellRoute() => ShellRoute(
GoRoute( GoRoute(
name: PayoutRoutes.editWallet, name: PayoutRoutes.editWallet,
path: PayoutRoutes.editWalletPath, path: PayoutRoutes.editWalletPath,
pageBuilder: (context, state) { pageBuilder: (context, state) => NoTransitionPage(
final walletsProvider = context.read<WalletsController>(); child: WalletEditPage(onBack: () => _popOrGo(context)),
final wallet = walletsProvider.selectedWallet; ),
final loc = AppLocalizations.of(context)!;
return NoTransitionPage(
child: wallet != null
? WalletEditPage(onBack: () => _popOrGo(context))
: Center(child: Text(loc.noWalletSelected)),
);
},
), ),
GoRoute( GoRoute(
name: PayoutRoutes.walletTopUp, name: PayoutRoutes.walletTopUp,
@@ -389,10 +386,32 @@ void _openEditRecipient(BuildContext context, {required Recipient recipient}) {
} }
void _openWalletEdit(BuildContext context, Wallet wallet) { void _openWalletEdit(BuildContext context, Wallet wallet) {
context.read<PaymentSourceController>().selectWallet(wallet);
context.read<WalletsController>().selectWallet(wallet); context.read<WalletsController>().selectWallet(wallet);
context.pushToEditWallet(); context.pushToEditWallet();
} }
void _openLedgerEdit(BuildContext context, LedgerAccount account) {
context.read<PaymentSourceController>().selectLedgerByRef(
account.ledgerAccountRef,
);
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) { void _openWalletTopUp(BuildContext context, Wallet wallet) {
context.read<WalletsController>().selectWallet(wallet); context.read<WalletsController>().selectWallet(wallet);
context.pushToWalletTopUp(); context.pushToWalletTopUp();

View File

@@ -3,18 +3,24 @@ import 'dart:collection';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/source_type.dart';
import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/models/state/load_more_state.dart'; import 'package:pweb/models/state/load_more_state.dart';
import 'package:pweb/utils/report/operations/operations.dart'; import 'package:pweb/utils/report/operations/operations.dart';
import 'package:pweb/utils/report/payment_mapper.dart'; import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/utils/report/source_filter.dart';
class ReportOperationsController extends ChangeNotifier { class ReportOperationsController extends ChangeNotifier {
PaymentsProvider? _payments; PaymentsProvider? _payments;
PaymentSourceType? _sourceType;
Set<String> _sourceRefs = const <String>{};
DateTimeRange? _selectedRange; DateTimeRange? _selectedRange;
final Set<OperationStatus> _selectedStatuses = {}; final Set<OperationStatus> _selectedStatuses = {};
List<Payment> _paymentItems = const [];
List<OperationItem> _operations = const []; List<OperationItem> _operations = const [];
List<OperationItem> _filtered = const []; List<OperationItem> _filtered = const [];
@@ -36,10 +42,20 @@ class ReportOperationsController extends ChangeNotifier {
return LoadMoreState.hidden; return LoadMoreState.hidden;
} }
void update(PaymentsProvider provider) { void update(
PaymentsProvider provider, {
PaymentSourceType? sourceType,
String? sourceRef,
List<String>? sourceRefs,
}) {
if (!identical(_payments, provider)) { if (!identical(_payments, provider)) {
_payments = provider; _payments = provider;
} }
_sourceType = sourceType;
final effectiveSourceRefs =
sourceRefs ??
(sourceRef == null ? const <String>[] : <String>[sourceRef]);
_sourceRefs = _normalizeRefs(effectiveSourceRefs);
_rebuildOperations(); _rebuildOperations();
} }
@@ -74,13 +90,16 @@ class ReportOperationsController extends ChangeNotifier {
} }
void _rebuildOperations() { void _rebuildOperations() {
final items = _payments?.payments ?? const []; _paymentItems = _payments?.payments ?? const [];
_operations = items.map(mapPaymentToOperation).toList(); _operations = _paymentItems
.where(_matchesCurrentSource)
.map(mapPaymentToOperation)
.toList();
_rebuildFiltered(notify: true); _rebuildFiltered(notify: true);
} }
void _rebuildFiltered({bool notify = true}) { void _rebuildFiltered({bool notify = true}) {
_filtered = _applyFilters(_operations); _filtered = _applyFilters(sortOperations(_operations));
if (notify) { if (notify) {
notifyListeners(); notifyListeners();
} }
@@ -88,13 +107,14 @@ class ReportOperationsController extends ChangeNotifier {
List<OperationItem> _applyFilters(List<OperationItem> operations) { List<OperationItem> _applyFilters(List<OperationItem> operations) {
if (_selectedRange == null && _selectedStatuses.isEmpty) { if (_selectedRange == null && _selectedStatuses.isEmpty) {
return sortOperations(operations); return operations;
} }
final filtered = operations.where((op) { final filtered = operations.where((op) {
final statusMatch = final statusMatch =
_selectedStatuses.isEmpty || _selectedStatuses.contains(op.status); _selectedStatuses.isEmpty || _selectedStatuses.contains(op.status);
final dateMatch = _selectedRange == null || final dateMatch =
_selectedRange == null ||
isUnknownDate(op.date) || isUnknownDate(op.date) ||
(op.date.isAfter( (op.date.isAfter(
_selectedRange!.start.subtract(const Duration(seconds: 1)), _selectedRange!.start.subtract(const Duration(seconds: 1)),
@@ -105,7 +125,30 @@ class ReportOperationsController extends ChangeNotifier {
return statusMatch && dateMatch; return statusMatch && dateMatch;
}).toList(); }).toList();
return sortOperations(filtered); return filtered;
}
bool _matchesCurrentSource(Payment payment) {
final sourceType = _sourceType;
if (sourceType == null || _sourceRefs.isEmpty) return true;
for (final sourceRef in _sourceRefs) {
if (paymentMatchesSource(
payment,
sourceType: sourceType,
sourceRef: sourceRef,
)) {
return true;
}
}
return false;
}
Set<String> _normalizeRefs(List<String> refs) {
final normalized = refs
.map((value) => value.trim())
.where((value) => value.isNotEmpty)
.toSet();
return normalized;
} }
bool _isSameRange(DateTimeRange? left, DateTimeRange? right) { bool _isSameRange(DateTimeRange? left, DateTimeRange? right) {

View File

@@ -71,16 +71,24 @@ class WalletTransactionsController extends ChangeNotifier {
void _rebuildFiltered({bool notify = true}) { void _rebuildFiltered({bool notify = true}) {
final source = _provider?.transactions ?? const <WalletTransaction>[]; final source = _provider?.transactions ?? const <WalletTransaction>[];
final activeWalletId = _provider?.walletId;
_filteredTransactions = source.where((tx) { _filteredTransactions = source.where((tx) {
final walletMatch =
activeWalletId == null || tx.walletId == activeWalletId;
final statusMatch = final statusMatch =
_selectedStatuses.isEmpty || _selectedStatuses.contains(tx.status); _selectedStatuses.isEmpty || _selectedStatuses.contains(tx.status);
final typeMatch = final typeMatch =
_selectedTypes.isEmpty || _selectedTypes.contains(tx.type); _selectedTypes.isEmpty || _selectedTypes.contains(tx.type);
final dateMatch = _dateRange == null || final dateMatch =
(tx.date.isAfter(_dateRange!.start.subtract(const Duration(seconds: 1))) && _dateRange == null ||
tx.date.isBefore(_dateRange!.end.add(const Duration(seconds: 1)))); (tx.date.isAfter(
_dateRange!.start.subtract(const Duration(seconds: 1)),
) &&
tx.date.isBefore(
_dateRange!.end.add(const Duration(seconds: 1)),
));
return statusMatch && typeMatch && dateMatch; return walletMatch && statusMatch && typeMatch && dateMatch;
}).toList(); }).toList();
if (notify) notifyListeners(); if (notify) notifyListeners();

View File

@@ -1,13 +1,12 @@
import 'package:flutter/foundation.dart'; 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/execution_operation.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/models/documents/operation.dart';
import 'package:pweb/utils/report/operations/document_rule.dart'; import 'package:pweb/utils/report/operations/document_rule.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
class PaymentDetailsController extends ChangeNotifier { class PaymentDetailsController extends ChangeNotifier {
PaymentDetailsController({required String paymentId}) PaymentDetailsController({required String paymentId})
@@ -22,26 +21,7 @@ class PaymentDetailsController extends ChangeNotifier {
bool get isLoading => _payments?.isLoading ?? false; bool get isLoading => _payments?.isLoading ?? false;
Exception? get error => _payments?.error; Exception? get error => _payments?.error;
bool get canDownload { OperationDocumentRef? operationDocumentRequest(
final current = _payment;
if (current == null) return false;
if (statusFromPayment(current) != OperationStatus.success) return false;
return primaryOperationDocumentRequest != null;
}
OperationDocumentRequestModel? get primaryOperationDocumentRequest {
final current = _payment;
if (current == null) return null;
for (final operation in current.operations) {
final request = operationDocumentRequest(operation);
if (request != null) {
return request;
}
}
return null;
}
OperationDocumentRequestModel? operationDocumentRequest(
PaymentExecutionOperation operation, PaymentExecutionOperation operation,
) { ) {
final current = _payment; final current = _payment;
@@ -54,7 +34,7 @@ class PaymentDetailsController extends ChangeNotifier {
if (!isOperationDocumentEligible(operation.code)) return null; if (!isOperationDocumentEligible(operation.code)) return null;
return OperationDocumentRequestModel( return OperationDocumentRef(
gatewayService: gatewayService, gatewayService: gatewayService,
operationRef: operationRef, operationRef: operationRef,
); );

View File

@@ -638,7 +638,7 @@
} }
} }
}, },
"noFee": "No fee", "noFee": "None",
"recipientWillReceive": "Recipient will receive: {amount}", "recipientWillReceive": "Recipient will receive: {amount}",
"@recipientWillReceive": { "@recipientWillReceive": {

View File

@@ -638,7 +638,7 @@
} }
} }
}, },
"noFee": "Нет комиссии", "noFee": "Без оплаты",
"recipientWillReceive": "Получатель получит: {amount}", "recipientWillReceive": "Получатель получит: {amount}",
"@recipientWillReceive": { "@recipientWillReceive": {

View 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();
}

View File

@@ -1,9 +0,0 @@
class OperationDocumentRequestModel {
final String gatewayService;
final String operationRef;
const OperationDocumentRequestModel({
required this.gatewayService,
required this.operationRef,
});
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -1,17 +1,8 @@
import 'package:flutter/material.dart'; 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/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/source/card.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 { class WalletCard extends StatelessWidget {
@@ -28,56 +19,10 @@ class WalletCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified) return BalanceSourceCard.wallet(
? null wallet: wallet,
: wallet.network!.localizedName(context); onTap: onTap,
final symbol = wallet.tokenSymbol?.trim(); onAddFunds: onTopUp,
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),
],
),
],
),
],
),
),
),
),
); );
} }
} }

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.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/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/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.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/indicator.dart';
@@ -16,7 +17,9 @@ class BalanceCarousel extends StatefulWidget {
final int currentIndex; final int currentIndex;
final ValueChanged<int> onIndexChanged; final ValueChanged<int> onIndexChanged;
final ValueChanged<Wallet> onTopUp; final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap; final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
const BalanceCarousel({ const BalanceCarousel({
super.key, super.key,
@@ -24,7 +27,9 @@ class BalanceCarousel extends StatefulWidget {
required this.currentIndex, required this.currentIndex,
required this.onIndexChanged, required this.onIndexChanged,
required this.onTopUp, required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap, required this.onWalletTap,
required this.onLedgerTap,
}); });
@override @override
@@ -99,14 +104,18 @@ class _BalanceCarouselState extends State<BalanceCarousel> {
itemCount: widget.items.length, itemCount: widget.items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = widget.items[index]; final item = widget.items[index];
final Widget card = switch (item.type) { final Widget card = switch (item) {
BalanceItemType.wallet => WalletCard( WalletBalanceItem(:final wallet) => WalletCard(
wallet: item.wallet!, wallet: wallet,
onTopUp: () => widget.onTopUp(item.wallet!), onTopUp: () => widget.onTopUp(wallet),
onTap: () => widget.onWalletTap(item.wallet!), onTap: () => widget.onWalletTap(wallet),
), ),
BalanceItemType.ledger => LedgerAccountCard(account: item.account!), LedgerBalanceItem(:final account) => LedgerAccountCard(
BalanceItemType.addAction => const AddBalanceCard(), account: account,
onTap: () => widget.onLedgerTap(account),
onAddFunds: () => widget.onLedgerAddFunds(account),
),
AddBalanceActionItem() => const AddBalanceCard(),
}; };
return Padding( return Padding(
@@ -123,19 +132,16 @@ class _BalanceCarouselState extends State<BalanceCarousel> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
onPressed: safeIndex > 0 onPressed: safeIndex > 0 ? () => _goToPage(safeIndex - 1) : null,
? () => _goToPage(safeIndex - 1)
: null,
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
CarouselIndicator( CarouselIndicator(itemCount: widget.items.length, index: safeIndex),
itemCount: widget.items.length,
index: safeIndex,
),
const SizedBox(width: 16), const SizedBox(width: 16),
IconButton( IconButton(
onPressed: safeIndex < widget.items.length - 1 ? () => _goToPage(safeIndex + 1) : null, onPressed: safeIndex < widget.items.length - 1
? () => _goToPage(safeIndex + 1)
: null,
icon: const Icon(Icons.arrow_forward), icon: const Icon(Icons.arrow_forward),
), ),
], ],

View File

@@ -3,7 +3,8 @@ import 'package:flutter/foundation.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/provider/ledger.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';
class BalanceCarouselController with ChangeNotifier { class BalanceCarouselController with ChangeNotifier {
WalletsController? _walletsController; WalletsController? _walletsController;
@@ -73,14 +74,19 @@ class BalanceCarouselController with ChangeNotifier {
String? _currentWalletRef(List<BalanceItem> items, int index) { String? _currentWalletRef(List<BalanceItem> items, int index) {
if (items.isEmpty || index < 0 || index >= items.length) return null; if (items.isEmpty || index < 0 || index >= items.length) return null;
final current = items[index]; final current = items[index];
if (!current.isWallet) return null; return switch (current) {
return current.wallet?.id; WalletBalanceItem(:final wallet) => wallet.id,
_ => null,
};
} }
int? _walletIndexByRef(List<BalanceItem> items, String? walletRef) { int? _walletIndexByRef(List<BalanceItem> items, String? walletRef) {
if (walletRef == null || walletRef.isEmpty) return null; if (walletRef == null || walletRef.isEmpty) return null;
final idx = items.indexWhere( 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; if (idx < 0) return null;
return idx; return idx;
@@ -97,17 +103,17 @@ class BalanceCarouselController with ChangeNotifier {
for (var i = 0; i < left.length; i++) { for (var i = 0; i < left.length; i++) {
final a = left[i]; final a = left[i];
final b = right[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; if (_itemIdentity(a) != _itemIdentity(b)) return false;
} }
return true; return true;
} }
String _itemIdentity(BalanceItem item) => switch (item.type) { String _itemIdentity(BalanceItem item) => switch (item) {
BalanceItemType.wallet => item.wallet?.id ?? '', WalletBalanceItem(:final wallet) => wallet.id,
BalanceItemType.ledger => item.account?.ledgerAccountRef ?? '', LedgerBalanceItem(:final account) => account.ledgerAccountRef,
BalanceItemType.addAction => 'add', AddBalanceActionItem() => 'add',
}; };
void _syncSelectedWallet() { void _syncSelectedWallet() {
@@ -115,9 +121,8 @@ class BalanceCarouselController with ChangeNotifier {
if (walletsController == null || _items.isEmpty) return; if (walletsController == null || _items.isEmpty) return;
final current = _items[_index]; final current = _items[_index];
if (!current.isWallet || current.wallet == null) return; if (current is! WalletBalanceItem) return;
final wallet = current.wallet;
final wallet = current.wallet!;
if (walletsController.selectedWallet?.id == wallet.id) return; if (walletsController.selectedWallet?.id == wallet.id) return;
walletsController.selectWallet(wallet); walletsController.selectWallet(wallet);
} }

View File

@@ -1,124 +1,27 @@
import 'package:flutter/material.dart'; 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/models/ledger/account.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pweb/pages/dashboard/buttons/balance/source/card.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 { class LedgerAccountCard extends StatelessWidget {
final LedgerAccount account; final LedgerAccount account;
final VoidCallback onAddFunds;
final VoidCallback? onTap;
const LedgerAccountCard({super.key, required this.account}); const LedgerAccountCard({
super.key,
String _formatBalance() { required this.account,
final money = account.balance?.balance; required this.onAddFunds,
if (money == null) return '--'; this.onTap,
});
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme; return BalanceSourceCard.ledger(
final colorScheme = Theme.of(context).colorScheme; account: account,
final loc = AppLocalizations.of(context)!; onTap: onTap ?? () {},
final accountName = account.name.trim(); onAddFunds: onAddFunds,
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: 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,
),
],
),
],
),
),
); );
} }
} }

View File

@@ -0,0 +1,55 @@
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(
children: [
Flexible(
child: Text(
balance,
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,
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerSourceActions extends StatelessWidget {
final String ledgerAccountRef;
final VoidCallback onAddFunds;
const LedgerSourceActions({
super.key,
required this.ledgerAccountRef,
required this.onAddFunds,
});
@override
Widget build(BuildContext context) {
final ledgerProvider = context.watch<LedgerAccountsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy =
ledgerProvider.isWalletRefreshing(ledgerAccountRef) ||
ledgerProvider.isLoading;
final hasTarget = ledgerProvider.accounts.any(
(a) => a.ledgerAccountRef == ledgerAccountRef,
);
return BalanceActionsBar(
isRefreshBusy: isBusy,
canRefresh: hasTarget,
onRefresh: () {
context.read<LedgerAccountsProvider>().refreshBalance(ledgerAccountRef);
},
onAddFunds: onAddFunds,
refreshLabel: loc.refreshBalance,
addFundsLabel: loc.addFunds,
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart';
import 'package:pweb/generated/i18n/app_localizations.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) {
final walletsProvider = context.watch<WalletsProvider>();
final loc = AppLocalizations.of(context)!;
final isBusy =
walletsProvider.isWalletRefreshing(walletRef) ||
walletsProvider.isLoading;
final hasTarget = walletsProvider.wallets.any((w) => w.id == walletRef);
return BalanceActionsBar(
isRefreshBusy: isBusy,
canRefresh: hasTarget,
onRefresh: () {
context.read<WalletsProvider>().refreshBalance(walletRef);
},
onAddFunds: onAddFunds,
refreshLabel: loc.refreshBalance,
addFundsLabel: loc.addFunds,
);
}
}

View File

@@ -0,0 +1,99 @@
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/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/generated/i18n/app_localizations.dart';
class BalanceSourceCard extends StatelessWidget {
final PaymentSourceType _type;
final Wallet? _wallet;
final LedgerAccount? _ledgerAccount;
final VoidCallback onTap;
final VoidCallback onAddFunds;
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();
return BalanceSourceCardLayout(
title: wallet.name,
subtitle: networkLabel,
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
onTap: onTap,
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 subtitle = accountCode.isNotEmpty ? accountCode : null;
final badge = account.currency.trim().isEmpty
? null
: account.currency.toUpperCase();
return BalanceSourceCardLayout(
title: title,
subtitle: subtitle,
badge: badge,
onTap: onTap,
actions: LedgerSourceActions(
ledgerAccountRef: account.ledgerAccountRef,
onAddFunds: onAddFunds,
),
amount: LedgerBalanceAmount(account: account),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
class BalanceSourceCardLayout extends StatelessWidget {
final String title;
final String? subtitle;
final String? badge;
final Widget amount;
final Widget actions;
final VoidCallback onTap;
const BalanceSourceCardLayout({
super.key,
required this.title,
required this.subtitle,
required this.badge,
required this.amount,
required this.actions,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
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: SizedBox.expand(
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(title: title, subtitle: subtitle, badge: badge),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: amount),
const SizedBox(width: 12),
actions,
],
),
],
),
),
),
),
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/provider/ledger.dart'; import 'package:pshared/provider/ledger.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
@@ -13,12 +14,16 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceWidget extends StatelessWidget { class BalanceWidget extends StatelessWidget {
final ValueChanged<Wallet> onTopUp; final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap; final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
const BalanceWidget({ const BalanceWidget({
super.key, super.key,
required this.onTopUp, required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap, required this.onWalletTap,
required this.onLedgerTap,
}); });
@override @override
@@ -45,7 +50,9 @@ class BalanceWidget extends StatelessWidget {
currentIndex: carousel.index, currentIndex: carousel.index,
onIndexChanged: carousel.onPageChanged, onIndexChanged: carousel.onPageChanged,
onTopUp: onTopUp, onTopUp: onTopUp,
onLedgerAddFunds: onLedgerAddFunds,
onWalletTap: onWalletTap, onWalletTap: onWalletTap,
onLedgerTap: onLedgerTap,
); );
if (wallets.isEmpty && accounts.isEmpty) { if (wallets.isEmpty && accounts.isEmpty) {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
@@ -15,6 +16,7 @@ import 'package:pweb/pages/loader.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class AppSpacing { class AppSpacing {
static const double small = 10; static const double small = 10;
static const double medium = 16; static const double medium = 16;
@@ -25,14 +27,18 @@ class DashboardPage extends StatefulWidget {
final ValueChanged<Recipient> onRecipientSelected; final ValueChanged<Recipient> onRecipientSelected;
final void Function(PaymentType type) onGoToPaymentWithoutRecipient; final void Function(PaymentType type) onGoToPaymentWithoutRecipient;
final ValueChanged<Wallet> onTopUp; final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap; final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
const DashboardPage({ const DashboardPage({
super.key, super.key,
required this.onRecipientSelected, required this.onRecipientSelected,
required this.onGoToPaymentWithoutRecipient, required this.onGoToPaymentWithoutRecipient,
required this.onTopUp, required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap, required this.onWalletTap,
required this.onLedgerTap,
}); });
@override @override
@@ -86,7 +92,9 @@ class _DashboardPageState extends State<DashboardPage> {
BalanceWidgetProviders( BalanceWidgetProviders(
child: BalanceWidget( child: BalanceWidget(
onTopUp: widget.onTopUp, onTopUp: widget.onTopUp,
onLedgerAddFunds: widget.onLedgerAddFunds,
onWalletTap: widget.onWalletTap, onWalletTap: widget.onWalletTap,
onLedgerTap: widget.onLedgerTap,
), ),
), ),
const SizedBox(height: AppSpacing.small), const SizedBox(height: AppSpacing.small),

View File

@@ -5,11 +5,11 @@ import 'package:provider/provider.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/payments/amount_field.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/pages/dashboard/payouts/amount/mode/selector.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentAmountField extends StatelessWidget { class PaymentAmountField extends StatelessWidget {
const PaymentAmountField(); const PaymentAmountField();
@@ -37,10 +37,6 @@ class PaymentAmountField extends StatelessWidget {
labelText: loc.amount, labelText: loc.amount,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixText: symbol == null ? null : '$symbol\u00A0', prefixText: symbol == null ? null : '$symbol\u00A0',
helperText: switch (ui.mode) {
PaymentAmountMode.debit => loc.debitAmountLabel,
PaymentAmountMode.settlement => loc.expectedSettlementAmountLabel,
},
), ),
onChanged: ui.handleChanged, onChanged: ui.handleChanged,
), ),

View File

@@ -102,7 +102,7 @@ class PaymentFormWidget extends StatelessWidget {
children: [ children: [
detailsHeader, detailsHeader,
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(
flex: 3, flex: 3,
@@ -114,7 +114,7 @@ class PaymentFormWidget extends StatelessWidget {
), ),
const SizedBox(height: _smallSpacing), const SizedBox(height: _smallSpacing),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
flex: 3, flex: 3,

View File

@@ -6,7 +6,7 @@ import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart';
import 'package:pweb/utils/payment/payout_verification_flow.dart'; import 'package:pweb/utils/payment/verification_flow.dart';
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';

View File

@@ -26,15 +26,18 @@ class QuoteStatusCard extends StatelessWidget {
}); });
static const double _cardRadius = 12; static const double _cardRadius = 12;
static const double _cardSpacing = 12; static const double _cardSpacing = 8;
static const double _iconSize = 18; static const double _iconSize = 18;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
final foregroundColor = _resolveForegroundColor(theme, statusType); final foregroundColor = _resolveForegroundColor(theme, statusType);
final elementColor = _resolveElementColor(theme, statusType); final elementColor = _resolveElementColor(theme, statusType);
final statusStyle = theme.textTheme.bodyMedium?.copyWith(color: elementColor); final statusStyle = theme.textTheme.bodyMedium?.copyWith(
color: elementColor,
);
final helperStyle = theme.textTheme.bodySmall?.copyWith( final helperStyle = theme.textTheme.bodySmall?.copyWith(
color: foregroundColor.withValues(alpha: 0.8), color: foregroundColor.withValues(alpha: 0.8),
); );
@@ -44,12 +47,10 @@ class QuoteStatusCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _resolveCardColor(theme, statusType), color: _resolveCardColor(theme, statusType),
borderRadius: BorderRadius.circular(_cardRadius), borderRadius: BorderRadius.circular(_cardRadius),
border: Border.all( border: Border.all(color: elementColor.withValues(alpha: 0.5)),
color: elementColor.withValues(alpha: 0.5),
),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
@@ -59,7 +60,9 @@ class QuoteStatusCard extends StatelessWidget {
height: _iconSize, height: _iconSize,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(foregroundColor), valueColor: AlwaysStoppedAnimation<Color>(
foregroundColor,
),
), ),
) )
: Icon( : Icon(
@@ -81,19 +84,15 @@ class QuoteStatusCard extends StatelessWidget {
], ],
), ),
), ),
if (canRefresh) if (canRefresh) ...[
Padding( const SizedBox(width: _cardSpacing),
padding: const EdgeInsets.only(left: _cardSpacing), IconButton(
child: showPrimaryRefresh onPressed: onRefresh,
? ElevatedButton( tooltip: loc.quoteRefresh,
onPressed: canRefresh ? onRefresh : null, icon: const Icon(Icons.refresh),
child: Text(AppLocalizations.of(context)!.quoteRefresh), color: showPrimaryRefresh ? foregroundColor : elementColor,
)
: TextButton(
onPressed: canRefresh ? onRefresh : null,
child: Text(AppLocalizations.of(context)!.quoteRefresh),
),
), ),
],
], ],
), ),
); );

View File

@@ -23,7 +23,7 @@ class RecipientAvatar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textColor = Theme.of(context).colorScheme.onPrimary; final textColor = Theme.of(context).colorScheme.onSecondary;
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -31,7 +31,7 @@ class RecipientAvatar extends StatelessWidget {
CircleAvatar( CircleAvatar(
radius: avatarRadius, radius: avatarRadius,
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null, backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null,
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primaryFixed,
child: avatarUrl == null child: avatarUrl == null
? Text( ? Text(
getInitials(name), getInitials(name),

View File

@@ -7,54 +7,56 @@ import 'package:pweb/pages/dashboard/payouts/single/address_book/avatar.dart';
class ShortListAddressBookPayout extends StatelessWidget { class ShortListAddressBookPayout extends StatelessWidget {
final List<Recipient> recipients; final List<Recipient> recipients;
final ValueChanged<Recipient> onSelected; final ValueChanged<Recipient> onSelected;
final Widget? trailing; final Widget? leading;
const ShortListAddressBookPayout({ const ShortListAddressBookPayout({
super.key, super.key,
required this.recipients, required this.recipients,
required this.onSelected, required this.onSelected,
this.trailing, this.leading,
}); });
static const double _avatarRadius = 20; static const double _avatarRadius = 20;
static const double _avatarSize = 80; static const double _avatarSize = 80;
static const EdgeInsets _padding = EdgeInsets.symmetric(horizontal: 10, vertical: 8); static const EdgeInsets _padding = EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
);
static const TextStyle _nameStyle = TextStyle(fontSize: 12); static const TextStyle _nameStyle = TextStyle(fontSize: 12);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final trailingWidget = trailing; final leadingWidget = leading;
final recipientItems = recipients.map((recipient) {
return Padding(
padding: _padding,
child: InkWell(
borderRadius: BorderRadius.circular(5),
hoverColor: Theme.of(context).colorScheme.onTertiary,
onTap: () => onSelected(recipient),
child: SizedBox(
height: _avatarSize,
width: _avatarSize,
child: RecipientAvatar(
isVisible: true,
name: recipient.name,
avatarUrl: recipient.avatarUrl,
avatarRadius: _avatarRadius,
nameStyle: _nameStyle,
),
),
),
);
});
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: children: [
recipients.map((recipient) { if (leadingWidget != null)
return Padding( Padding(padding: _padding, child: leadingWidget),
padding: _padding, ...recipientItems,
child: InkWell( ],
borderRadius: BorderRadius.circular(5),
hoverColor: Theme.of(context).colorScheme.primaryContainer,
onTap: () => onSelected(recipient),
child: SizedBox(
height: _avatarSize,
width: _avatarSize,
child: RecipientAvatar(
isVisible: true,
name: recipient.name,
avatarUrl: recipient.avatarUrl,
avatarRadius: _avatarRadius,
nameStyle: _nameStyle,
),
),
),
);
}).toList()
..addAll(
trailingWidget == null
? const []
: [Padding(padding: _padding, child: trailingWidget)],
),
), ),
); );
} }

View File

@@ -21,10 +21,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class AddressBookPayout extends StatefulWidget { class AddressBookPayout extends StatefulWidget {
final ValueChanged<Recipient> onSelected; final ValueChanged<Recipient> onSelected;
const AddressBookPayout({ const AddressBookPayout({super.key, required this.onSelected});
super.key,
required this.onSelected,
});
@override @override
State<AddressBookPayout> createState() => _AddressBookPayoutState(); State<AddressBookPayout> createState() => _AddressBookPayoutState();
@@ -71,6 +68,7 @@ class _AddressBookPayoutState extends State<AddressBookPayout> {
provider.setCurrentObject(null); provider.setCurrentObject(null);
context.pushNamed(PayoutRoutes.addRecipient); context.pushNamed(PayoutRoutes.addRecipient);
} }
final filteredRecipients = filterRecipients( final filteredRecipients = filterRecipients(
recipients: recipients, recipients: recipients,
query: _query, query: _query,
@@ -81,16 +79,18 @@ class _AddressBookPayoutState extends State<AddressBookPayout> {
} }
if (provider.error != null) { if (provider.error != null) {
return Center(child: Text(loc.notificationError(provider.error ?? loc.noErrorInformation))); return Center(
child: Text(
loc.notificationError(provider.error ?? loc.noErrorInformation),
),
);
} }
return SizedBox( return SizedBox(
height: _isExpanded ? _expandedHeight : _collapsedHeight, height: _isExpanded ? _expandedHeight : _collapsedHeight,
child: Card( child: Card(
margin: const EdgeInsets.all(_cardMargin), margin: const EdgeInsets.all(_cardMargin),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
elevation: 4, elevation: 4,
color: Theme.of(context).colorScheme.onSecondary, color: Theme.of(context).colorScheme.onSecondary,
child: Padding( child: Padding(
@@ -105,27 +105,27 @@ class _AddressBookPayoutState extends State<AddressBookPayout> {
const SizedBox(height: _spacingBetween), const SizedBox(height: _spacingBetween),
Expanded( Expanded(
child: recipients.isEmpty child: recipients.isEmpty
? Center( ? Center(
child: AddRecipientTile( child: AddRecipientTile(
label: loc.addRecipient, label: loc.addRecipient,
onTap: onAddRecipient, onTap: onAddRecipient,
), ),
) )
: _isExpanded && filteredRecipients.isEmpty : _isExpanded && filteredRecipients.isEmpty
? AddressBookPlaceholder(text: loc.noRecipientsFound) ? AddressBookPlaceholder(text: loc.noRecipientsFound)
: _isExpanded : _isExpanded
? LongListAddressBookPayout( ? LongListAddressBookPayout(
filteredRecipients: filteredRecipients, filteredRecipients: filteredRecipients,
onSelected: widget.onSelected, onSelected: widget.onSelected,
) )
: ShortListAddressBookPayout( : ShortListAddressBookPayout(
recipients: recipients, recipients: recipients,
onSelected: widget.onSelected, onSelected: widget.onSelected,
trailing: AddRecipientTile( leading: AddRecipientTile(
label: loc.addRecipient, label: loc.addRecipient,
onTap: onAddRecipient, onTap: onAddRecipient,
),
), ),
),
), ),
], ],
), ),

View File

@@ -5,19 +5,21 @@ import 'package:pshared/models/recipient/recipient.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/controllers/payments/page_ui.dart'; import 'package:pweb/controllers/payments/page_ui.dart';
import 'package:pweb/pages/payout_page/send/page_handlers.dart'; import 'package:pweb/utils/payment/page_handlers.dart';
import 'package:pweb/pages/payout_page/send/page_view.dart'; import 'package:pweb/pages/payout_page/send/page_view.dart';
class PaymentPage extends StatefulWidget { class PaymentPage extends StatefulWidget {
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
final PaymentType? initialPaymentType; final PaymentType? initialPaymentType;
final String? initialDestinationLedgerAccountRef;
final PayoutDestination fallbackDestination; final PayoutDestination fallbackDestination;
const PaymentPage({ const PaymentPage({
super.key, super.key,
this.onBack, this.onBack,
this.initialPaymentType, this.initialPaymentType,
this.initialDestinationLedgerAccountRef,
this.fallbackDestination = PayoutDestination.dashboard, this.fallbackDestination = PayoutDestination.dashboard,
}); });
@@ -34,7 +36,11 @@ class _PaymentPageState extends State<PaymentPage> {
_uiController = PaymentPageUiController(); _uiController = PaymentPageUiController();
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => initializePaymentPage(context, widget.initialPaymentType), (_) => initializePaymentPage(
context,
widget.initialPaymentType,
destinationLedgerAccountRef: widget.initialDestinationLedgerAccountRef,
),
); );
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart';
import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
@@ -14,6 +15,7 @@ import 'package:pweb/controllers/payments/page_ui.dart';
import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart';
import 'package:pweb/models/state/control_state.dart'; import 'package:pweb/models/state/control_state.dart';
class PaymentPageView extends StatelessWidget { class PaymentPageView extends StatelessWidget {
final PaymentPageUiController uiController; final PaymentPageUiController uiController;
final ValueChanged<Recipient?>? onBack; final ValueChanged<Recipient?>? onBack;
@@ -47,6 +49,7 @@ class PaymentPageView extends StatelessWidget {
final uiController = context.watch<PaymentPageUiController>(); final uiController = context.watch<PaymentPageUiController>();
final methodsProvider = context.watch<PaymentMethodsProvider>(); final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>(); final recipientProvider = context.watch<RecipientsProvider>();
final flowProvider = context.watch<PaymentFlowProvider>();
final quotationProvider = context.watch<QuotationProvider>(); final quotationProvider = context.watch<QuotationProvider>();
final verificationController = context final verificationController = context
.watch<PayoutVerificationController>(); .watch<PayoutVerificationController>();
@@ -58,10 +61,12 @@ class PaymentPageView extends StatelessWidget {
recipients: recipientProvider.recipients, recipients: recipientProvider.recipients,
query: uiController.query, query: uiController.query,
); );
final hasDestinationSelection =
flowProvider.selectedPaymentData != null;
final sendState = final sendState =
verificationController.isCooldownActiveFor(verificationContextKey) verificationController.isCooldownActiveFor(verificationContextKey)
? ControlState.disabled ? ControlState.disabled
: (recipient == null : (!hasDestinationSelection
? ControlState.disabled ? ControlState.disabled
: ControlState.enabled); : ControlState.enabled);

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/utils/dimensions.dart';
class PaymentInfoManualDetailsSection extends StatelessWidget {
final AppDimensions dimensions;
final String title;
final VisibilityState titleVisibility;
final PaymentMethodData data;
const PaymentInfoManualDetailsSection({
super.key,
required this.dimensions,
required this.title,
required this.titleVisibility,
required this.data,
});
@override
Widget build(BuildContext context) {
final entry = RecipientMethodDraft(type: data.type, data: data);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentInfoHeader(
dimensions: dimensions,
title: title,
visibility: titleVisibility,
),
PaymentMethodPanel(
selectedType: data.type,
selectedIndex: 0,
entries: [entry],
onRemove: (_) {},
onChanged: (_, ignored) {},
editState: ControlState.disabled,
deleteVisibility: VisibilityState.hidden,
),
],
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:pshared/provider/payment/flow.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_section.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_section.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_state.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/methods_state.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/manual_details.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_methods.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_methods.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_recipient.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/no_recipient.dart';
import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/models/state/visibility.dart';
@@ -35,8 +36,9 @@ class PaymentInfoSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final flowProvider = context.watch<PaymentFlowProvider>(); final flowProvider = context.watch<PaymentFlowProvider>();
final manualData = flowProvider.manualPaymentData;
if (!flowProvider.hasRecipient) { if (!flowProvider.hasRecipient && manualData == null) {
return PaymentInfoNoRecipientSection( return PaymentInfoNoRecipientSection(
dimensions: dimensions, dimensions: dimensions,
title: loc.paymentInfo, title: loc.paymentInfo,
@@ -44,6 +46,15 @@ class PaymentInfoSection extends StatelessWidget {
); );
} }
if (!flowProvider.hasRecipient && manualData != null) {
return PaymentInfoManualDetailsSection(
dimensions: dimensions,
title: loc.paymentInfo,
titleVisibility: titleVisibility,
data: manualData,
);
}
final methods = flowProvider.methodsForRecipient; final methods = flowProvider.methodsForRecipient;
final types = visiblePaymentTypes; final types = visiblePaymentTypes;

View File

@@ -81,7 +81,7 @@ class RecipientSection extends StatelessWidget {
ShortListAddressBookPayout( ShortListAddressBookPayout(
recipients: recipientProvider.recipients, recipients: recipientProvider.recipients,
onSelected: onRecipientSelected, onSelected: onRecipientSelected,
trailing: AddRecipientTile( leading: AddRecipientTile(
label: loc.addRecipient, label: loc.addRecipient,
onTap: onAddRecipient, onTap: onAddRecipient,
), ),

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/section.dart'; import 'package:pweb/pages/payout_page/send/widgets/payment_info/section.dart';
@@ -46,24 +49,30 @@ class PaymentRecipientDetailsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final flowProvider = context.watch<PaymentFlowProvider>();
final isRecipientSelectionLocked =
!flowProvider.hasRecipient && flowProvider.manualPaymentData != null;
return PaymentSectionCard( return PaymentSectionCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
RecipientSection( if (!isRecipientSelectionLocked) ...[
recipient: recipient, RecipientSection(
dimensions: dimensions, recipient: recipient,
recipientProvider: recipientProvider, dimensions: dimensions,
searchQuery: searchQuery, recipientProvider: recipientProvider,
filteredRecipients: filteredRecipients, searchQuery: searchQuery,
searchController: searchController, filteredRecipients: filteredRecipients,
searchFocusNode: searchFocusNode, searchController: searchController,
onSearchChanged: onSearchChanged, searchFocusNode: searchFocusNode,
onRecipientSelected: onRecipientSelected, onSearchChanged: onSearchChanged,
onRecipientCleared: onRecipientCleared, onRecipientSelected: onRecipientSelected,
onAddRecipient: onAddRecipient, onRecipientCleared: onRecipientCleared,
), onAddRecipient: onAddRecipient,
SizedBox(height: dimensions.paddingMedium), ),
SizedBox(height: dimensions.paddingMedium),
],
PaymentInfoSection( PaymentInfoSection(
dimensions: dimensions, dimensions: dimensions,
titleVisibility: VisibilityState.hidden, titleVisibility: VisibilityState.hidden,

View File

@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/pages/payout_page/wallet/edit/buttons/send.dart'; import 'package:pweb/pages/payout_page/wallet/edit/buttons/send.dart';
import 'package:pweb/pages/payout_page/wallet/edit/buttons/top_up.dart'; import 'package:pweb/pages/payout_page/wallet/edit/buttons/top_up.dart';
import 'package:pshared/provider/payment/wallets.dart';
class ButtonsWalletWidget extends StatelessWidget { class ButtonsWalletWidget extends StatelessWidget {
@@ -12,25 +13,20 @@ class ButtonsWalletWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<WalletsProvider>(); final source = context.watch<PaymentSourceController>();
if (!source.hasSources) return const SizedBox.shrink();
if (provider.wallets.isEmpty) return const SizedBox.shrink();
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Expanded( Expanded(child: SendPayoutButton()),
child: SendPayoutButton(), VerticalDivider(
), color: Theme.of(context).colorScheme.primary,
VerticalDivider( thickness: 1,
color: Theme.of(context).colorScheme.primary, width: 10,
thickness: 1, ),
width: 10, Expanded(child: TopUpButton()),
), ],
Expanded(
child: TopUpButton(),
),
],
); );
} }
} }

View File

@@ -4,7 +4,8 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source_type.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/app/router/payout_routes.dart';
@@ -18,24 +19,27 @@ class SendPayoutButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return ElevatedButton( final source = context.watch<PaymentSourceController>();
style: ElevatedButton.styleFrom(
shadowColor: null,
elevation: 0,
),
onPressed: () {
final wallets = context.read<WalletsController>();
final wallet = wallets.selectedWallet;
if (wallet != null) { final sourceType = source.selectedType;
context.pushNamed( final paymentType = switch (sourceType) {
PayoutRoutes.payment, PaymentSourceType.wallet => PaymentType.wallet,
queryParameters: PayoutRoutes.buildQueryParameters( PaymentSourceType.ledger => PaymentType.ledger,
paymentType: PaymentType.wallet, _ => null,
), };
);
} return ElevatedButton(
}, style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0),
onPressed: paymentType == null
? null
: () {
context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: paymentType,
),
);
},
child: Text(loc.payoutNavSendPayout), child: Text(loc.payoutNavSendPayout),
); );
} }

View File

@@ -1,35 +1,55 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source_type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class TopUpButton extends StatelessWidget{ class TopUpButton extends StatelessWidget {
const TopUpButton({super.key}); const TopUpButton({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final source = context.watch<PaymentSourceController>();
final selectedType = source.selectedType;
final selectedLedger = source.selectedLedgerAccount;
final canTopUp =
selectedType == PaymentSourceType.wallet ||
(selectedType == PaymentSourceType.ledger && selectedLedger != null);
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0),
shadowColor: null, onPressed: !canTopUp
elevation: 0, ? null
), : () {
onPressed: () { if (selectedType == PaymentSourceType.wallet) {
final wallet = context.read<WalletsController>().selectedWallet; context.pushToWalletTopUp();
if (wallet == null) { return;
ScaffoldMessenger.of(context).showSnackBar( }
SnackBar(content: Text(loc.noWalletSelected)),
); if (selectedType == PaymentSourceType.ledger &&
return; selectedLedger != null) {
} context.read<RecipientsProvider>().setCurrentObject(null);
context.pushToWalletTopUp(); context.pushNamed(
}, PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: PaymentType.ledger,
destinationLedgerAccountRef:
selectedLedger.ledgerAccountRef,
),
);
}
},
child: Text(loc.topUpBalance), child: Text(loc.topUpBalance),
); );
} }

View File

@@ -1,55 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/pages/payout_page/wallet/edit/fields/ledger/section.dart';
import 'package:pweb/pages/payout_page/wallet/edit/fields/wallet/wallet_section.dart';
class WalletEditFields extends StatelessWidget { class WalletEditFields extends StatelessWidget {
const WalletEditFields({super.key}); const WalletEditFields({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<WalletsController>( return Consumer<PaymentSourceController>(
builder: (context, controller, _) { builder: (context, sourceController, _) {
final wallet = controller.selectedWallet; final wallet = sourceController.selectedWallet;
if (wallet != null) {
if (wallet == null) { return WalletSection(wallet: wallet);
return SizedBox.shrink();
} }
return Column( final ledger = sourceController.selectedLedgerAccount;
crossAxisAlignment: CrossAxisAlignment.start, if (ledger != null) {
children: [ return LedgerSection(ledger: ledger);
Row( }
children: [
Expanded( return const SizedBox.shrink();
child: BalanceAmount(
wallet: wallet,
onToggleMask: () => controller.toggleBalanceMask(wallet.id),
),
),
WalletBalanceRefreshButton(walletRef: wallet.id),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(wallet.walletUserID, style: Theme.of(context).textTheme.bodyLarge),
IconButton(
icon: Icon(Icons.copy),
iconSize: 18,
onPressed: () => Clipboard.setData(ClipboardData(text: wallet.walletUserID)),
),
],
),
],
);
}, },
); );
} }

View File

@@ -0,0 +1,44 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
class LedgerBalanceFormatter {
const LedgerBalanceFormatter._();
static String format(LedgerAccount account) {
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}';
}
}
static String formatMasked(LedgerAccount account) {
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';
}
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class LedgerBalanceRow extends StatelessWidget {
final String balance;
final bool isMasked;
final VoidCallback onToggleMask;
const LedgerBalanceRow({
super.key,
required this.balance,
required this.isMasked,
required this.onToggleMask,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Flexible(
child: Text(
balance,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onToggleMask,
child: Icon(
isMasked ? Icons.visibility_off : Icons.visibility,
size: 22,
),
),
],
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
import 'package:pweb/pages/payout_page/wallet/edit/fields/ledger/balance_row.dart';
import 'package:pweb/pages/payout_page/wallet/edit/fields/shared/copyable_value_row.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart';
class LedgerSection extends StatelessWidget {
final LedgerAccount ledger;
const LedgerSection({super.key, required this.ledger});
@override
Widget build(BuildContext context) {
return Consumer<LedgerBalanceMaskController>(
builder: (context, balanceMask, _) {
final isMasked = balanceMask.isBalanceMasked(ledger.ledgerAccountRef);
final accountCode = ledger.accountCode.trim();
final hasAccountCode = accountCode.isNotEmpty;
final balance = isMasked
? LedgerBalanceFormatter.formatMasked(ledger)
: LedgerBalanceFormatter.format(ledger);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: LedgerBalanceRow(
balance: balance,
isMasked: isMasked,
onToggleMask: () {
balanceMask.toggleBalanceMask(ledger.ledgerAccountRef);
},
),
),
LedgerBalanceRefreshButton(
ledgerAccountRef: ledger.ledgerAccountRef,
),
],
),
const SizedBox(height: 8),
CopyableValueRow(
value: hasAccountCode ? accountCode : '-',
canCopy: hasAccountCode,
onCopy: hasAccountCode
? () {
Clipboard.setData(ClipboardData(text: accountCode));
}
: null,
overflow: TextOverflow.ellipsis,
wrapValueWithFlexible: true,
),
],
);
},
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
class CopyableValueRow extends StatelessWidget {
final String value;
final bool canCopy;
final VoidCallback? onCopy;
final TextOverflow overflow;
final bool wrapValueWithFlexible;
const CopyableValueRow({
super.key,
required this.value,
required this.canCopy,
required this.onCopy,
this.overflow = TextOverflow.visible,
this.wrapValueWithFlexible = false,
});
@override
Widget build(BuildContext context) {
final valueText = Text(
value,
style: Theme.of(context).textTheme.bodyLarge,
overflow: overflow,
);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (wrapValueWithFlexible) Flexible(child: valueText) else valueText,
IconButton(
icon: const Icon(Icons.copy),
iconSize: 18,
onPressed: canCopy ? onCopy : null,
),
],
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/pages/payout_page/wallet/edit/fields/shared/copyable_value_row.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
class WalletSection extends StatelessWidget {
final Wallet wallet;
const WalletSection({super.key, required this.wallet});
@override
Widget build(BuildContext context) {
final depositAddress = wallet.depositAddress?.trim();
final hasDepositAddress =
depositAddress != null && depositAddress.isNotEmpty;
final copyAddress = hasDepositAddress ? depositAddress : '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: BalanceAmount(
wallet: wallet,
onToggleMask: () {
context.read<WalletsController>().toggleBalanceMask(
wallet.id,
);
},
),
),
WalletBalanceRefreshButton(walletRef: wallet.id),
],
),
const SizedBox(height: 8),
CopyableValueRow(
value: hasDepositAddress ? depositAddress : '-',
canCopy: hasDepositAddress,
onCopy: hasDepositAddress
? () {
Clipboard.setData(ClipboardData(text: copyAddress));
}
: null,
),
],
);
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -12,15 +12,20 @@ class WalletEditHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = context.watch<WalletsController>(); final controller = context.watch<PaymentSourceController>();
final wallet = controller.selectedWallet; final wallet = controller.selectedWallet;
final ledger = controller.selectedLedgerAccount;
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
if (wallet == null) { if (wallet == null && ledger == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final theme = Theme.of(context); final theme = Theme.of(context);
final title = wallet != null
? loc.paymentTypeCryptoWallet
: loc.paymentTypeLedger;
final subtitle = wallet?.tokenSymbol;
return Row( return Row(
spacing: 8, spacing: 8,
@@ -32,14 +37,14 @@ class WalletEditHeader extends StatelessWidget {
spacing: 4, spacing: 4,
children: [ children: [
Text( Text(
loc.paymentTypeCryptoWallet, title,
style: theme.textTheme.headlineMedium!.copyWith( style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
if (wallet.tokenSymbol != null) if (subtitle != null && subtitle.trim().isNotEmpty)
Text( Text(
wallet.tokenSymbol!, subtitle,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),

View File

@@ -2,17 +2,16 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/pages/payout_page/wallet/edit/buttons/buttons.dart'; import 'package:pweb/pages/payout_page/wallet/edit/buttons/buttons.dart';
import 'package:pweb/pages/payout_page/wallet/edit/fields.dart'; import 'package:pweb/pages/payout_page/wallet/edit/fields.dart';
import 'package:pweb/pages/payout_page/wallet/edit/header.dart'; import 'package:pweb/pages/payout_page/wallet/edit/header.dart';
import 'package:pweb/pages/payout_page/wallet/history/history.dart';
import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
//TODO make this page more generic and reusable
class WalletEditPage extends StatelessWidget { class WalletEditPage extends StatelessWidget {
final VoidCallback onBack; final VoidCallback onBack;
@@ -23,11 +22,11 @@ class WalletEditPage extends StatelessWidget {
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Consumer<WalletsController>( return Consumer<PaymentSourceController>(
builder: (context, controller, child) { builder: (context, controller, child) {
final wallet = controller.selectedWallet; final sourceType = controller.selectedType;
if (wallet == null) { if (sourceType == null) {
return Center(child: Text(loc.noWalletSelected)); return Center(child: Text(loc.noWalletSelected));
} }
@@ -36,11 +35,15 @@ class WalletEditPage extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
ConstrainedBox( ConstrainedBox(
constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth), constraints: BoxConstraints(
maxWidth: dimensions.maxContentWidth,
),
child: Material( child: Material(
elevation: dimensions.elevationSmall, elevation: dimensions.elevationSmall,
color: Theme.of(context).colorScheme.onSecondary, color: Theme.of(context).colorScheme.onSecondary,
borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), borderRadius: BorderRadius.circular(
dimensions.borderRadiusMedium,
),
child: Padding( child: Padding(
padding: EdgeInsets.all(dimensions.paddingLarge), padding: EdgeInsets.all(dimensions.paddingLarge),
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -55,19 +58,12 @@ class WalletEditPage extends StatelessWidget {
WalletEditFields(), WalletEditFields(),
const SizedBox(height: 24), const SizedBox(height: 24),
ButtonsWalletWidget(), ButtonsWalletWidget(),
const SizedBox(height: 24),
], ],
), ),
), ),
), ),
), ),
), ),
const SizedBox(height: 24),
Expanded(
child: SingleChildScrollView(
child: WalletHistory(wallet: wallet),
),
),
], ],
), ),
); );

View File

@@ -2,118 +2,87 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/source_type.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/pages/payout_page/wallet/history/filters.dart'; import 'package:pweb/controllers/operations/report_operations.dart';
import 'package:pweb/pages/payout_page/wallet/history/table.dart'; import 'package:pweb/models/state/load_more_state.dart';
import 'package:pweb/controllers/operations/wallet_transactions.dart'; import 'package:pweb/pages/report/cards/list.dart';
import 'package:pweb/providers/wallet_transactions.dart'; import 'package:pweb/pages/report/operations/actions.dart';
import 'package:pweb/pages/report/operations/states/error.dart';
import 'package:pweb/pages/report/operations/states/loading.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletHistory extends StatefulWidget { class WalletHistory extends StatelessWidget {
final Wallet wallet; final String sourceRef;
final PaymentSourceType sourceType;
final List<String> sourceRefs;
const WalletHistory({super.key, required this.wallet}); const WalletHistory({
super.key,
@override required this.sourceRef,
State<WalletHistory> createState() => _WalletHistoryState(); required this.sourceType,
} required this.sourceRefs,
});
class _WalletHistoryState extends State<WalletHistory> {
@override @override
void initState() { Widget build(BuildContext context) {
super.initState(); return ChangeNotifierProxyProvider<
_load(); PaymentsProvider,
} ReportOperationsController
>(
@override create: (_) => ReportOperationsController(),
void didUpdateWidget(covariant WalletHistory oldWidget) { update: (_, payments, controller) => controller!
super.didUpdateWidget(oldWidget); ..update(
if (oldWidget.wallet.id != widget.wallet.id) { payments,
_load(); sourceType: sourceType,
} sourceRef: sourceRef,
} sourceRefs: sourceRefs,
),
void _load() { child: const _WalletHistoryContent(),
WidgetsBinding.instance.addPostFrameCallback((_) { );
context }
.read<WalletTransactionsProvider>() }
.load(walletId: widget.wallet.id);
}); class _WalletHistoryContent extends StatelessWidget {
} const _WalletHistoryContent();
Future<void> _pickRange() async {
final provider = context.read<WalletTransactionsController>();
final now = DateTime.now();
final initial = provider.dateRange ??
DateTimeRange(
start: now.subtract(const Duration(days: 30)),
end: now,
);
final picked = await showDateRangePicker(
context: context,
firstDate: now.subtract(const Duration(days: 365)),
lastDate: now.add(const Duration(days: 1)),
initialDateRange: initial,
);
if (picked != null) {
provider.setDateRange(picked);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Consumer<WalletTransactionsController>( return Consumer<ReportOperationsController>(
builder: (context, provider, child) { builder: (context, controller, child) {
if (provider.isLoading) { if (controller.isLoading) {
return const Padding( return const OperationHistoryLoading();
padding: EdgeInsets.all(16.0), }
child: Center(child: CircularProgressIndicator()),
if (controller.error != null) {
final message =
controller.error?.toString() ?? loc.noErrorInformation;
return OperationHistoryError(
message: loc.notificationError(message),
retryLabel: loc.retry,
onRetry: controller.refresh,
); );
} }
if (provider.error != null) { final hasLoadMore = controller.loadMoreState != LoadMoreState.hidden;
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( OperationsCardsList(
loc.failedToLoadHistory, operations: controller.filteredOperations,
style: theme.textTheme.titleMedium! onTap: (operation) => openPaymentDetails(context, operation),
.copyWith(color: theme.colorScheme.error), loadMoreState: controller.loadMoreState,
), onLoadMore: hasLoadMore ? controller.loadMore : null,
const SizedBox(height: 8), ),
Text(loc.notificationError(provider.error ?? loc.noErrorInformation)), ],
const SizedBox(height: 8), ),
OutlinedButton(
onPressed: _load,
child: Text(loc.retry),
),
],
),
);
}
final transactions = provider.filteredTransactions;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
WalletHistoryFilters(
provider: provider,
onPickRange: _pickRange,
),
const SizedBox(height: 12),
WalletTransactionsTable(transactions: transactions),
],
); );
}, },
); );

View File

@@ -13,7 +13,6 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsContent extends StatelessWidget { class PaymentDetailsContent extends StatelessWidget {
final Payment payment; final Payment payment;
final VoidCallback onBack; final VoidCallback onBack;
final VoidCallback? onDownloadAct;
final bool Function(PaymentExecutionOperation operation)? final bool Function(PaymentExecutionOperation operation)?
canDownloadOperationDocument; canDownloadOperationDocument;
final ValueChanged<PaymentExecutionOperation>? onDownloadOperationDocument; final ValueChanged<PaymentExecutionOperation>? onDownloadOperationDocument;
@@ -22,7 +21,6 @@ class PaymentDetailsContent extends StatelessWidget {
super.key, super.key,
required this.payment, required this.payment,
required this.onBack, required this.onBack,
this.onDownloadAct,
this.canDownloadOperationDocument, this.canDownloadOperationDocument,
this.onDownloadOperationDocument, this.onDownloadOperationDocument,
}); });
@@ -37,7 +35,7 @@ class PaymentDetailsContent extends StatelessWidget {
children: [ children: [
PaymentDetailsHeader(title: loc.paymentInfo, onBack: onBack), PaymentDetailsHeader(title: loc.paymentInfo, onBack: onBack),
const SizedBox(height: 16), const SizedBox(height: 16),
PaymentSummaryCard(payment: payment, onDownloadAct: onDownloadAct), PaymentSummaryCard(payment: payment),
const SizedBox(height: 16), const SizedBox(height: 16),
PaymentDetailsSections( PaymentDetailsSections(
payment: payment, payment: payment,

View File

@@ -64,17 +64,6 @@ class _PaymentDetailsView extends StatelessWidget {
return PaymentDetailsContent( return PaymentDetailsContent(
payment: payment, payment: payment,
onBack: () => _handleBack(context), onBack: () => _handleBack(context),
onDownloadAct: controller.canDownload
? () {
final request = controller.primaryOperationDocumentRequest;
if (request == null) return;
downloadPaymentAct(
context,
gatewayService: request.gatewayService,
operationRef: request.operationRef,
);
}
: null,
canDownloadOperationDocument: canDownloadOperationDocument:
controller.canDownloadOperationDocument, controller.canDownloadOperationDocument,
onDownloadOperationDocument: (operation) { onDownloadOperationDocument: (operation) {

View File

@@ -44,12 +44,12 @@ class PaymentOperationsSection extends StatelessWidget {
); );
if (i < operations.length - 1) { if (i < operations.length - 1) {
children.addAll([ children.addAll([
const SizedBox(height: 8), const SizedBox(height: 10),
Divider( Divider(
height: 1, height: 1,
color: Theme.of(context).dividerColor.withAlpha(20), color: Theme.of(context).dividerColor.withAlpha(20),
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
]); ]);
} }
} }

View File

@@ -14,15 +14,11 @@ import 'package:pweb/utils/clipboard.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentSummaryCard extends StatelessWidget { class PaymentSummaryCard extends StatelessWidget {
final Payment payment; final Payment payment;
final VoidCallback? onDownloadAct;
const PaymentSummaryCard({ const PaymentSummaryCard({super.key, required this.payment});
super.key,
required this.payment,
this.onDownloadAct,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -93,14 +89,6 @@ class PaymentSummaryCard extends StatelessWidget {
text: feeText, text: feeText,
muted: true, muted: true,
), ),
if (onDownloadAct != null) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onDownloadAct,
icon: const Icon(Icons.download),
label: Text(loc.downloadAct),
),
],
if (showPaymentId) ...[ if (showPaymentId) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Divider(color: theme.dividerColor.withAlpha(35), height: 1), Divider(color: theme.dividerColor.withAlpha(35), height: 1),

View File

@@ -13,8 +13,9 @@ import 'package:pshared/utils/payment/quote_helpers.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
import 'package:pweb/models/payment/multiple_payouts/state.dart'; import 'package:pweb/models/payment/multiple_payouts/state.dart';
import 'package:pweb/utils/payment/multiple_csv_parser.dart'; import 'package:pweb/utils/payment/multiple/csv_parser.dart';
import 'package:pweb/utils/payment/multiple_intent_builder.dart'; import 'package:pweb/utils/payment/multiple/intent_builder.dart';
class MultiplePayoutsProvider extends ChangeNotifier { class MultiplePayoutsProvider extends ChangeNotifier {
final MultipleCsvParser _csvParser; final MultipleCsvParser _csvParser;

View File

@@ -13,6 +13,7 @@ class WalletTransactionsProvider extends ChangeNotifier {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
String? _walletId; String? _walletId;
int _loadSeq = 0;
List<WalletTransaction> get transactions => List.unmodifiable(_transactions); List<WalletTransaction> get transactions => List.unmodifiable(_transactions);
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
@@ -20,18 +21,28 @@ class WalletTransactionsProvider extends ChangeNotifier {
String? get walletId => _walletId; String? get walletId => _walletId;
Future<void> load({String? walletId}) async { Future<void> load({String? walletId}) async {
final targetWalletId = walletId ?? _walletId;
final requestSeq = ++_loadSeq;
_walletId = targetWalletId;
_isLoading = true; _isLoading = true;
_error = null; _error = null;
notifyListeners(); notifyListeners();
try { try {
_walletId = walletId ?? _walletId; final fetched = await _service.fetchHistory(walletId: targetWalletId);
_transactions = await _service.fetchHistory(walletId: _walletId); if (requestSeq != _loadSeq) return;
_transactions = targetWalletId == null
? fetched
: fetched.where((tx) => tx.walletId == targetWalletId).toList();
} catch (e) { } catch (e) {
if (requestSeq != _loadSeq) return;
_error = e.toString(); _error = e.toString();
} finally { } finally {
_isLoading = false; if (requestSeq == _loadSeq) {
notifyListeners(); _isLoading = false;
notifyListeners();
}
} }
} }
} }

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/flow.dart';
@@ -17,14 +18,30 @@ import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
import 'package:pweb/controllers/payments/page.dart'; import 'package:pweb/controllers/payments/page.dart';
import 'package:pweb/controllers/payments/page_ui.dart'; import 'package:pweb/controllers/payments/page_ui.dart';
import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart';
import 'package:pweb/utils/payment/payout_verification_flow.dart'; import 'package:pweb/utils/payment/verification_flow.dart';
void initializePaymentPage( void initializePaymentPage(
BuildContext context, BuildContext context,
PaymentType? initialPaymentType, PaymentType? initialPaymentType, {
) { String? destinationLedgerAccountRef,
}) {
final flowProvider = context.read<PaymentFlowProvider>(); final flowProvider = context.read<PaymentFlowProvider>();
final recipientsProvider = context.read<RecipientsProvider>();
flowProvider.setPreferredType(initialPaymentType); flowProvider.setPreferredType(initialPaymentType);
final destinationRef = destinationLedgerAccountRef?.trim();
if (destinationRef != null && destinationRef.isNotEmpty) {
recipientsProvider.setCurrentObject(null);
flowProvider.setPreferredType(PaymentType.ledger);
flowProvider.setManualPaymentData(
LedgerPaymentMethod(ledgerAccountRef: destinationRef),
);
return;
}
flowProvider.setManualPaymentData(null);
} }
void handleSearchChanged(PaymentPageUiController uiController, String query) { void handleSearchChanged(PaymentPageUiController uiController, String query) {

View File

@@ -1,12 +1,13 @@
import 'package:pshared/models/payment/operation_document.dart';
import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/state.dart';
import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/payment/status.dart';
import 'package:pshared/utils/money.dart'; import 'package:pshared/utils/money.dart';
import 'package:pweb/models/report/operation/document.dart';
import 'package:pweb/utils/report/operations/document_rule.dart'; import 'package:pweb/utils/report/operations/document_rule.dart';
OperationItem mapPaymentToOperation(Payment payment) { OperationItem mapPaymentToOperation(Payment payment) {
final debit = payment.lastQuote?.amounts?.sourceDebitTotal; final debit = payment.lastQuote?.amounts?.sourceDebitTotal;
final settlement = payment.lastQuote?.amounts?.destinationSettlement; final settlement = payment.lastQuote?.amounts?.destinationSettlement;
@@ -55,7 +56,7 @@ OperationItem mapPaymentToOperation(Payment payment) {
); );
} }
OperationDocumentInfo? _resolveOperationDocument(Payment payment) { OperationDocumentRef? _resolveOperationDocument(Payment payment) {
for (final operation in payment.operations) { for (final operation in payment.operations) {
final operationRef = operation.operationRef; final operationRef = operation.operationRef;
final gatewayService = operation.gateway; final gatewayService = operation.gateway;
@@ -64,7 +65,7 @@ OperationDocumentInfo? _resolveOperationDocument(Payment payment) {
if (!isOperationDocumentEligible(operation.code)) continue; if (!isOperationDocumentEligible(operation.code)) continue;
return OperationDocumentInfo( return OperationDocumentRef(
operationRef: operationRef, operationRef: operationRef,
gatewayService: gatewayService, gatewayService: gatewayService,
); );

View File

@@ -0,0 +1,112 @@
import 'package:pshared/models/payment/intent.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/methods/wallet.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/source_type.dart';
bool paymentMatchesSource(
Payment payment, {
required PaymentSourceType sourceType,
required String sourceRef,
}) {
final normalizedSourceRef = _normalize(sourceRef);
if (normalizedSourceRef == null) return false;
final paymentSourceRef = _paymentSourceRef(payment, sourceType);
return paymentSourceRef != null && paymentSourceRef == normalizedSourceRef;
}
String? _paymentSourceRef(Payment payment, PaymentSourceType sourceType) {
final fromIntent = _sourceRefFromIntent(payment.intent, sourceType);
if (fromIntent != null) return fromIntent;
return _sourceRefFromMetadata(payment.metadata, sourceType);
}
String? _sourceRefFromIntent(
PaymentIntent? intent,
PaymentSourceType sourceType,
) {
final source = intent?.source;
if (source == null) return null;
final fromIntentAttributes = _sourceRefFromMetadata(
intent?.attributes,
sourceType,
);
if (fromIntentAttributes != null) return fromIntentAttributes;
switch (sourceType) {
case PaymentSourceType.wallet:
return _walletSourceRef(source);
case PaymentSourceType.ledger:
return _ledgerSourceRef(source);
}
}
String? _walletSourceRef(PaymentMethodData source) {
if (source is ManagedWalletPaymentMethod) {
return _normalize(source.managedWalletRef) ??
_sourceRefFromMetadata(source.metadata, PaymentSourceType.wallet);
}
if (source is WalletPaymentMethod) {
return _normalize(source.walletId) ??
_sourceRefFromMetadata(source.metadata, PaymentSourceType.wallet);
}
return null;
}
String? _ledgerSourceRef(PaymentMethodData source) {
if (source is LedgerPaymentMethod) {
return _normalize(source.ledgerAccountRef) ??
_sourceRefFromMetadata(source.metadata, PaymentSourceType.ledger);
}
return null;
}
String? _sourceRefFromMetadata(
Map<String, String>? metadata,
PaymentSourceType sourceType,
) {
if (metadata == null || metadata.isEmpty) return null;
final keys = switch (sourceType) {
PaymentSourceType.wallet => const <String>[
'source_wallet_ref',
'managed_wallet_ref',
'wallet_ref',
'wallet_id',
'source_wallet_id',
'source_wallet_user_id',
'wallet_user_id',
'wallet_user_ref',
'wallet_number',
'source_wallet_number',
'source_managed_wallet_ref',
'source_ref',
],
PaymentSourceType.ledger => const <String>[
'source_ledger_account_ref',
'ledger_account_ref',
'source_account_code',
'ledger_account_code',
'account_code',
'source_ref',
],
};
for (final key in keys) {
final value = _normalize(metadata[key]);
if (value != null) return value;
}
return null;
}
String? _normalize(String? value) {
final normalized = value?.trim();
if (normalized == null || normalized.isEmpty) return null;
return normalized;
}