Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b16542c2 | ||
|
|
39c04beb21 | ||
|
|
d6a3a0cc5b |
41
Makefile
41
Makefile
@@ -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
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: "ationv2.QuoteIntent{
|
|
||||||
Comment: " invoice-7 ",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if dto == nil {
|
|
||||||
t.Fatal("expected non-nil payment dto")
|
|
||||||
}
|
|
||||||
if got, want := dto.Comment, "invoice-7"; got != want {
|
|
||||||
t.Fatalf("comment mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
|
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
|
||||||
dto := toPaymentQuote("ationv2.PaymentQuote{
|
dto := toPaymentQuote("ationv2.PaymentQuote{
|
||||||
QuoteRef: "quote-1",
|
QuoteRef: "quote-1",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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] = ©Rec
|
|
||||||
*rec = copyRec
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentsUpsert_ReusesExistingIDFromIdempotencyLookup(t *testing.T) {
|
|
||||||
key := "idem-existing"
|
|
||||||
existingID := bson.NewObjectID()
|
|
||||||
existingCreatedAt := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
repo := &fakePaymentsRepo{
|
|
||||||
records: map[string]*model.PaymentRecord{
|
|
||||||
key: {
|
|
||||||
Base: storable.Base{
|
|
||||||
ID: existingID,
|
|
||||||
CreatedAt: existingCreatedAt,
|
|
||||||
UpdatedAt: existingCreatedAt,
|
|
||||||
},
|
|
||||||
IdempotencyKey: key,
|
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -638,7 +638,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"noFee": "No fee",
|
"noFee": "None",
|
||||||
|
|
||||||
"recipientWillReceive": "Recipient will receive: {amount}",
|
"recipientWillReceive": "Recipient will receive: {amount}",
|
||||||
"@recipientWillReceive": {
|
"@recipientWillReceive": {
|
||||||
|
|||||||
@@ -638,7 +638,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"noFee": "Нет комиссии",
|
"noFee": "Без оплаты",
|
||||||
|
|
||||||
"recipientWillReceive": "Получатель получит: {amount}",
|
"recipientWillReceive": "Получатель получит: {amount}",
|
||||||
"@recipientWillReceive": {
|
"@recipientWillReceive": {
|
||||||
|
|||||||
26
frontend/pweb/lib/models/dashboard/balance_item.dart
Normal file
26
frontend/pweb/lib/models/dashboard/balance_item.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:pshared/models/ledger/account.dart';
|
||||||
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
|
|
||||||
|
sealed class BalanceItem {
|
||||||
|
const BalanceItem();
|
||||||
|
|
||||||
|
const factory BalanceItem.wallet(Wallet wallet) = WalletBalanceItem;
|
||||||
|
const factory BalanceItem.ledger(LedgerAccount account) = LedgerBalanceItem;
|
||||||
|
const factory BalanceItem.addAction() = AddBalanceActionItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WalletBalanceItem extends BalanceItem {
|
||||||
|
final Wallet wallet;
|
||||||
|
|
||||||
|
const WalletBalanceItem(this.wallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class LedgerBalanceItem extends BalanceItem {
|
||||||
|
final LedgerAccount account;
|
||||||
|
|
||||||
|
const LedgerBalanceItem(this.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AddBalanceActionItem extends BalanceItem {
|
||||||
|
const AddBalanceActionItem();
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
class OperationDocumentRequestModel {
|
|
||||||
final String gatewayService;
|
|
||||||
final String operationRef;
|
|
||||||
|
|
||||||
const OperationDocumentRequestModel({
|
|
||||||
required this.gatewayService,
|
|
||||||
required this.operationRef,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
enum PaymentState {
|
|
||||||
success,
|
|
||||||
failed,
|
|
||||||
cancelled,
|
|
||||||
processing,
|
|
||||||
unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
PaymentState paymentStateFromRaw(String? raw) {
|
|
||||||
final trimmed = (raw ?? '').trim().toUpperCase();
|
|
||||||
final normalized = trimmed.startsWith('PAYMENT_STATE_')
|
|
||||||
? trimmed.substring('PAYMENT_STATE_'.length)
|
|
||||||
: trimmed;
|
|
||||||
|
|
||||||
switch (normalized) {
|
|
||||||
case 'SUCCESS':
|
|
||||||
return PaymentState.success;
|
|
||||||
case 'FAILED':
|
|
||||||
return PaymentState.failed;
|
|
||||||
case 'CANCELLED':
|
|
||||||
return PaymentState.cancelled;
|
|
||||||
case 'PROCESSING':
|
|
||||||
return PaymentState.processing;
|
|
||||||
default:
|
|
||||||
return PaymentState.unknown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import 'package:pshared/models/ledger/account.dart';
|
|
||||||
import 'package:pshared/models/payment/wallet.dart';
|
|
||||||
|
|
||||||
|
|
||||||
enum BalanceItemType { wallet, ledger, addAction }
|
|
||||||
|
|
||||||
class BalanceItem {
|
|
||||||
final BalanceItemType type;
|
|
||||||
final Wallet? wallet;
|
|
||||||
final LedgerAccount? account;
|
|
||||||
|
|
||||||
const BalanceItem.wallet(this.wallet) : type = BalanceItemType.wallet, account = null;
|
|
||||||
|
|
||||||
const BalanceItem.ledger(this.account) : type = BalanceItemType.ledger, wallet = null;
|
|
||||||
|
|
||||||
const BalanceItem.addAction() : type = BalanceItemType.addAction, wallet = null, account = null;
|
|
||||||
|
|
||||||
bool get isWallet => type == BalanceItemType.wallet;
|
|
||||||
bool get isLedger => type == BalanceItemType.ledger;
|
|
||||||
bool get isAdd => type == BalanceItemType.addAction;
|
|
||||||
}
|
|
||||||
@@ -1,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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
112
frontend/pweb/lib/utils/report/source_filter.dart
Normal file
112
frontend/pweb/lib/utils/report/source_filter.dart
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user