Compare commits

29 Commits

Author SHA1 Message Date
Arseni
0aceb2f441 added missed loc instead of hardcode 2026-03-06 18:05:04 +03:00
Arseni
281b3834d3 wallet card redesign 2026-03-06 17:48:36 +03:00
Arseni
2b0ada1541 Merge remote-tracking branch 'origin/main' into SEND066
merge fresh main into SEND66
2026-03-06 17:18:39 +03:00
ea5ec79a6e Merge pull request 'fixed po <-> tgsettle contract' (#688) from po-687 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #688
2026-03-06 14:12:46 +00:00
Stephan D
3295b9d9f0 fixed po <-> tgsettle contract 2026-03-06 15:12:14 +01:00
031b8931ca Merge pull request 'fixed tgsettle upsert logic' (#686) from tg-685 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #686
2026-03-06 12:50:56 +00:00
Stephan D
4295456f63 fixed tgsettle upsert logic 2026-03-06 13:50:13 +01:00
2b1b4135f4 Merge pull request 'mntx error codes update' (#684) from mntx-683 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #684
2026-03-06 12:35:09 +00:00
Stephan D
c60e7d2329 mntx error codes update 2026-03-06 12:14:32 +01:00
be49254769 Merge pull request 'bff USDT ledger creation' (#682) from bff-681 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #682
2026-03-06 10:58:27 +00:00
Stephan D
34e507b664 bff USDT ledger creation 2026-03-06 11:58:07 +01:00
Arseni
b67d199427 Merge remote-tracking branch 'origin/main' into SEND066
merge lastest updates in main
2026-03-06 01:14:40 +03:00
b481de9ffc Merge pull request 'New comments section in the requests/responses' (#679) from bff-677 into main
Reviewed-on: #679
2026-03-05 19:29:10 +00:00
Stephan D
0c29e7686d New comments section in the requests/responses 2026-03-05 20:28:28 +01:00
5b26a70a15 Merge pull request 'New comments section in the requests/responses' (#678) from bff-677 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #678
2026-03-05 19:28:05 +00:00
Stephan D
b832c2a7c4 New comments section in the requests/responses 2026-03-05 20:27:45 +01:00
Arseni
97b16542c2 ledger top up functionality and few small fixes for project architechture and design 2026-03-05 21:49:23 +03:00
Arseni
39c04beb21 Merge remote-tracking branch 'origin/main' into SEND066
merge main into SEND066
2026-03-05 21:12:43 +03:00
15393765b9 Merge pull request 'fixes for multiple payout' (#674) from SEND067 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #674
2026-03-05 16:35:37 +00:00
Arseni
440b6a2553 fixes for multiple payout 2026-03-05 19:28:02 +03:00
bc76cfe063 Merge pull request 'tg-670' (#671) from tg-670 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #671
2026-03-05 13:47:37 +00:00
Stephan D
ed8f7c519c moved tg settings to db 2026-03-05 14:46:34 +01:00
Stephan D
71d99338f2 moved tg settings to db 2026-03-05 14:46:26 +01:00
b499778bce Merge pull request 'fixed treasury messages' (#669) from tg-666 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #669
2026-03-05 13:18:13 +00:00
Stephan D
4a554833c4 fixed treasury messages 2026-03-05 14:17:50 +01:00
b7ea11a62b Merge pull request 'fixed treasury messages' (#668) from tg-666 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #668
2026-03-05 13:09:47 +00:00
Stephan D
026f698d9b fixed treasury messages 2026-03-05 14:09:21 +01:00
0da6078468 Merge pull request 'fixed treasury messages' (#667) from tg-666 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #667
2026-03-05 12:59:57 +00:00
Stephan D
3b65a2dc3a fixed treasury messages 2026-03-05 13:59:38 +01:00
120 changed files with 4437 additions and 1109 deletions

View File

@@ -1,10 +1,31 @@
# Sendico Development Environment - Makefile
# Docker Compose + Makefile build system
.PHONY: help init build up down restart logs rebuild clean vault-init proto generate generate-api generate-frontend update update-api update-frontend test test-api test-frontend
.PHONY: help init build up down restart logs rebuild clean vault-init proto generate generate-api generate-frontend update update-api update-frontend test test-api test-frontend backend-up backend-down backend-rebuild
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
SERVICE ?=
BACKEND_SERVICES := \
dev-discovery \
dev-fx-oracle \
dev-fx-ingestor \
dev-billing-fees \
dev-billing-documents \
dev-ledger \
dev-payments-orchestrator \
dev-payments-quotation \
dev-payments-methods \
dev-chain-gateway-vault-agent \
dev-chain-gateway \
dev-tron-gateway-vault-agent \
dev-tron-gateway \
dev-aurora-gateway \
dev-tgsettle-gateway \
dev-notification \
dev-callbacks-vault-agent \
dev-callbacks \
dev-bff-vault-agent \
dev-bff
# Colors
GREEN := \033[0;32m
@@ -31,6 +52,9 @@ help:
@echo "$(YELLOW)Selective Operations:$(NC)"
@echo " make infra-up Start infrastructure only (mongo, nats, vault)"
@echo " make services-up Start application services only"
@echo " make backend-up Start backend services only (no infrastructure/frontend)"
@echo " make backend-down Stop backend services only"
@echo " make backend-rebuild Rebuild and restart backend services only"
@echo " make list-services List all available services"
@echo ""
@echo "$(YELLOW)Build Groups:$(NC)"
@@ -229,6 +253,21 @@ services-up:
dev-bff \
dev-frontend
# Backend services only (no infrastructure, no frontend)
backend-up:
@echo "$(GREEN)Starting backend services only (no infra changes)...$(NC)"
@$(COMPOSE) up -d --no-deps $(BACKEND_SERVICES)
backend-down:
@echo "$(YELLOW)Stopping backend services only...$(NC)"
@$(COMPOSE) stop $(BACKEND_SERVICES)
backend-rebuild:
@echo "$(GREEN)Rebuilding backend services only (no infra changes)...$(NC)"
@$(COMPOSE) build $(BACKEND_SERVICES)
@$(COMPOSE) up -d --no-deps --force-recreate $(BACKEND_SERVICES)
@echo "$(GREEN)✅ Backend services rebuilt$(NC)"
# Status check
status:
@$(COMPOSE) ps

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -101,6 +101,68 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
}
}
func TestCardPayoutProcessor_Submit_AcceptedBodyErrorRemainsWaiting(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := monetix.APIResponse{
Status: "error",
Code: "3062",
Message: "Payment details not received",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil)
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted payout response")
}
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING {
t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus())
}
if got := resp.GetErrorCode(); got != "3062" {
t.Fatalf("expected response error code %q, got %q", "3062", got)
}
if got := resp.GetErrorMessage(); got != "Payment details not received" {
t.Fatalf("expected response error message, got %q", got)
}
stored, ok := repo.payouts.Get(req.GetPayoutId())
if !ok || stored == nil {
t.Fatalf("expected payout state stored")
}
if got := stored.Status; got != model.PayoutStatusWaiting {
t.Fatalf("expected stored waiting status, got %v", got)
}
if got := stored.ProviderCode; got != "3062" {
t.Fatalf("expected stored provider code %q, got %q", "3062", got)
}
if got := stored.ProviderMessage; got != "Payment details not received" {
t.Fatalf("expected stored provider message, got %q", got)
}
}
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
cfg := monetix.Config{
AllowedCurrencies: []string{"RUB"},
@@ -525,7 +587,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
n := calls.Add(1)
resp := monetix.APIResponse{}
if n == 1 {
resp.Code = providerCodeDeclineAmountOrFrequencyLimit
resp.Code = "10101"
resp.Message = "Decline due to amount or frequency limit"
body, _ := json.Marshal(resp)
return &http.Response{
@@ -554,7 +616,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32) time.Duration { return 10 * time.Millisecond }
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 10 * time.Millisecond }
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
@@ -581,7 +643,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
}
}
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *testing.T) {
func TestCardPayoutProcessor_Submit_ProviderRetryUsesDelayedStrategy(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
@@ -590,12 +652,10 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
}
repo := newMockRepository()
var calls atomic.Int32
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
_ = calls.Add(1)
resp := monetix.APIResponse{
Code: providerCodeDeclineAmountOrFrequencyLimit,
Code: "10101",
Message: "Decline due to amount or frequency limit",
}
body, _ := json.Marshal(resp)
@@ -617,7 +677,159 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
capturedStrategy := payoutRetryStrategy(0)
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
capturedStrategy = strategy
return time.Hour
}
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response when retry is scheduled")
}
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyDelayed {
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyDelayed)
}
}
func TestCardPayoutProcessor_Submit_StatusRefreshRetryUsesStatusRefreshStrategy(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := monetix.APIResponse{
Code: "3061",
Message: "Transaction not found",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
capturedStrategy := payoutRetryStrategy(0)
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
capturedStrategy = strategy
return time.Hour
}
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response when retry is scheduled")
}
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyStatusRefresh {
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyStatusRefresh)
}
}
func TestCardPayoutProcessor_Submit_TransportRetryUsesImmediateStrategy(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("transport timeout")
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
capturedStrategy := payoutRetryStrategy(0)
processor.retryDelayFn = func(_ uint32, strategy payoutRetryStrategy) time.Duration {
capturedStrategy = strategy
return time.Hour
}
resp, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if !resp.GetAccepted() {
t.Fatalf("expected accepted response when retry is scheduled")
}
if got := normalizeRetryStrategy(capturedStrategy); got != payoutRetryStrategyImmediate {
t.Fatalf("unexpected retry strategy: got=%v want=%v", got, payoutRetryStrategyImmediate)
}
}
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttention(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
var calls atomic.Int32
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
_ = calls.Add(1)
resp := monetix.APIResponse{
Code: "10101",
Message: "Decline due to amount or frequency limit",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return time.Millisecond }
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
@@ -631,14 +843,14 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
deadline := time.Now().Add(2 * time.Second)
for {
state, ok := repo.payouts.Get(req.GetPayoutId())
if ok && state != nil && state.Status == model.PayoutStatusFailed {
if !strings.Contains(state.FailureReason, providerCodeDeclineAmountOrFrequencyLimit) {
if ok && state != nil && state.Status == model.PayoutStatusNeedsAttention {
if !strings.Contains(state.FailureReason, "10101") {
t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("timeout waiting for terminal failed status")
t.Fatalf("timeout waiting for terminal needs_attention status")
}
time.Sleep(10 * time.Millisecond)
}
@@ -647,6 +859,59 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *test
}
}
func TestCardPayoutProcessor_Submit_NonRetryProviderDeclineRemainsFailed(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
ProjectID: 99,
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := monetix.APIResponse{
Code: "10003",
Message: "Decline by anti-fraud policy",
}
body, _ := json.Marshal(resp)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
repo,
httpClient,
nil,
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
if err != nil {
t.Fatalf("submit returned error: %v", err)
}
if resp.GetAccepted() {
t.Fatalf("expected non-accepted response for non-retryable provider decline")
}
state, ok := repo.payouts.Get(req.GetPayoutId())
if !ok || state == nil {
t.Fatal("expected stored payout state")
}
if got, want := state.Status, model.PayoutStatusFailed; got != want {
t.Fatalf("unexpected payout status: got=%q want=%q", got, want)
}
}
func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
@@ -687,7 +952,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
)
defer processor.stopRetries()
processor.dispatchThrottleInterval = 0
processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond }
processor.retryDelayFn = func(uint32, payoutRetryStrategy) time.Duration { return 5 * time.Millisecond }
req := validCardPayoutRequest()
resp, err := processor.Submit(context.Background(), req)
@@ -702,7 +967,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
cb.Payment.ID = req.GetPayoutId()
cb.Payment.Status = "failed"
cb.Operation.Status = "failed"
cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit
cb.Operation.Code = "10101"
cb.Operation.Message = "Decline due to amount or frequency limit"
cb.Payment.Sum.Currency = "RUB"

View File

@@ -69,6 +69,9 @@ func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
case model.PayoutStatusFailed:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case model.PayoutStatusNeedsAttention:
// Connector/gateway proto does not expose needs_attention yet; map it to failed externally.
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case model.PayoutStatusCancelled:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
default:

View File

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

View File

@@ -1,13 +1,11 @@
package gateway
import (
"sort"
"strconv"
"strings"
)
const (
providerCodeDeclineAmountOrFrequencyLimit = "10101"
)
type payoutFailureAction int
const (
@@ -15,46 +13,136 @@ const (
payoutFailureActionRetry
)
type payoutRetryStrategy int
const (
payoutRetryStrategyImmediate payoutRetryStrategy = iota + 1
payoutRetryStrategyDelayed
payoutRetryStrategyStatusRefresh
)
type payoutFailureDecision struct {
Action payoutFailureAction
Strategy payoutRetryStrategy
Reason string
}
type payoutFailurePolicy struct {
providerCodeActions map[string]payoutFailureAction
providerCodeStrategies map[string]payoutRetryStrategy
documentedProviderCodes map[string]struct{}
}
func defaultPayoutFailurePolicy() payoutFailurePolicy {
return payoutFailurePolicy{
providerCodeActions: map[string]payoutFailureAction{
providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry,
type retryCodeBucket struct {
strategy payoutRetryStrategy
retryable bool
codes []string
}
var providerRetryOnlyCodeBuckets = []retryCodeBucket{
// GTX "repeat request now / temporary issue" style codes.
{
strategy: payoutRetryStrategyImmediate,
retryable: true,
codes: []string{
// General codes.
"104", "108", "301", "320", "601", "602", "603", "3025", "3198",
// External card PS codes.
"10000", "10100", "10104", "10105", "10107", "10202", "102051", "10301", "105012", "10505", "10601", "10602", "10603",
// External alternate PS codes.
"20000", "20100", "20104", "20105", "20202", "20301", "20304", "20601", "20602", "20603",
},
},
// GTX "retry later / limits / period restrictions" style codes.
{
strategy: payoutRetryStrategyDelayed,
retryable: true,
codes: []string{
// General codes.
"312", "314", "315", "316", "325", "2466",
"3106", "3108", "3109", "3110", "3111", "3112",
"3285", "3297", "3298",
"3305", "3306", "3307", "3308", "3309", "3310", "3311", "3312", "3313", "3314", "3315", "3316", "3317", "3318", "3319", "3320", "3321", "3322", "3323", "3324", "3325", "3326", "3327", "3328", "3329", "3330", "3331", "3332", "3333", "3334", "3335", "3336", "3337", "3338", "3339", "3340",
"3342", "3343", "3344", "3345", "3346", "3347", "3348", "3349", "3350", "3351", "3352", "3353", "3355", "3357",
"3407", "3408", "3450", "3451", "3452", "3613",
// External card PS codes.
"10101", "10109", "10112", "10114", "101012", "101013", "101014",
// External alternate PS codes.
"20109", "20206", "20505", "201012", "201013", "201014",
},
},
// GTX status refresh/polling conditions.
{
strategy: payoutRetryStrategyStatusRefresh,
retryable: true,
codes: []string{
"3061", "3062",
"9999", "19999", "20802", "29999",
},
},
}
var providerDocumentedNonRetryCodes = buildDocumentedNonRetryCodes(providerDocumentedCodes, providerRetryOnlyCodeBuckets)
var providerRetryCodeBuckets = func() []retryCodeBucket {
buckets := make([]retryCodeBucket, 0, len(providerRetryOnlyCodeBuckets)+1)
buckets = append(buckets, providerRetryOnlyCodeBuckets...)
buckets = append(buckets, retryCodeBucket{
strategy: payoutRetryStrategyImmediate,
retryable: false,
codes: providerDocumentedNonRetryCodes,
})
return buckets
}()
func defaultPayoutFailurePolicy() payoutFailurePolicy {
strategies := map[string]payoutRetryStrategy{}
for _, bucket := range providerRetryCodeBuckets {
if !bucket.retryable {
continue
}
registerRetryStrategy(strategies, bucket.strategy, bucket.codes...)
}
return payoutFailurePolicy{
providerCodeStrategies: strategies,
documentedProviderCodes: newCodeSet(providerDocumentedCodes),
}
}
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
normalized := strings.TrimSpace(code)
normalized := normalizeProviderCode(code)
if normalized == "" {
return payoutFailureDecision{
Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_failure",
}
}
if action, ok := p.providerCodeActions[normalized]; ok {
if strategy, ok := p.providerCodeStrategies[normalized]; ok {
return payoutFailureDecision{
Action: action,
Action: payoutFailureActionRetry,
Strategy: strategy,
Reason: "provider_code_" + normalized,
}
}
if _, ok := p.documentedProviderCodes[normalized]; ok {
return payoutFailureDecision{
Action: payoutFailureActionFail,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_code_" + normalized + "_documented_non_retry",
}
}
return payoutFailureDecision{
Action: payoutFailureActionFail,
Reason: "provider_code_" + normalized,
Strategy: payoutRetryStrategyImmediate,
Reason: "provider_code_" + normalized + "_unknown",
}
}
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
return payoutFailureDecision{
Action: payoutFailureActionRetry,
Strategy: payoutRetryStrategyImmediate,
Reason: "transport_failure",
}
}
@@ -72,8 +160,40 @@ func payoutFailureReason(code, message string) string {
}
}
func retryDelayForAttempt(attempt uint32) int {
// Backoff in seconds by attempt number (attempt starts at 1).
func retryDelayForAttempt(attempt uint32, strategy payoutRetryStrategy) int {
strategy = normalizeRetryStrategy(strategy)
// Backoff in seconds by strategy and attempt number (attempt starts at 1).
if strategy == payoutRetryStrategyStatusRefresh {
switch {
case attempt <= 1:
return 10
case attempt == 2:
return 20
case attempt == 3:
return 40
case attempt == 4:
return 80
default:
return 160
}
}
if strategy == payoutRetryStrategyDelayed {
switch {
case attempt <= 1:
return 30
case attempt == 2:
return 120
case attempt == 3:
return 600
case attempt == 4:
return 1800
default:
return 7200
}
}
switch {
case attempt <= 1:
return 5
@@ -85,3 +205,86 @@ func retryDelayForAttempt(attempt uint32) int {
return 60
}
}
func registerRetryStrategy(dst map[string]payoutRetryStrategy, strategy payoutRetryStrategy, codes ...string) {
if dst == nil || len(codes) == 0 {
return
}
strategy = normalizeRetryStrategy(strategy)
for _, code := range codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
dst[normalized] = strategy
}
}
func newCodeSet(codes []string) map[string]struct{} {
set := map[string]struct{}{}
for _, code := range codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
set[normalized] = struct{}{}
}
return set
}
func buildDocumentedNonRetryCodes(documented []string, retryBuckets []retryCodeBucket) []string {
documentedSet := newCodeSet(documented)
retrySet := map[string]struct{}{}
for _, bucket := range retryBuckets {
for _, code := range bucket.codes {
normalized := normalizeProviderCode(code)
if normalized == "" {
continue
}
retrySet[normalized] = struct{}{}
}
}
nonRetry := make([]string, 0, len(documentedSet))
for code := range documentedSet {
if _, ok := retrySet[code]; ok {
continue
}
nonRetry = append(nonRetry, code)
}
sort.Slice(nonRetry, func(i, j int) bool {
left, leftErr := strconv.Atoi(nonRetry[i])
right, rightErr := strconv.Atoi(nonRetry[j])
if leftErr != nil || rightErr != nil {
return nonRetry[i] < nonRetry[j]
}
return left < right
})
return nonRetry
}
func normalizeProviderCode(code string) string {
return strings.TrimSpace(code)
}
func normalizeRetryStrategy(strategy payoutRetryStrategy) payoutRetryStrategy {
switch strategy {
case payoutRetryStrategyDelayed, payoutRetryStrategyStatusRefresh:
return strategy
default:
return payoutRetryStrategyImmediate
}
}
func (s payoutRetryStrategy) String() string {
switch normalizeRetryStrategy(s) {
case payoutRetryStrategyDelayed:
return "delayed"
case payoutRetryStrategyStatusRefresh:
return "status_refresh"
default:
return "immediate"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -166,25 +166,16 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
}
}
if apiResp.Operation.RequestID != "" {
result.ProviderRequestID = apiResp.Operation.RequestID
} else if apiResp.RequestID != "" {
result.ProviderRequestID = apiResp.RequestID
}
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
if result.ProviderStatus == "" {
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
}
result.ProviderRequestID = providerRequestID(apiResp)
result.ProviderStatus = providerStatus(apiResp)
if !result.Accepted {
result.ErrorCode = apiResp.Code
if result.ErrorCode == "" {
errorCode, errorMessage := providerError(apiResp)
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
result.ErrorCode = errorCode
if !result.Accepted && result.ErrorCode == "" {
result.ErrorCode = http.StatusText(resp.StatusCode)
}
result.ErrorMessage = apiResp.Message
if result.ErrorMessage == "" {
result.ErrorMessage = apiResp.Operation.Message
}
result.ErrorMessage = errorMessage
}
c.logger.Info("Monetix tokenization response",
@@ -288,25 +279,16 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
}
}
if apiResp.Operation.RequestID != "" {
result.ProviderRequestID = apiResp.Operation.RequestID
} else if apiResp.RequestID != "" {
result.ProviderRequestID = apiResp.RequestID
}
result.ProviderStatus = strings.TrimSpace(apiResp.Status)
if result.ProviderStatus == "" {
result.ProviderStatus = strings.TrimSpace(apiResp.Operation.Status)
}
result.ProviderRequestID = providerRequestID(apiResp)
result.ProviderStatus = providerStatus(apiResp)
if !result.Accepted {
result.ErrorCode = apiResp.Code
if result.ErrorCode == "" {
errorCode, errorMessage := providerError(apiResp)
if !result.Accepted || isProviderStatusError(result.ProviderStatus) {
result.ErrorCode = errorCode
if !result.Accepted && result.ErrorCode == "" {
result.ErrorCode = http.StatusText(resp.StatusCode)
}
result.ErrorMessage = apiResp.Message
if result.ErrorMessage == "" {
result.ErrorMessage = apiResp.Operation.Message
}
result.ErrorMessage = errorMessage
}
if responseLog != nil {
@@ -324,6 +306,32 @@ func normalizeExpiryYear(year int) int {
return year
}
func providerRequestID(resp APIResponse) string {
return firstNonEmpty(resp.Operation.RequestID, resp.RequestID)
}
func providerStatus(resp APIResponse) string {
return firstNonEmpty(resp.Status, resp.Operation.Status)
}
func providerError(resp APIResponse) (code, message string) {
return firstNonEmpty(resp.Code, resp.Operation.Code), firstNonEmpty(resp.Message, resp.Operation.Message)
}
func isProviderStatusError(status string) bool {
return strings.EqualFold(strings.TrimSpace(status), "error")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func normalizeRequestExpiryYear(req any) {
switch r := req.(type) {
case *CardPayoutRequest:

View File

@@ -175,6 +175,99 @@ func TestSendCardPayout_HTTPError(t *testing.T) {
}
}
func TestSendCardPayout_HTTPAcceptedBodyErrorStillAccepted(t *testing.T) {
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
body := `{"status":"error","code":"3062","message":"Payment details not received"}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
cfg := Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
}
client := NewClient(cfg, httpClient, zap.NewNop())
req := CardPayoutRequest{
General: General{ProjectID: 1, PaymentID: "payout-1"},
Customer: Customer{
ID: "cust-1",
FirstName: "Jane",
LastName: "Doe",
IP: "203.0.113.10",
},
Payment: Payment{Amount: 1000, Currency: "RUB"},
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
}
result, err := client.CreateCardPayout(context.Background(), req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !result.Accepted {
t.Fatalf("expected accepted response")
}
if result.ProviderStatus != "error" {
t.Fatalf("expected provider status error, got %q", result.ProviderStatus)
}
if result.ErrorCode != "3062" {
t.Fatalf("expected error code %q, got %q", "3062", result.ErrorCode)
}
if result.ErrorMessage != "Payment details not received" {
t.Fatalf("expected error message, got %q", result.ErrorMessage)
}
}
func TestSendCardPayout_HTTPErrorFallsBackToOperationCode(t *testing.T) {
httpClient := &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
body := `{"operation":{"code":"3061","message":"Transaction not found"}}`
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}),
}
cfg := Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
}
client := NewClient(cfg, httpClient, zap.NewNop())
req := CardPayoutRequest{
General: General{ProjectID: 1, PaymentID: "payout-1"},
Customer: Customer{
ID: "cust-1",
FirstName: "Jane",
LastName: "Doe",
IP: "203.0.113.10",
},
Payment: Payment{Amount: 1000, Currency: "RUB"},
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
}
result, err := client.CreateCardPayout(context.Background(), req)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Accepted {
t.Fatalf("expected rejected response")
}
if result.ErrorCode != "3061" {
t.Fatalf("expected error code %q, got %q", "3061", result.ErrorCode)
}
if result.ErrorMessage != "Transaction not found" {
t.Fatalf("expected error message, got %q", result.ErrorMessage)
}
}
type errorReadCloser struct {
err error
}

View File

@@ -10,4 +10,5 @@ const (
PayoutStatusSuccess PayoutStatus = "success" // final success
PayoutStatusFailed PayoutStatus = "failed" // final failure
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
PayoutStatusNeedsAttention PayoutStatus = "needs_attention" // final, manual review required
)

View File

@@ -45,9 +45,6 @@ gateway:
treasury:
execution_delay: 60s
poll_interval: 60s
telegram:
allowed_chats: []
users: []
ledger:
timeout: 5s
limits:

View File

@@ -50,10 +50,3 @@ treasury:
limits:
max_amount_per_operation: ""
max_daily_amount: ""
telegram:
allowed_chats: []
users:
- telegram_user_id: "8273799472"
ledger_account: "6972c738949b91ea0395e5fb"
- telegram_user_id: "8273507566"
ledger_account: "6995d6c118bca1d8baa5f2be"

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ package serverimp
import (
"context"
"os"
"strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/internal/service/gateway"
@@ -38,8 +37,6 @@ type config struct {
*grpcapp.Config `yaml:",inline"`
Gateway gatewayConfig `yaml:"gateway"`
Treasury treasuryConfig `yaml:"treasury"`
Ledger ledgerConfig `yaml:"ledger"` // deprecated: use treasury.ledger
Telegram telegramConfig `yaml:"telegram"` // deprecated: use treasury.telegram
}
type gatewayConfig struct {
@@ -50,20 +47,9 @@ type gatewayConfig struct {
SuccessReaction string `yaml:"success_reaction"`
}
type telegramConfig struct {
AllowedChats []string `yaml:"allowed_chats"`
Users []telegramUserConfig `yaml:"users"`
}
type telegramUserConfig struct {
TelegramUserID string `yaml:"telegram_user_id"`
LedgerAccount string `yaml:"ledger_account"`
}
type treasuryConfig struct {
ExecutionDelay time.Duration `yaml:"execution_delay"`
PollInterval time.Duration `yaml:"poll_interval"`
Telegram telegramConfig `yaml:"telegram"`
Ledger ledgerConfig `yaml:"ledger"`
Limits treasuryLimitsConfig `yaml:"limits"`
}
@@ -145,8 +131,6 @@ func (i *Imp) Start() error {
if cfg.Messaging != nil {
msgSettings = cfg.Messaging.Settings
}
treasuryTelegram := treasuryTelegramConfig(cfg, i.logger)
treasuryLedger := treasuryLedgerConfig(cfg, i.logger)
gwCfg := gateway.Config{
Rail: cfg.Gateway.Rail,
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
@@ -159,12 +143,8 @@ func (i *Imp) Start() error {
Treasury: gateway.TreasuryConfig{
ExecutionDelay: cfg.Treasury.ExecutionDelay,
PollInterval: cfg.Treasury.PollInterval,
Telegram: gateway.TelegramConfig{
AllowedChats: treasuryTelegram.AllowedChats,
Users: telegramUsers(treasuryTelegram.Users),
},
Ledger: gateway.LedgerConfig{
Timeout: treasuryLedger.Timeout,
Timeout: cfg.Treasury.Ledger.Timeout,
},
Limits: gateway.TreasuryLimitsConfig{
MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation,
@@ -228,46 +208,3 @@ func (i *Imp) loadConfig() (*config, error) {
}
return cfg, nil
}
func telegramUsers(input []telegramUserConfig) []gateway.TelegramUserBinding {
result := make([]gateway.TelegramUserBinding, 0, len(input))
for _, next := range input {
result = append(result, gateway.TelegramUserBinding{
TelegramUserID: strings.TrimSpace(next.TelegramUserID),
LedgerAccount: strings.TrimSpace(next.LedgerAccount),
})
}
return result
}
func treasuryTelegramConfig(cfg *config, logger mlogger.Logger) telegramConfig {
if cfg == nil {
return telegramConfig{}
}
if len(cfg.Treasury.Telegram.Users) > 0 || len(cfg.Treasury.Telegram.AllowedChats) > 0 {
return cfg.Treasury.Telegram
}
if len(cfg.Telegram.Users) > 0 || len(cfg.Telegram.AllowedChats) > 0 {
if logger != nil {
logger.Warn("Deprecated config path used: telegram.*; move these settings to treasury.telegram.*")
}
return cfg.Telegram
}
return cfg.Treasury.Telegram
}
func treasuryLedgerConfig(cfg *config, logger mlogger.Logger) ledgerConfig {
if cfg == nil {
return ledgerConfig{}
}
if cfg.Treasury.Ledger.Timeout > 0 {
return cfg.Treasury.Ledger
}
if cfg.Ledger.Timeout > 0 {
if logger != nil {
logger.Warn("Deprecated config path used: ledger.*; move these settings to treasury.ledger.*")
}
return cfg.Ledger
}
return cfg.Treasury.Ledger
}

View File

@@ -136,13 +136,22 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transfer := resp.GetTransfer()
operationID := strings.TrimSpace(transfer.GetOperationRef())
if operationID == "" {
s.logger.Warn("Submit operation transfer response missing operation_ref", append(logFields,
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
)...)
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{
Error: connectorError(connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE, "submit_operation: operation_ref is missing in transfer response", op, ""),
}}, nil
}
s.logger.Info("Submit operation transfer submitted", append(logFields,
zap.String("transfer_ref", strings.TrimSpace(transfer.GetTransferRef())),
zap.String("status", transfer.GetStatus().String()),
)...)
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
OperationId: operationID,
Status: transferStatusToOperation(transfer.GetStatus()),
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
},
@@ -224,7 +233,7 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
OperationId: strings.TrimSpace(transfer.GetOperationRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: transferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),

View File

@@ -0,0 +1,119 @@
package gateway
import (
"context"
"testing"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestSubmitOperation_UsesOperationRefAsOperationID(t *testing.T) {
svc, _, _ := newTestService(t)
svc.chatID = "1"
req := &connectorv1.SubmitOperationRequest{
Operation: &connectorv1.Operation{
Type: connectorv1.OperationType_TRANSFER,
IdempotencyKey: "idem-settlement-1",
OperationRef: "payment-1:hop_2_settlement_fx_convert",
IntentRef: "intent-1",
Money: &moneyv1.Money{Amount: "1.00", Currency: "USDT"},
From: &connectorv1.OperationParty{
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: tgsettleConnectorID,
AccountId: "wallet-src",
}},
},
To: &connectorv1.OperationParty{
Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: tgsettleConnectorID,
AccountId: "wallet-dst",
}},
},
Params: structFromMap(map[string]interface{}{
"payment_ref": "payment-1",
"organization_ref": "org-1",
}),
},
}
resp, err := svc.SubmitOperation(context.Background(), req)
if err != nil {
t.Fatalf("SubmitOperation returned error: %v", err)
}
if resp.GetReceipt() == nil {
t.Fatal("expected receipt")
}
if got := resp.GetReceipt().GetError(); got != nil {
t.Fatalf("expected no connector error, got: %v", got)
}
if got, want := resp.GetReceipt().GetOperationId(), "payment-1:hop_2_settlement_fx_convert"; got != want {
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetReceipt().GetProviderRef(), "idem-settlement-1"; got != want {
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
}
}
func TestGetOperation_UsesOperationRefIdentity(t *testing.T) {
svc, repo, _ := newTestService(t)
record := &storagemodel.PaymentRecord{
IdempotencyKey: "idem-settlement-2",
OperationRef: "payment-2:hop_2_settlement_fx_convert",
PaymentIntentID: "pi-2",
PaymentRef: "payment-2",
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
Status: storagemodel.PaymentStatusSuccess,
}
if err := repo.payments.Upsert(context.Background(), record); err != nil {
t.Fatalf("failed to seed payment record: %v", err)
}
resp, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
OperationId: "payment-2:hop_2_settlement_fx_convert",
})
if err != nil {
t.Fatalf("GetOperation returned error: %v", err)
}
if resp.GetOperation() == nil {
t.Fatal("expected operation")
}
if got, want := resp.GetOperation().GetOperationId(), "payment-2:hop_2_settlement_fx_convert"; got != want {
t.Fatalf("operation_id mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetOperation().GetProviderRef(), "idem-settlement-2"; got != want {
t.Fatalf("provider_ref mismatch: got=%q want=%q", got, want)
}
}
func TestGetOperation_DoesNotResolveByIdempotencyKey(t *testing.T) {
svc, repo, _ := newTestService(t)
record := &storagemodel.PaymentRecord{
IdempotencyKey: "idem-settlement-3",
OperationRef: "payment-3:hop_2_settlement_fx_convert",
PaymentIntentID: "pi-3",
PaymentRef: "payment-3",
RequestedMoney: &paymenttypes.Money{Amount: "5.00", Currency: "USDT"},
Status: storagemodel.PaymentStatusSuccess,
}
if err := repo.payments.Upsert(context.Background(), record); err != nil {
t.Fatalf("failed to seed payment record: %v", err)
}
_, err := svc.GetOperation(context.Background(), &connectorv1.GetOperationRequest{
OperationId: "idem-settlement-3",
})
if err == nil {
t.Fatal("expected not found error")
}
if status.Code(err) != codes.NotFound {
t.Fatalf("unexpected error code: got=%s want=%s", status.Code(err), codes.NotFound)
}
}

View File

@@ -68,20 +68,9 @@ type Config struct {
Treasury TreasuryConfig
}
type TelegramConfig struct {
AllowedChats []string
Users []TelegramUserBinding
}
type TelegramUserBinding struct {
TelegramUserID string
LedgerAccount string
}
type TreasuryConfig struct {
ExecutionDelay time.Duration
PollInterval time.Duration
Telegram TelegramConfig
Ledger LedgerConfig
Limits TreasuryLimitsConfig
}
@@ -181,39 +170,13 @@ func (s *Service) Shutdown() {
}
func (s *Service) startTreasuryModule() {
if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil {
if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil || s.repo.TreasuryTelegramUsers() == nil {
return
}
if s.cfg.DiscoveryRegistry == nil {
s.logger.Warn("Treasury module disabled: discovery registry is unavailable")
return
}
configuredUsers := s.cfg.Treasury.Telegram.Users
if len(configuredUsers) == 0 {
return
}
users := make([]treasurysvc.UserBinding, 0, len(configuredUsers))
configuredUserIDs := make([]string, 0, len(configuredUsers))
for _, binding := range configuredUsers {
userID := strings.TrimSpace(binding.TelegramUserID)
accountID := strings.TrimSpace(binding.LedgerAccount)
if userID != "" {
configuredUserIDs = append(configuredUserIDs, userID)
}
if userID == "" || accountID == "" {
continue
}
users = append(users, treasurysvc.UserBinding{
TelegramUserID: userID,
LedgerAccount: accountID,
})
}
if len(users) == 0 {
s.logger.Warn("Treasury module disabled: no valid treasury.telegram.users bindings",
zap.Int("configured_bindings", len(configuredUsers)),
zap.Strings("configured_user_ids", configuredUserIDs))
return
}
ledgerTimeout := s.cfg.Treasury.Ledger.Timeout
if ledgerTimeout <= 0 {
@@ -241,10 +204,9 @@ func (s *Service) startTreasuryModule() {
module, err := treasurysvc.NewModule(
s.logger,
s.repo.TreasuryRequests(),
s.repo.TreasuryTelegramUsers(),
ledgerClient,
treasurysvc.Config{
AllowedChats: s.cfg.Treasury.Telegram.AllowedChats,
Users: users,
ExecutionDelay: executionDelay,
PollInterval: pollInterval,
MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation,

View File

@@ -81,6 +81,7 @@ type fakeRepo struct {
tg *fakeTelegramStore
pending *fakePendingStore
treasury storage.TreasuryRequestsStore
users storage.TreasuryTelegramUsersStore
}
func (f *fakeRepo) Payments() storage.PaymentsStore {
@@ -99,6 +100,10 @@ func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore {
return f.treasury
}
func (f *fakeRepo) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
return f.users
}
type fakePendingStore struct {
mu sync.Mutex
records map[string]*storagemodel.PendingConfirmation

View File

@@ -14,5 +14,5 @@ func markdownCode(value string) string {
}
func markdownCommand(command Command) string {
return markdownCode(command.Slash())
return command.Slash()
}

View File

@@ -51,6 +51,16 @@ type TreasuryService interface {
CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
}
type UserBinding struct {
TelegramUserID string
LedgerAccountID string
AllowedChatIDs []string
}
type UserBindingResolver interface {
ResolveUserBinding(ctx context.Context, telegramUserID string) (*UserBinding, error)
}
type limitError interface {
error
LimitKind() string
@@ -65,9 +75,7 @@ type Router struct {
send SendTextFunc
tracker ScheduleTracker
allowedChats map[string]struct{}
userAccounts map[string]string
allowAnyChat bool
users UserBindingResolver
}
func NewRouter(
@@ -75,43 +83,23 @@ func NewRouter(
service TreasuryService,
send SendTextFunc,
tracker ScheduleTracker,
allowedChats []string,
userAccounts map[string]string,
users UserBindingResolver,
) *Router {
if logger != nil {
logger = logger.Named("treasury_router")
}
allowed := map[string]struct{}{}
for _, chatID := range allowedChats {
chatID = strings.TrimSpace(chatID)
if chatID == "" {
continue
}
allowed[chatID] = struct{}{}
}
users := map[string]string{}
for userID, accountID := range userAccounts {
userID = strings.TrimSpace(userID)
accountID = strings.TrimSpace(accountID)
if userID == "" || accountID == "" {
continue
}
users[userID] = accountID
}
return &Router{
logger: logger,
service: service,
dialogs: NewDialogs(),
send: send,
tracker: tracker,
allowedChats: allowed,
userAccounts: users,
allowAnyChat: len(allowed) == 0,
users: users,
}
}
func (r *Router) Enabled() bool {
return r != nil && r.service != nil && len(r.userAccounts) > 0
return r != nil && r.service != nil && r.users != nil
}
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
@@ -138,20 +126,28 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
)
}
if !r.allowAnyChat {
if _, ok := r.allowedChats[chatID]; !ok {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
binding, err := r.users.ResolveUserBinding(ctx, userID)
if err != nil {
if r.logger != nil {
r.logger.Warn("Failed to resolve treasury user binding",
zap.Error(err),
zap.String("telegram_user_id", userID),
zap.String("chat_id", chatID))
}
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check treasury authorization right now. Please try again.")
return true
}
}
accountID, ok := r.userAccounts[userID]
if !ok || strings.TrimSpace(accountID) == "" {
if binding == nil || strings.TrimSpace(binding.LedgerAccountID) == "" {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedMessage)
return true
}
if !isChatAllowed(chatID, binding.AllowedChatIDs) {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
return true
}
accountID := strings.TrimSpace(binding.LedgerAccountID)
switch command {
case CommandStart:
@@ -507,6 +503,22 @@ func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID stri
return profile
}
func isChatAllowed(chatID string, allowedChatIDs []string) bool {
chatID = strings.TrimSpace(chatID)
if chatID == "" {
return false
}
if len(allowedChatIDs) == 0 {
return true
}
for _, allowed := range allowedChatIDs {
if strings.TrimSpace(allowed) == chatID {
return true
}
}
return false
}
func formatSeconds(value int64) string {
if value == 1 {
return "1 second"

View File

@@ -12,6 +12,21 @@ import (
type fakeService struct{}
type fakeUserBindingResolver struct {
bindings map[string]*UserBinding
err error
}
func (f fakeUserBindingResolver) ResolveUserBinding(_ context.Context, telegramUserID string) (*UserBinding, error) {
if f.err != nil {
return nil, f.err
}
if f.bindings == nil {
return nil, nil
}
return f.bindings[telegramUserID], nil
}
func (fakeService) ExecutionDelay() time.Duration {
return 30 * time.Second
}
@@ -54,8 +69,15 @@ func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
return nil
},
nil,
[]string{"100"},
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
AllowedChatIDs: []string{"100"},
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
@@ -85,8 +107,15 @@ func TestRouterUnknownChatGetsDenied(t *testing.T) {
return nil
},
nil,
[]string{"100"},
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
AllowedChatIDs: []string{"100"},
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
@@ -116,8 +145,14 @@ func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
@@ -151,8 +186,14 @@ func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
@@ -182,8 +223,14 @@ func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
@@ -213,8 +260,14 @@ func TestRouterHelpAuthorizedShowsHelp(t *testing.T) {
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
@@ -244,8 +297,14 @@ func TestRouterStartUnauthorizedGetsDenied(t *testing.T) {
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
@@ -275,8 +334,14 @@ func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(t *testing.T) {
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{

View File

@@ -2,15 +2,7 @@ package treasury
import "time"
type UserBinding struct {
TelegramUserID string
LedgerAccount string
}
type Config struct {
AllowedChats []string
Users []UserBinding
ExecutionDelay time.Duration
PollInterval time.Duration

View File

@@ -26,6 +26,7 @@ type Module struct {
func NewModule(
logger mlogger.Logger,
repo storage.TreasuryRequestsStore,
users storage.TreasuryTelegramUsersStore,
ledgerClient ledger.Client,
cfg Config,
send bot.SendTextFunc,
@@ -33,6 +34,9 @@ func NewModule(
if logger != nil {
logger = logger.Named("treasury")
}
if users == nil {
return nil, merrors.InvalidArgument("treasury telegram users store is required", "users")
}
service, err := NewService(
logger,
repo,
@@ -45,23 +49,13 @@ func NewModule(
return nil, err
}
users := map[string]string{}
for _, binding := range cfg.Users {
userID := strings.TrimSpace(binding.TelegramUserID)
accountID := strings.TrimSpace(binding.LedgerAccount)
if userID == "" || accountID == "" {
continue
}
users[userID] = accountID
}
module := &Module{
logger: logger,
service: service,
ledger: ledgerClient,
}
module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval)
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, cfg.AllowedChats, users)
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, &botUsersAdapter{store: users})
return module, nil
}
@@ -99,6 +93,28 @@ type botServiceAdapter struct {
svc *Service
}
type botUsersAdapter struct {
store storage.TreasuryTelegramUsersStore
}
func (a *botUsersAdapter) ResolveUserBinding(ctx context.Context, telegramUserID string) (*bot.UserBinding, error) {
if a == nil || a.store == nil {
return nil, merrors.Internal("treasury users store unavailable")
}
record, err := a.store.FindByTelegramUserID(ctx, telegramUserID)
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return &bot.UserBinding{
TelegramUserID: strings.TrimSpace(record.TelegramUserID),
LedgerAccountID: strings.TrimSpace(record.LedgerAccountID),
AllowedChatIDs: normalizeChatIDs(record.AllowedChatIDs),
}, nil
}
func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) {
if a == nil || a.svc == nil {
return 0
@@ -164,3 +180,26 @@ func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string,
}
return a.svc.CancelRequest(ctx, requestID, telegramUserID)
}
func normalizeChatIDs(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, next := range values {
next = strings.TrimSpace(next)
if next == "" {
continue
}
if _, ok := seen[next]; ok {
continue
}
seen[next] = struct{}{}
out = append(out, next)
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -441,7 +441,7 @@ func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string
}
func newRequestID() string {
return "TGSETTLE-" + strings.ToUpper(bson.NewObjectID().Hex()[:8])
return "TG-TREASURY-" + strings.ToUpper(bson.NewObjectID().Hex())
}
func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string {

View File

@@ -23,6 +23,9 @@ type PaymentRecord struct {
storable.Base `bson:",inline" json:",inline"`
OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"`
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
ConfirmationRef string `bson:"confirmationRef,omitempty" json:"confirmation_ref,omitempty"`
ConfirmationMessageID string `bson:"confirmationMessageId,omitempty" json:"confirmation_message_id,omitempty"`
ConfirmationReplyMessageID string `bson:"confirmationReplyMessageId,omitempty" json:"confirmation_reply_message_id,omitempty"`
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"`

View File

@@ -5,6 +5,7 @@ const (
telegramConfirmationsCollection = "telegram_confirmations"
pendingConfirmationsCollection = "pending_confirmations"
treasuryRequestsCollection = "treasury_requests"
treasuryTelegramUsersCollection = "treasury_telegram_users"
)
func (*PaymentRecord) Collection() string {
@@ -22,3 +23,7 @@ func (*PendingConfirmation) Collection() string {
func (*TreasuryRequest) Collection() string {
return treasuryRequestsCollection
}
func (*TreasuryTelegramUser) Collection() string {
return treasuryTelegramUsersCollection
}

View File

@@ -49,3 +49,11 @@ type TreasuryRequest struct {
Active bool `bson:"active,omitempty" json:"active,omitempty"`
}
type TreasuryTelegramUser struct {
storable.Base `bson:",inline" json:",inline"`
TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"`
LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"`
AllowedChatIDs []string `bson:"allowedChatIds,omitempty" json:"allowed_chat_ids,omitempty"`
}

View File

@@ -25,6 +25,7 @@ type Repository struct {
tg storage.TelegramConfirmationsStore
pending storage.PendingConfirmationsStore
treasury storage.TreasuryRequestsStore
users storage.TreasuryTelegramUsersStore
outbox gatewayoutbox.Store
}
@@ -80,6 +81,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests"))
return nil, err
}
treasuryUsersStore, err := store.NewTreasuryTelegramUsers(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise treasury telegram users store", zap.Error(err), zap.String("store", "treasury_telegram_users"))
return nil, err
}
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
@@ -89,6 +95,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
result.tg = tgStore
result.pending = pendingStore
result.treasury = treasuryStore
result.users = treasuryUsersStore
result.outbox = outboxStore
result.logger.Info("Payment gateway MongoDB storage initialised")
return result, nil
@@ -110,6 +117,10 @@ func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore {
return r.treasury
}
func (r *Repository) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
return r.users
}
func (r *Repository) Outbox() gatewayoutbox.Store {
return r.outbox
}

View File

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

View File

@@ -0,0 +1,245 @@
package store
import (
"context"
"strings"
"testing"
"time"
"github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type fakePaymentsRepo struct {
repository.Repository
records map[string]*model.PaymentRecord
findErrByCall map[int]error
duplicateWhenZeroID bool
findCalls int
upsertCalls int
upsertIDs []bson.ObjectID
upsertIdempotencyKey []string
}
func (f *fakePaymentsRepo) FindOneByFilter(_ context.Context, query repository.FilterQuery, result storable.Storable) error {
f.findCalls++
if err, ok := f.findErrByCall[f.findCalls]; ok {
return err
}
rec, ok := result.(*model.PaymentRecord)
if !ok {
return merrors.InvalidDataType("expected *model.PaymentRecord")
}
doc := query.BuildQuery()
if key := stringField(doc, fieldIdempotencyKey); key != "" {
stored, ok := f.records[key]
if !ok {
return merrors.NoData("payment not found by filter")
}
*rec = *stored
return nil
}
if operationRef := stringField(doc, fieldOperationRef); operationRef != "" {
for _, stored := range f.records {
if strings.TrimSpace(stored.OperationRef) == operationRef {
*rec = *stored
return nil
}
}
return merrors.NoData("payment not found by operation ref")
}
return merrors.NoData("payment not found")
}
func (f *fakePaymentsRepo) Upsert(_ context.Context, obj storable.Storable) error {
f.upsertCalls++
rec, ok := obj.(*model.PaymentRecord)
if !ok {
return merrors.InvalidDataType("expected *model.PaymentRecord")
}
f.upsertIDs = append(f.upsertIDs, rec.ID)
f.upsertIdempotencyKey = append(f.upsertIdempotencyKey, rec.IdempotencyKey)
if f.duplicateWhenZeroID && rec.ID.IsZero() {
if _, exists := f.records[rec.IdempotencyKey]; exists {
return mongo.WriteException{
WriteErrors: mongo.WriteErrors{
{
Code: 11000,
Message: "E11000 duplicate key error collection: tgsettle_gateway.payments",
},
},
}
}
}
copyRec := *rec
if copyRec.ID.IsZero() {
copyRec.ID = bson.NewObjectID()
}
if copyRec.CreatedAt.IsZero() {
copyRec.CreatedAt = time.Now().UTC()
}
copyRec.UpdatedAt = time.Now().UTC()
if f.records == nil {
f.records = map[string]*model.PaymentRecord{}
}
f.records[copyRec.IdempotencyKey] = &copyRec
*rec = copyRec
return nil
}
func TestPaymentsUpsert_ReusesExistingIDFromIdempotencyLookup(t *testing.T) {
key := "idem-existing"
existingID := bson.NewObjectID()
existingCreatedAt := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC)
repo := &fakePaymentsRepo{
records: map[string]*model.PaymentRecord{
key: {
Base: storable.Base{
ID: existingID,
CreatedAt: existingCreatedAt,
UpdatedAt: existingCreatedAt,
},
IdempotencyKey: key,
IntentRef: "pi-old",
},
},
duplicateWhenZeroID: true,
}
store := &Payments{logger: zap.NewNop(), repo: repo}
record := &model.PaymentRecord{
IdempotencyKey: key,
IntentRef: "pi-new",
QuoteRef: "quote-new",
}
if err := store.Upsert(context.Background(), record); err != nil {
t.Fatalf("upsert failed: %v", err)
}
if repo.upsertCalls != 1 {
t.Fatalf("expected one upsert call, got %d", repo.upsertCalls)
}
if len(repo.upsertIDs) != 1 || repo.upsertIDs[0] != existingID {
t.Fatalf("expected upsert to reuse existing id %s, got %+v", existingID.Hex(), repo.upsertIDs)
}
if record.ID != existingID {
t.Fatalf("record ID mismatch: got %s want %s", record.ID.Hex(), existingID.Hex())
}
}
func TestPaymentsUpsert_RetriesAfterDuplicateKeyRace(t *testing.T) {
key := "idem-race"
existingID := bson.NewObjectID()
repo := &fakePaymentsRepo{
records: map[string]*model.PaymentRecord{
key: {
Base: storable.Base{
ID: existingID,
CreatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 3, 6, 10, 1, 0, 0, time.UTC),
},
IdempotencyKey: key,
IntentRef: "pi-existing",
},
},
findErrByCall: map[int]error{
1: merrors.NoData("payment not found by filter"),
},
duplicateWhenZeroID: true,
}
store := &Payments{logger: zap.NewNop(), repo: repo}
record := &model.PaymentRecord{
IdempotencyKey: key,
IntentRef: "pi-new",
QuoteRef: "quote-new",
}
if err := store.Upsert(context.Background(), record); err != nil {
t.Fatalf("upsert failed: %v", err)
}
if repo.upsertCalls != 2 {
t.Fatalf("expected two upsert calls, got %d", repo.upsertCalls)
}
if len(repo.upsertIDs) != 2 {
t.Fatalf("expected two upsert IDs, got %d", len(repo.upsertIDs))
}
if !repo.upsertIDs[0].IsZero() {
t.Fatalf("expected first upsert to use zero id due stale read, got %s", repo.upsertIDs[0].Hex())
}
if repo.upsertIDs[1] != existingID {
t.Fatalf("expected retry to use existing id %s, got %s", existingID.Hex(), repo.upsertIDs[1].Hex())
}
}
func TestPaymentsUpsert_PropagatesNoSuchTransactionAfterDuplicate(t *testing.T) {
key := "idem-nosuchtx"
repo := &fakePaymentsRepo{
records: map[string]*model.PaymentRecord{
key: {
Base: storable.Base{
ID: bson.NewObjectID(),
CreatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 3, 6, 10, 2, 0, 0, time.UTC),
},
IdempotencyKey: key,
IntentRef: "pi-existing",
},
},
findErrByCall: map[int]error{
1: merrors.NoData("payment not found by filter"),
2: mongo.CommandError{
Code: 251,
Name: "NoSuchTransaction",
Message: "Transaction with { txnNumber: 2 } has been aborted.",
},
},
duplicateWhenZeroID: true,
}
store := &Payments{logger: zap.NewNop(), repo: repo}
record := &model.PaymentRecord{
IdempotencyKey: key,
IntentRef: "pi-new",
QuoteRef: "quote-new",
}
err := store.Upsert(context.Background(), record)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "NoSuchTransaction") {
t.Fatalf("expected NoSuchTransaction error, got %v", err)
}
if repo.upsertCalls != 1 {
t.Fatalf("expected one upsert attempt before lookup failure, got %d", repo.upsertCalls)
}
}
func stringField(doc bson.D, key string) string {
for _, entry := range doc {
if entry.Key != key {
continue
}
res, _ := entry.Value.(string)
return strings.TrimSpace(res)
}
return ""
}

View File

@@ -296,20 +296,48 @@ func (t *TreasuryRequests) Update(ctx context.Context, record *model.TreasuryReq
Set(repository.Field("operationType"), record.OperationType).
Set(repository.Field("telegramUserId"), record.TelegramUserID).
Set(repository.Field("ledgerAccountId"), record.LedgerAccountID).
Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode).
Set(repository.Field("organizationRef"), record.OrganizationRef).
Set(repository.Field("chatId"), record.ChatID).
Set(repository.Field("amount"), record.Amount).
Set(repository.Field("currency"), record.Currency).
Set(repository.Field(fieldTreasuryStatus), record.Status).
Set(repository.Field("confirmedAt"), record.ConfirmedAt).
Set(repository.Field("scheduledAt"), record.ScheduledAt).
Set(repository.Field("executedAt"), record.ExecutedAt).
Set(repository.Field("cancelledAt"), record.CancelledAt).
Set(repository.Field(fieldTreasuryIdempotencyKey), record.IdempotencyKey).
Set(repository.Field("ledgerReference"), record.LedgerReference).
Set(repository.Field("errorMessage"), record.ErrorMessage).
Set(repository.Field(fieldTreasuryActive), record.Active)
if record.LedgerAccountCode != "" {
patch = patch.Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode)
} else {
patch = patch.Unset(repository.Field("ledgerAccountCode"))
}
if !record.ConfirmedAt.IsZero() {
patch = patch.Set(repository.Field("confirmedAt"), record.ConfirmedAt)
} else {
patch = patch.Unset(repository.Field("confirmedAt"))
}
if !record.ScheduledAt.IsZero() {
patch = patch.Set(repository.Field("scheduledAt"), record.ScheduledAt)
} else {
patch = patch.Unset(repository.Field("scheduledAt"))
}
if !record.ExecutedAt.IsZero() {
patch = patch.Set(repository.Field("executedAt"), record.ExecutedAt)
} else {
patch = patch.Unset(repository.Field("executedAt"))
}
if !record.CancelledAt.IsZero() {
patch = patch.Set(repository.Field("cancelledAt"), record.CancelledAt)
} else {
patch = patch.Unset(repository.Field("cancelledAt"))
}
if record.LedgerReference != "" {
patch = patch.Set(repository.Field("ledgerReference"), record.LedgerReference)
} else {
patch = patch.Unset(repository.Field("ledgerReference"))
}
if record.ErrorMessage != "" {
patch = patch.Set(repository.Field("errorMessage"), record.ErrorMessage)
} else {
patch = patch.Unset(repository.Field("errorMessage"))
}
if _, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, record.RequestID), patch); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
t.logger.Warn("Failed to update treasury request", zap.Error(err), zap.String("request_id", record.RequestID))

View File

@@ -15,6 +15,7 @@ type Repository interface {
TelegramConfirmations() TelegramConfirmationsStore
PendingConfirmations() PendingConfirmationsStore
TreasuryRequests() TreasuryRequestsStore
TreasuryTelegramUsers() TreasuryTelegramUsersStore
}
type PaymentsStore interface {
@@ -46,3 +47,7 @@ type TreasuryRequestsStore interface {
Update(ctx context.Context, record *model.TreasuryRequest) error
ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]model.TreasuryRequest, error)
}
type TreasuryTelegramUsersStore interface {
FindByTelegramUserID(ctx context.Context, telegramUserID string) (*model.TreasuryTelegramUser, error)
}

View File

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

View File

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

View File

@@ -45,6 +45,9 @@ const (
type StepShell struct {
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
}
@@ -53,6 +56,9 @@ type StepShell struct {
type StepExecution struct {
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
State StepState `bson:"state" json:"state"`

View File

@@ -143,10 +143,16 @@ func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].report_visibility is invalid")
}
userLabel := strings.TrimSpace(shell[i].UserLabel)
railValue := model.ParseRail(string(shell[i].Rail))
gatewayID := strings.TrimSpace(shell[i].Gateway)
instanceID := strings.TrimSpace(shell[i].InstanceID)
out = append(out, StepExecution{
StepRef: stepRef,
StepCode: stepCode,
Rail: railValue,
Gateway: gatewayID,
InstanceID: instanceID,
ReportVisibility: visibility,
UserLabel: userLabel,
State: StepStatePending,

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -42,7 +43,15 @@ func TestCreate_OK(t *testing.T) {
QuoteSnapshot: quote,
Steps: []StepShell{
{StepRef: " s1 ", StepCode: " reserve_funds ", ReportVisibility: model.ReportVisibilityHidden},
{StepRef: "s2", StepCode: "submit_gateway", ReportVisibility: model.ReportVisibilityUser, UserLabel: " Card payout "},
{
StepRef: "s2",
StepCode: "submit_gateway",
Rail: discovery.RailProviderSettlement,
Gateway: "payment_gateway_settlement",
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
ReportVisibility: model.ReportVisibilityUser,
UserLabel: " Card payout ",
},
},
})
if err != nil {
@@ -111,6 +120,15 @@ func TestCreate_OK(t *testing.T) {
if got, want := payment.StepExecutions[1].UserLabel, "Card payout"; got != want {
t.Fatalf("unexpected second step user label: got=%q want=%q", got, want)
}
if got, want := payment.StepExecutions[1].Rail, model.Rail(discovery.RailProviderSettlement); got != want {
t.Fatalf("unexpected second step rail: got=%q want=%q", got, want)
}
if got, want := payment.StepExecutions[1].Gateway, "payment_gateway_settlement"; got != want {
t.Fatalf("unexpected second step gateway: got=%q want=%q", got, want)
}
if got, want := payment.StepExecutions[1].InstanceID, "04a54fec-20f4-4250-a715-eb9886e13e12"; got != want {
t.Fatalf("unexpected second step instance_id: got=%q want=%q", got, want)
}
// Verify immutable snapshot semantics by ensuring clones were created.
payment.IntentSnapshot.Ref = "changed"

View File

@@ -221,6 +221,9 @@ func toStepShells(graph *xplan.Graph) []agg.StepShell {
out = append(out, agg.StepShell{
StepRef: graph.Steps[i].StepRef,
StepCode: graph.Steps[i].StepCode,
Rail: graph.Steps[i].Rail,
Gateway: graph.Steps[i].Gateway,
InstanceID: graph.Steps[i].InstanceID,
ReportVisibility: graph.Steps[i].Visibility,
UserLabel: graph.Steps[i].UserLabel,
})

View File

@@ -408,6 +408,15 @@ func stepExecutionEqual(left, right agg.StepExecution) bool {
if left.StepRef != right.StepRef || left.StepCode != right.StepCode {
return false
}
if left.Rail != right.Rail {
return false
}
if strings.TrimSpace(left.Gateway) != strings.TrimSpace(right.Gateway) {
return false
}
if strings.TrimSpace(left.InstanceID) != strings.TrimSpace(right.InstanceID) {
return false
}
if left.State != right.State || left.Attempt != right.Attempt {
return false
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
)
@@ -144,6 +145,16 @@ func (s *svc) normalizeStepExecutions(
stepCode = stepsByRef[stepRef].StepCode
}
exec.StepCode = stepCode
step := stepsByRef[stepRef]
if exec.Rail == discovery.RailUnspecified {
exec.Rail = step.Rail
}
if strings.TrimSpace(exec.Gateway) == "" {
exec.Gateway = strings.TrimSpace(step.Gateway)
}
if strings.TrimSpace(exec.InstanceID) == "" {
exec.InstanceID = strings.TrimSpace(step.InstanceID)
}
exec.ReportVisibility = effectiveStepVisibility(exec.ReportVisibility, stepsByRef[stepRef].Visibility)
exec.UserLabel = firstNonEmpty(exec.UserLabel, stepsByRef[stepRef].UserLabel)
cloned := cloneStepExecution(exec)
@@ -158,6 +169,9 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste
exec.FailureCode = strings.TrimSpace(exec.FailureCode)
exec.FailureMsg = strings.TrimSpace(exec.FailureMsg)
exec.UserLabel = strings.TrimSpace(exec.UserLabel)
exec.Gateway = strings.TrimSpace(exec.Gateway)
exec.InstanceID = strings.TrimSpace(exec.InstanceID)
exec.Rail = model.ParseRail(string(exec.Rail))
exec.ReportVisibility = model.NormalizeReportVisibility(exec.ReportVisibility)
exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
if exec.StepRef == "" {
@@ -197,6 +211,9 @@ func seedMissingExecutions(
executionsByRef[stepRef] = &agg.StepExecution{
StepRef: step.StepRef,
StepCode: step.StepCode,
Rail: step.Rail,
Gateway: strings.TrimSpace(step.Gateway),
InstanceID: strings.TrimSpace(step.InstanceID),
ReportVisibility: effectiveStepVisibility(model.ReportVisibilityUnspecified, step.Visibility),
UserLabel: strings.TrimSpace(step.UserLabel),
State: agg.StepStatePending,

View File

@@ -13,7 +13,6 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/payments/storage/model"
cons "github.com/tech/sendico/pkg/messaging/consumer"
paymentgatewaynotifications "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
@@ -412,6 +411,9 @@ func buildObserveCandidate(step agg.StepExecution) (runningObserveCandidate, boo
}
}
}
if candidate.gatewayInstanceID == "" {
candidate.gatewayInstanceID = strings.TrimSpace(step.InstanceID)
}
if candidate.stepRef == "" || candidate.transferRef == "" {
return runningObserveCandidate{}, false
}
@@ -475,7 +477,7 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment
StepRef: candidate.stepRef,
OperationRef: firstNonEmpty(strings.TrimSpace(transfer.GetOperationRef()), candidate.operationRef),
TransferRef: strings.TrimSpace(candidate.transferRef),
GatewayInstanceID: firstNonEmpty(candidate.gatewayInstanceID, strings.TrimSpace(gateway.InstanceID), strings.TrimSpace(gateway.ID)),
GatewayInstanceID: resolvedObserveGatewayID(candidate.gatewayInstanceID, gateway),
Status: status,
}
switch status {
@@ -517,39 +519,106 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment
}
func (s *Service) resolveObserveGateway(ctx context.Context, payment *agg.Payment, candidate runningObserveCandidate) (*model.GatewayInstanceDescriptor, error) {
if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" {
if s == nil || s.gatewayRegistry == nil {
return nil, errors.New("observe polling: gateway registry is unavailable")
}
items, err := s.gatewayRegistry.List(ctx)
if err == nil {
for i := range items {
item := items[i]
if item == nil || !item.IsEnabled {
continue
if err != nil {
return nil, err
}
if !strings.EqualFold(strings.TrimSpace(item.ID), gatewayID) && !strings.EqualFold(strings.TrimSpace(item.InstanceID), gatewayID) {
continue
}
if strings.TrimSpace(item.InvokeURI) == "" {
continue
hint, hasHint := observeStepGatewayHint(payment, candidate.stepRef)
expectedRail := model.Rail(discovery.RailUnspecified)
if hasHint {
expectedRail = hint.rail
}
if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" {
if item := findEnabledGatewayDescriptor(items, gatewayID, expectedRail); item != nil {
return item, nil
}
}
if hasHint {
if item := findEnabledGatewayDescriptor(items, hint.instanceID, hint.rail); item != nil {
return item, nil
}
if item := findEnabledGatewayDescriptor(items, hint.gatewayID, hint.rail); item != nil {
return item, nil
}
}
return nil, errors.New("observe polling: gateway instance not found")
}
executor := gatewayCryptoExecutor{
gatewayRegistry: s.gatewayRegistry,
type observeStepHint struct {
rail model.Rail
gatewayID string
instanceID string
}
func observeStepGatewayHint(payment *agg.Payment, stepRef string) (observeStepHint, bool) {
if payment == nil {
return observeStepHint{}, false
}
step := xplan.Step{
Rail: discovery.RailCrypto,
key := strings.TrimSpace(stepRef)
if key == "" {
return observeStepHint{}, false
}
if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" {
step.InstanceID = gatewayID
step.Gateway = gatewayID
} else if gateway, instanceID, ok := sourceCryptoHop(payment); ok {
step.Gateway = strings.TrimSpace(gateway)
step.InstanceID = strings.TrimSpace(instanceID)
for i := range payment.StepExecutions {
step := payment.StepExecutions[i]
if !strings.EqualFold(strings.TrimSpace(step.StepRef), key) {
continue
}
return executor.resolveGateway(ctx, step)
hint := observeStepHint{
rail: model.ParseRail(string(step.Rail)),
gatewayID: strings.TrimSpace(step.Gateway),
instanceID: strings.TrimSpace(step.InstanceID),
}
if hint.gatewayID == "" && hint.instanceID == "" {
return observeStepHint{}, false
}
return hint, true
}
return observeStepHint{}, false
}
func findEnabledGatewayDescriptor(items []*model.GatewayInstanceDescriptor, identifier string, rail model.Rail) *model.GatewayInstanceDescriptor {
key := strings.TrimSpace(identifier)
if key == "" {
return nil
}
for i := range items {
item := items[i]
if item == nil || !item.IsEnabled || strings.TrimSpace(item.InvokeURI) == "" {
continue
}
if rail != model.Rail(discovery.RailUnspecified) && model.ParseRail(string(item.Rail)) != rail {
continue
}
if strings.EqualFold(strings.TrimSpace(item.ID), key) || strings.EqualFold(strings.TrimSpace(item.InstanceID), key) {
return item
}
}
return nil
}
func resolvedObserveGatewayID(candidateGatewayID string, gateway *model.GatewayInstanceDescriptor) string {
candidateID := strings.TrimSpace(candidateGatewayID)
if candidateID != "" && gatewayIdentifierMatches(gateway, candidateID) {
return candidateID
}
if gateway == nil {
return ""
}
return firstNonEmpty(strings.TrimSpace(gateway.InstanceID), strings.TrimSpace(gateway.ID))
}
func gatewayIdentifierMatches(gateway *model.GatewayInstanceDescriptor, identifier string) bool {
if gateway == nil {
return false
}
key := strings.TrimSpace(identifier)
if key == "" {
return false
}
return strings.EqualFold(strings.TrimSpace(gateway.ID), key) || strings.EqualFold(strings.TrimSpace(gateway.InstanceID), key)
}
func mapTransferStatus(status chainv1.TransferStatus) (gatewayStatus erecon.GatewayStatus, terminal bool, ok bool) {

View File

@@ -3,14 +3,15 @@ package orchestrator
import (
"context"
"errors"
"github.com/tech/sendico/pkg/discovery"
"testing"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/discovery"
pm "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/payments/rail"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
@@ -412,6 +413,30 @@ func TestRunningObserveCandidates_UsesCardPayoutRefAsTransfer(t *testing.T) {
}
}
func TestRunningObserveCandidates_UsesPlannedStepInstanceWhenExternalRefGatewayMissing(t *testing.T) {
payment := &agg.Payment{
StepExecutions: []agg.StepExecution{
{
StepRef: "hop_2_settlement_observe",
StepCode: "hop.2.settlement.observe",
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
State: agg.StepStateRunning,
ExternalRefs: []agg.ExternalRef{
{Kind: erecon.ExternalRefKindTransfer, Ref: "trf-2"},
},
},
},
}
candidates := runningObserveCandidates(payment)
if len(candidates) != 1 {
t.Fatalf("candidate count mismatch: got=%d want=1", len(candidates))
}
if got, want := candidates[0].gatewayInstanceID, "04a54fec-20f4-4250-a715-eb9886e13e12"; got != want {
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
}
}
func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *testing.T) {
svc := &Service{
gatewayRegistry: &fakeGatewayRegistry{
@@ -466,5 +491,192 @@ func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *test
}
}
func TestResolveObserveGateway_UsesPlannedStepGatewayWhenExternalRefInstanceIsStale(t *testing.T) {
svc := &Service{
gatewayRegistry: &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "payment_gateway_settlement",
InstanceID: "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4",
Rail: discovery.RailProviderSettlement,
InvokeURI: "grpc://tgsettle-gateway-new",
IsEnabled: true,
},
{
ID: "crypto_rail_gateway_tron_mainnet",
InstanceID: "fbef2c3b-ff66-447e-8bba-fa666a955855",
Rail: discovery.RailCrypto,
InvokeURI: "grpc://tron-gateway",
IsEnabled: true,
},
},
},
}
payment := &agg.Payment{
StepExecutions: []agg.StepExecution{
{
StepRef: "hop_2_settlement_observe",
StepCode: "hop.2.settlement.observe",
Rail: discovery.RailProviderSettlement,
Gateway: "payment_gateway_settlement",
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
},
},
}
gateway, err := svc.resolveObserveGateway(context.Background(), payment, runningObserveCandidate{
stepRef: "hop_2_settlement_observe",
transferRef: "trf-1",
gatewayInstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
})
if err != nil {
t.Fatalf("resolveObserveGateway returned error: %v", err)
}
if gateway == nil {
t.Fatal("expected gateway")
}
if got, want := gateway.ID, "payment_gateway_settlement"; got != want {
t.Fatalf("gateway id mismatch: got=%q want=%q", got, want)
}
if got, want := gateway.InstanceID, "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4"; got != want {
t.Fatalf("gateway instance mismatch: got=%q want=%q", got, want)
}
}
func TestResolveObserveGateway_FailsWhenPlannedGatewayMetadataIsMissing(t *testing.T) {
svc := &Service{
gatewayRegistry: &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto_rail_gateway_tron_mainnet",
InstanceID: "fbef2c3b-ff66-447e-8bba-fa666a955855",
Rail: discovery.RailCrypto,
InvokeURI: "grpc://tron-gateway",
IsEnabled: true,
},
},
},
}
payment := &agg.Payment{
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{
Index: 1,
Rail: "CRYPTO",
Gateway: "crypto_rail_gateway_tron_mainnet",
InstanceID: "fbef2c3b-ff66-447e-8bba-fa666a955855",
Role: paymenttypes.QuoteRouteHopRoleSource,
},
},
},
},
}
gateway, err := svc.resolveObserveGateway(context.Background(), payment, runningObserveCandidate{
stepRef: "hop_2_settlement_observe",
transferRef: "trf-1",
gatewayInstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
})
if err == nil {
t.Fatal("expected gateway resolution error")
}
if gateway != nil {
t.Fatal("expected nil gateway on resolution failure")
}
}
func TestPollObserveCandidate_UsesResolvedGatewayAfterInstanceRotation(t *testing.T) {
orgID := bson.NewObjectID()
transferRef := "b6874b55-20b0-425d-9e47-d430964b1616:hop_2_settlement_fx_convert"
operationRef := "69aabf823555e083d23b2964:hop_2_settlement_fx_convert"
var requestedTransferRef string
client := &chainclient.Fake{
GetTransferFn: func(_ context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
requestedTransferRef = req.GetTransferRef()
return &chainv1.GetTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: req.GetTransferRef(),
OperationRef: operationRef,
Status: chainv1.TransferStatus_TRANSFER_SUCCESS,
},
}, nil
},
}
resolver := &fakeGatewayInvokeResolver{client: client}
v2 := &fakeExternalRuntimeV2{}
svc := &Service{
logger: zap.NewNop(),
v2: v2,
gatewayInvokeResolver: resolver,
gatewayRegistry: &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "payment_gateway_settlement",
InstanceID: "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4",
Rail: discovery.RailProviderSettlement,
InvokeURI: "grpc://tgsettle-gateway-new",
IsEnabled: true,
},
},
},
}
payment := &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "69aabf823555e083d23b2964",
StepExecutions: []agg.StepExecution{
{
StepRef: "hop_2_settlement_observe",
StepCode: "hop.2.settlement.observe",
Rail: discovery.RailProviderSettlement,
Gateway: "payment_gateway_settlement",
InstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
State: agg.StepStateRunning,
ExternalRefs: []agg.ExternalRef{
{
GatewayInstanceID: "04a54fec-20f4-4250-a715-eb9886e13e12",
Kind: erecon.ExternalRefKindTransfer,
Ref: transferRef,
},
},
},
},
}
candidates := runningObserveCandidates(payment)
if len(candidates) != 1 {
t.Fatalf("candidate count mismatch: got=%d want=1", len(candidates))
}
svc.pollObserveCandidate(context.Background(), payment, candidates[0])
if got, want := resolver.lastInvokeURI, "grpc://tgsettle-gateway-new"; got != want {
t.Fatalf("invoke uri mismatch: got=%q want=%q", got, want)
}
if got, want := requestedTransferRef, transferRef; got != want {
t.Fatalf("transfer_ref lookup mismatch: got=%q want=%q", got, want)
}
if v2.reconcileInput == nil || v2.reconcileInput.Event.Gateway == nil {
t.Fatal("expected reconcile gateway event")
}
gw := v2.reconcileInput.Event.Gateway
if got, want := gw.StepRef, "hop_2_settlement_observe"; got != want {
t.Fatalf("step_ref mismatch: got=%q want=%q", got, want)
}
if got, want := gw.Status, erecon.GatewayStatusSuccess; got != want {
t.Fatalf("status mismatch: got=%q want=%q", got, want)
}
if got, want := gw.OperationRef, operationRef; got != want {
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := gw.GatewayInstanceID, "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4"; got != want {
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
}
}
var _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil)
var _ psvc.Service = (*fakeExternalRuntimeV2)(nil)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -228,7 +228,8 @@ RouteBase payoutShellRoute() => ShellRoute(
onGoToPaymentWithoutRecipient: (type) =>
_startPayment(context, recipient: null, paymentType: type),
onTopUp: (wallet) => _openWalletTopUp(context, wallet),
onWalletTap: (wallet) => _openWalletEdit(context, wallet),
onLedgerAddFunds: (account) => _openLedgerAddFunds(context, account),
onWalletTap: (wallet) => _openWalletTopUp(context, wallet),
onLedgerTap: (account) => _openLedgerEdit(context, account),
),
),
@@ -306,6 +307,8 @@ RouteBase payoutShellRoute() => ShellRoute(
child: PaymentPage(
onBack: (_) => _popOrGo(context),
initialPaymentType: PayoutRoutes.paymentTypeFromState(state),
initialDestinationLedgerAccountRef:
PayoutRoutes.destinationLedgerAccountRefFromState(state),
fallbackDestination: fallbackDestination,
),
);
@@ -382,12 +385,6 @@ void _openEditRecipient(BuildContext context, {required Recipient recipient}) {
context.pushNamed(PayoutRoutes.editRecipient);
}
void _openWalletEdit(BuildContext context, Wallet wallet) {
context.read<PaymentSourceController>().selectWallet(wallet);
context.read<WalletsController>().selectWallet(wallet);
context.pushToEditWallet();
}
void _openLedgerEdit(BuildContext context, LedgerAccount account) {
context.read<PaymentSourceController>().selectLedgerByRef(
account.ledgerAccountRef,
@@ -395,6 +392,20 @@ void _openLedgerEdit(BuildContext context, LedgerAccount account) {
context.pushToEditWallet();
}
void _openLedgerAddFunds(BuildContext context, LedgerAccount account) {
context.read<PaymentSourceController>().selectLedgerByRef(
account.ledgerAccountRef,
);
context.read<RecipientsProvider>().setCurrentObject(null);
context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: PaymentType.ledger,
destinationLedgerAccountRef: account.ledgerAccountRef,
),
);
}
void _openWalletTopUp(BuildContext context, Wallet wallet) {
context.read<WalletsController>().selectWallet(wallet);
context.pushToWalletTopUp();

View File

@@ -0,0 +1,19 @@
import 'package:flutter/foundation.dart';
class BalanceActionsUiController extends ChangeNotifier {
int? _hoveredButtonIndex;
int? get hoveredButtonIndex => _hoveredButtonIndex;
bool isExpanded(int index) => _hoveredButtonIndex == index;
void onHoverChanged(int index, bool hovered) {
final next = hovered
? index
: (_hoveredButtonIndex == index ? null : _hoveredButtonIndex);
if (next == _hoveredButtonIndex) return;
_hoveredButtonIndex = next;
notifyListeners();
}
}

View File

@@ -1,14 +1,22 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
import 'package:pweb/models/dashboard/balance_item.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
class BalanceCarouselController extends ChangeNotifier {
BalanceCarouselController()
: pageController = PageController(
viewportFraction: WalletCardConfig.viewportFraction,
);
class BalanceCarouselController with ChangeNotifier {
WalletsController? _walletsController;
List<BalanceItem> _items = const <BalanceItem>[BalanceItem.addAction()];
int _index = 0;
final PageController pageController;
List<BalanceItem> get items => _items;
int get index => _index;
@@ -31,6 +39,7 @@ class BalanceCarouselController with ChangeNotifier {
_items = nextItems;
_index = nextIndex;
_syncPageController();
if (hasItemsChanged || hasIndexChanged) {
notifyListeners();
@@ -49,9 +58,24 @@ class BalanceCarouselController with ChangeNotifier {
notifyListeners();
}
void goBack() => onPageChanged(_index - 1);
void goBack() => animateTo(_index - 1);
void goForward() => onPageChanged(_index + 1);
void goForward() => animateTo(_index + 1);
void animateTo(int index) {
final target = _clampIndex(index, _items.length);
if (!pageController.hasClients) {
onPageChanged(target);
return;
}
pageController.animateToPage(
target,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
}
void syncPageController() => _syncPageController();
int _resolveNextIndex(
List<BalanceItem> nextItems,
@@ -73,14 +97,19 @@ class BalanceCarouselController with ChangeNotifier {
String? _currentWalletRef(List<BalanceItem> items, int index) {
if (items.isEmpty || index < 0 || index >= items.length) return null;
final current = items[index];
if (!current.isWallet) return null;
return current.wallet?.id;
return switch (current) {
WalletBalanceItem(:final wallet) => wallet.id,
_ => null,
};
}
int? _walletIndexByRef(List<BalanceItem> items, String? walletRef) {
if (walletRef == null || walletRef.isEmpty) return null;
final idx = items.indexWhere(
(item) => item.isWallet && item.wallet?.id == walletRef,
(item) => switch (item) {
WalletBalanceItem(:final wallet) => wallet.id == walletRef,
_ => false,
},
);
if (idx < 0) return null;
return idx;
@@ -97,17 +126,17 @@ class BalanceCarouselController with ChangeNotifier {
for (var i = 0; i < left.length; i++) {
final a = left[i];
final b = right[i];
if (a.type != b.type) return false;
if (a.runtimeType != b.runtimeType) return false;
if (_itemIdentity(a) != _itemIdentity(b)) return false;
}
return true;
}
String _itemIdentity(BalanceItem item) => switch (item.type) {
BalanceItemType.wallet => item.wallet?.id ?? '',
BalanceItemType.ledger => item.account?.ledgerAccountRef ?? '',
BalanceItemType.addAction => 'add',
String _itemIdentity(BalanceItem item) => switch (item) {
WalletBalanceItem(:final wallet) => wallet.id,
LedgerBalanceItem(:final account) => account.ledgerAccountRef,
AddBalanceActionItem() => 'add',
};
void _syncSelectedWallet() {
@@ -115,10 +144,23 @@ class BalanceCarouselController with ChangeNotifier {
if (walletsController == null || _items.isEmpty) return;
final current = _items[_index];
if (!current.isWallet || current.wallet == null) return;
final wallet = current.wallet!;
if (current is! WalletBalanceItem) return;
final wallet = current.wallet;
if (walletsController.selectedWallet?.id == wallet.id) return;
walletsController.selectWallet(wallet);
}
void _syncPageController() {
if (!pageController.hasClients || _items.isEmpty) return;
final current = pageController.page?.round();
final target = _clampIndex(_index, _items.length);
if (current == target) return;
pageController.jumpToPage(target);
}
@override
void dispose() {
pageController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceActionButtonState {
final String label;
final IconData icon;
final VoidCallback onPressed;
const BalanceActionButtonState({
required this.label,
required this.icon,
required this.onPressed,
});
}
class BalanceActionsState {
final BalanceActionButtonState topLeading;
final BalanceActionButtonState topTrailing;
final BalanceActionButtonState bottom;
const BalanceActionsState({
required this.topLeading,
required this.topTrailing,
required this.bottom,
});
}
class BalanceSourceActionsController {
const BalanceSourceActionsController();
BalanceActionsState wallet({
required BuildContext context,
required String walletRef,
required VoidCallback onAddFunds,
}) {
final l10n = AppLocalizations.of(context)!;
return BalanceActionsState(
topLeading: BalanceActionButtonState(
label: l10n.operationfryTitle,
icon: Icons.history_rounded,
onPressed: () => _openWalletOperationHistory(context, walletRef),
),
topTrailing: BalanceActionButtonState(
label: l10n.send,
icon: Icons.send_rounded,
onPressed: () => _sendWalletPayout(context, walletRef),
),
bottom: BalanceActionButtonState(
label: '${l10n.details} / ${l10n.addFunds}',
icon: Icons.account_balance_wallet_rounded,
onPressed: onAddFunds,
),
);
}
BalanceActionsState ledger({
required BuildContext context,
required String ledgerAccountRef,
required VoidCallback onAddFunds,
required VoidCallback onWalletDetails,
}) {
final l10n = AppLocalizations.of(context)!;
return BalanceActionsState(
topLeading: BalanceActionButtonState(
label: '${l10n.operationfryTitle} / ${l10n.details}',
icon: Icons.receipt_long_rounded,
onPressed: onWalletDetails,
),
topTrailing: BalanceActionButtonState(
label: l10n.send,
icon: Icons.send_rounded,
onPressed: () => _sendLedgerPayout(context, ledgerAccountRef),
),
bottom: BalanceActionButtonState(
label: l10n.addFunds,
icon: Icons.add_card_rounded,
onPressed: onAddFunds,
),
);
}
void _openWalletOperationHistory(BuildContext context, String walletRef) {
context.read<PaymentSourceController>().selectWalletByRef(walletRef);
context.pushNamed(PayoutRoutes.editWallet);
}
void _sendWalletPayout(BuildContext context, String walletRef) {
context.read<PaymentSourceController>().selectWalletByRef(walletRef);
context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: PaymentType.wallet,
),
);
}
void _sendLedgerPayout(BuildContext context, String ledgerAccountRef) {
context.read<PaymentSourceController>().selectLedgerByRef(ledgerAccountRef);
context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: PaymentType.ledger,
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/services.dart';
class BalanceCopyState {
final String label;
final String payload;
const BalanceCopyState({required this.label, required this.payload});
bool get canCopy => payload.trim().isNotEmpty;
}
class BalanceSourceCopyController {
const BalanceSourceCopyController();
BalanceCopyState wallet(String? depositAddress) {
return BalanceCopyState(
label: 'Copy Deposit Address',
payload: depositAddress?.trim() ?? '',
);
}
BalanceCopyState ledger(String? accountCode) {
return BalanceCopyState(
label: 'Copy Deposit Address',
payload: accountCode?.trim() ?? '',
);
}
Future<bool> copy(BalanceCopyState state) async {
if (!state.canCopy) return false;
await Clipboard.setData(ClipboardData(text: state.payload));
return true;
}
}

View File

@@ -1,12 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/operation_document.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/models/documents/operation.dart';
import 'package:pweb/utils/report/operations/document_rule.dart';
class PaymentDetailsController extends ChangeNotifier {
PaymentDetailsController({required String paymentId})
: _paymentId = paymentId;
@@ -20,7 +21,7 @@ class PaymentDetailsController extends ChangeNotifier {
bool get isLoading => _payments?.isLoading ?? false;
Exception? get error => _payments?.error;
OperationDocumentRequestModel? operationDocumentRequest(
OperationDocumentRef? operationDocumentRequest(
PaymentExecutionOperation operation,
) {
final current = _payment;
@@ -33,7 +34,7 @@ class PaymentDetailsController extends ChangeNotifier {
if (!isOperationDocumentEligible(operation.code)) return null;
return OperationDocumentRequestModel(
return OperationDocumentRef(
gatewayService: gatewayService,
operationRef: operationRef,
);

View File

@@ -1,23 +1,29 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
import 'package:pweb/models/payment/multiple_payouts/state.dart';
import 'package:pweb/providers/multiple_payouts.dart';
import 'package:pweb/services/payments/csv_input.dart';
class MultiplePayoutsController extends ChangeNotifier {
final CsvInputService _csvInput;
MultiplePayoutsProvider? _provider;
PaymentSourceController? _sourceController;
_PickState _pickState = _PickState.idle;
Exception? _uiError;
String? _lastSourceKey;
MultiplePayoutsController({required CsvInputService csvInput})
: _csvInput = csvInput;
@@ -37,6 +43,7 @@ class MultiplePayoutsController extends ChangeNotifier {
_sourceController?.removeListener(_onSourceChanged);
_sourceController = sourceController;
_sourceController?.addListener(_onSourceChanged);
_lastSourceKey = _currentSourceKey;
shouldNotify = true;
}
if (shouldNotify) {
@@ -60,16 +67,16 @@ class MultiplePayoutsController extends ChangeNotifier {
_provider?.quoteStatusType ?? QuoteStatusType.missing;
Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null;
bool get canSend => (_provider?.canSend ?? false) && _selectedSource != null;
Money? get aggregateDebitAmount =>
_provider?.aggregateDebitAmountFor(_selectedWallet);
_provider?.aggregateDebitAmountForCurrency(_selectedSourceCurrencyCode);
Money? get requestedSentAmount => _provider?.requestedSentAmount;
Money? get aggregateSettlementAmount =>
_provider?.aggregateSettlementAmountFor(_selectedWallet);
Money? get aggregateSettlementAmount => _provider
?.aggregateSettlementAmountForCurrency(_selectedSourceCurrencyCode);
Money? get aggregateFeeAmount =>
_provider?.aggregateFeeAmountFor(_selectedWallet);
_provider?.aggregateFeeAmountForCurrency(_selectedSourceCurrencyCode);
double? get aggregateFeePercent =>
_provider?.aggregateFeePercentFor(_selectedWallet);
_provider?.aggregateFeePercentForCurrency(_selectedSourceCurrencyCode);
Future<void> pickAndQuote() async {
if (_pickState == _PickState.picking) return;
@@ -84,15 +91,16 @@ class MultiplePayoutsController extends ChangeNotifier {
try {
final picked = await _csvInput.pickCsv();
if (picked == null) return;
final wallet = _selectedWallet;
if (wallet == null) {
_setUiError(StateError('Select source wallet first'));
final source = _selectedSource;
if (source == null) {
_setUiError(StateError('Select source of funds first'));
return;
}
await provider.quoteFromCsv(
fileName: picked.name,
content: picked.content,
sourceWallet: wallet,
sourceMethod: source.method,
sourceCurrencyCode: source.currencyCode,
);
} catch (e) {
_setUiError(e);
@@ -131,10 +139,78 @@ class MultiplePayoutsController extends ChangeNotifier {
}
void _onSourceChanged() {
final currentSourceKey = _currentSourceKey;
final sourceChanged = currentSourceKey != _lastSourceKey;
_lastSourceKey = currentSourceKey;
if (sourceChanged) {
unawaited(_requoteWithUploadedRows());
}
notifyListeners();
}
Wallet? get _selectedWallet => _sourceController?.selectedWallet;
String? get _selectedSourceCurrencyCode =>
_sourceController?.selectedCurrencyCode;
String? get _currentSourceKey {
final source = _sourceController;
if (source == null ||
source.selectedType == null ||
source.selectedRef == null) {
return null;
}
return '${source.selectedType!.name}:${source.selectedRef!}';
}
({PaymentMethodData method, String currencyCode})? get _selectedSource {
final source = _sourceController;
if (source == null) return null;
final currencyCode = source.selectedCurrencyCode;
if (currencyCode == null || currencyCode.isEmpty) return null;
final wallet = source.selectedWallet;
if (wallet != null) {
final hasAsset = (wallet.tokenSymbol ?? '').isNotEmpty;
final asset = hasAsset
? PaymentAsset(
chain: wallet.network ?? ChainNetwork.unspecified,
tokenSymbol: wallet.tokenSymbol!,
contractAddress: wallet.contractAddress,
)
: null;
return (
method: ManagedWalletPaymentMethod(
managedWalletRef: wallet.id,
asset: asset,
),
currencyCode: currencyCode,
);
}
final ledger = source.selectedLedgerAccount;
if (ledger != null) {
return (
method: LedgerPaymentMethod(ledgerAccountRef: ledger.ledgerAccountRef),
currencyCode: currencyCode,
);
}
return null;
}
Future<void> _requoteWithUploadedRows() async {
final provider = _provider;
if (provider == null) return;
if (provider.selectedFileName == null || provider.rows.isEmpty) return;
final source = _selectedSource;
if (source == null) return;
_clearUiError(notify: false);
await provider.requoteUploadedRows(
sourceMethod: source.method,
sourceCurrencyCode: source.currencyCode,
);
}
void _setUiError(Object error) {
_uiError = error is Exception ? error : Exception(error.toString());

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
sealed class BalanceItem {
const BalanceItem();
const factory BalanceItem.wallet(Wallet wallet) = WalletBalanceItem;
const factory BalanceItem.ledger(LedgerAccount account) = LedgerBalanceItem;
const factory BalanceItem.addAction() = AddBalanceActionItem;
}
final class WalletBalanceItem extends BalanceItem {
final Wallet wallet;
const WalletBalanceItem(this.wallet);
}
final class LedgerBalanceItem extends BalanceItem {
final LedgerAccount account;
const LedgerBalanceItem(this.account);
}
final class AddBalanceActionItem extends BalanceItem {
const AddBalanceActionItem();
}

View File

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

View File

@@ -1,27 +0,0 @@
enum PaymentState {
success,
failed,
cancelled,
processing,
unknown,
}
PaymentState paymentStateFromRaw(String? raw) {
final trimmed = (raw ?? '').trim().toUpperCase();
final normalized = trimmed.startsWith('PAYMENT_STATE_')
? trimmed.substring('PAYMENT_STATE_'.length)
: trimmed;
switch (normalized) {
case 'SUCCESS':
return PaymentState.success;
case 'FAILED':
return PaymentState.failed;
case 'CANCELLED':
return PaymentState.cancelled;
case 'PROCESSING':
return PaymentState.processing;
default:
return PaymentState.unknown;
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/dashboard/balance/actions_ui.dart';
import 'package:pweb/controllers/dashboard/balance/source_actions.dart';
import 'package:pweb/pages/dashboard/buttons/balance/actions/hover_expandable_action_button.dart';
class BalanceActionsBar extends StatefulWidget {
final BalanceActionsState state;
const BalanceActionsBar({super.key, required this.state});
@override
State<BalanceActionsBar> createState() => _BalanceActionsBarState();
}
class _BalanceActionsBarState extends State<BalanceActionsBar> {
static const double _buttonHeight = 34.0;
static const double _buttonGap = 6.0;
static const double _iconSize = 18.0;
static const double _textGap = 8.0;
static const double _horizontalPadding = 6.0;
final BalanceActionsUiController _uiController = BalanceActionsUiController();
@override
void dispose() {
_uiController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textStyle = Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w400,
color: colorScheme.onSecondary,
fontSize: 14,
);
final buttons = <BalanceActionButtonState>[
widget.state.topLeading,
widget.state.topTrailing,
widget.state.bottom,
];
return ListenableBuilder(
listenable: _uiController,
builder: (context, _) {
return Align(
alignment: Alignment.centerRight,
child: OverflowBox(
alignment: Alignment.centerRight,
minWidth: 0,
maxWidth: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < buttons.length; i++) ...[
HoverExpandableActionButton(
height: _buttonHeight,
icon: buttons[i].icon,
label: buttons[i].label,
iconSize: _iconSize,
textStyle: textStyle,
expanded: _uiController.isExpanded(i),
textGap: _textGap,
horizontalPadding: _horizontalPadding,
onHoverChanged: (hovered) =>
_uiController.onHoverChanged(i, hovered),
onPressed: buttons[i].onPressed,
),
if (i != buttons.length - 1)
const SizedBox(height: _buttonGap),
],
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
class HoverExpandableActionButton extends StatelessWidget {
final double height;
final IconData icon;
final String label;
final double iconSize;
final TextStyle? textStyle;
final bool expanded;
final double textGap;
final double horizontalPadding;
final ValueChanged<bool> onHoverChanged;
final VoidCallback onPressed;
const HoverExpandableActionButton({
super.key,
required this.height,
required this.icon,
required this.label,
required this.iconSize,
required this.textStyle,
required this.expanded,
required this.textGap,
required this.horizontalPadding,
required this.onHoverChanged,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return MouseRegion(
onEnter: (_) => onHoverChanged(true),
onExit: (_) => onHoverChanged(false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
height: height,
decoration: BoxDecoration(
color: colorScheme.primaryFixed,
borderRadius: BorderRadius.circular(999),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onPressed,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: iconSize, color: colorScheme.onSecondary),
AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.centerRight,
child: expanded
? Padding(
padding: EdgeInsets.only(left: textGap),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.visible,
style: textStyle,
),
)
: const SizedBox.shrink(),
),
],
),
),
),
),
),
);
}
}

View File

@@ -29,12 +29,17 @@ class BalanceAmount extends StatelessWidget {
final isMasked = wallets.isBalanceMasked(wallet.id);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isMasked ? '•••• $currencyBalance' : '${amountToString(wallet.balance)} $currencyBalance',
style: textTheme.headlineSmall?.copyWith(
isMasked
? '•••• $currencyBalance'
: '${amountToString(wallet.balance)} $currencyBalance',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
color: colorScheme.primary,
),
),
const SizedBox(width: _iconSpacing),
@@ -43,7 +48,7 @@ class BalanceAmount extends StatelessWidget {
child: Icon(
isMasked ? Icons.visibility_off : Icons.visibility,
size: _iconSize,
color: colorScheme.onSurface,
color: colorScheme.primary,
),
),
],

View File

@@ -1,21 +0,0 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
enum BalanceItemType { wallet, ledger, addAction }
class BalanceItem {
final BalanceItemType type;
final Wallet? wallet;
final LedgerAccount? account;
const BalanceItem.wallet(this.wallet) : type = BalanceItemType.wallet, account = null;
const BalanceItem.ledger(this.account) : type = BalanceItemType.ledger, wallet = null;
const BalanceItem.addAction() : type = BalanceItemType.addAction, wallet = null, account = null;
bool get isWallet => type == BalanceItemType.wallet;
bool get isLedger => type == BalanceItemType.ledger;
bool get isAdd => type == BalanceItemType.addAction;
}

View File

@@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add_funds.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
class WalletCard extends StatelessWidget {
final Wallet wallet;
final VoidCallback onTopUp;
final VoidCallback onTap;
const WalletCard({
super.key,
required this.wallet,
required this.onTopUp,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified)
? null
: wallet.network!.localizedName(context);
final symbol = wallet.tokenSymbol?.trim();
return Card(
color: Theme.of(context).colorScheme.onSecondary,
elevation: WalletCardConfig.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
),
child: InkWell(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
onTap: onTap,
child: SizedBox.expand(
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(
title: wallet.name,
subtitle: networkLabel,
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
BalanceAmount(
wallet: wallet,
onToggleMask: () {
context.read<WalletsController>().toggleBalanceMask(wallet.id);
},
),
Column(
children: [
WalletBalanceRefreshButton(
walletRef: wallet.id,
),
BalanceAddFunds(onTopUp: onTopUp),
],
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -1,148 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart';
import 'package:pweb/pages/dashboard/buttons/balance/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
import 'package:pweb/pages/dashboard/buttons/balance/ledger.dart';
class BalanceCarousel extends StatefulWidget {
final List<BalanceItem> items;
final int currentIndex;
final ValueChanged<int> onIndexChanged;
final ValueChanged<Wallet> onTopUp;
final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
const BalanceCarousel({
super.key,
required this.items,
required this.currentIndex,
required this.onIndexChanged,
required this.onTopUp,
required this.onWalletTap,
required this.onLedgerTap,
});
@override
State<BalanceCarousel> createState() => _BalanceCarouselState();
}
class _BalanceCarouselState extends State<BalanceCarousel> {
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(
initialPage: widget.currentIndex,
viewportFraction: WalletCardConfig.viewportFraction,
);
}
@override
void didUpdateWidget(covariant BalanceCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (!mounted) return;
if (_controller.hasClients) {
final currentPage = _controller.page?.round();
if (currentPage != widget.currentIndex) {
_controller.jumpToPage(widget.currentIndex);
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _goToPage(int index) {
if (!_controller.hasClients) return;
_controller.animateToPage(
index,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) {
return const SizedBox.shrink();
}
final safeIndex = widget.currentIndex.clamp(0, widget.items.length - 1);
final scrollBehavior = ScrollConfiguration.of(context).copyWith(
dragDevices: const {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
},
);
return Column(
children: [
SizedBox(
height: WalletCardConfig.cardHeight,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: ScrollConfiguration(
behavior: scrollBehavior,
child: PageView.builder(
controller: _controller,
onPageChanged: widget.onIndexChanged,
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
final Widget card = switch (item.type) {
BalanceItemType.wallet => WalletCard(
wallet: item.wallet!,
onTopUp: () => widget.onTopUp(item.wallet!),
onTap: () => widget.onWalletTap(item.wallet!),
),
BalanceItemType.ledger => LedgerAccountCard(
account: item.account!,
onTap: () => widget.onLedgerTap(item.account!),
),
BalanceItemType.addAction => const AddBalanceCard(),
};
return Padding(
padding: WalletCardConfig.cardPadding,
child: card,
);
},
),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: safeIndex > 0 ? () => _goToPage(safeIndex - 1) : null,
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 16),
CarouselIndicator(itemCount: widget.items.length, index: safeIndex),
const SizedBox(width: 16),
IconButton(
onPressed: safeIndex < widget.items.length - 1
? () => _goToPage(safeIndex + 1)
: null,
icon: const Icon(Icons.arrow_forward),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/dashboard/balance_item.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/cards/ledger.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/cards/wallet.dart';
class BalanceCarouselCardItem extends StatelessWidget {
final BalanceItem item;
final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
const BalanceCarouselCardItem({
super.key,
required this.item,
required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap,
required this.onLedgerTap,
});
@override
Widget build(BuildContext context) {
final card = switch (item) {
WalletBalanceItem(:final wallet) => WalletCard(
wallet: wallet,
onTopUp: () => onTopUp(wallet),
onTap: () => onWalletTap(wallet),
),
LedgerBalanceItem(:final account) => LedgerAccountCard(
account: account,
onTap: () => onLedgerTap(account),
onAddFunds: () => onLedgerAddFunds(account),
),
AddBalanceActionItem() => const AddBalanceCard(),
};
return Padding(padding: WalletCardConfig.cardPadding, child: card);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel/card_item.dart';
class BalanceCarouselCardsView extends StatelessWidget {
final BalanceCarouselController controller;
final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
final double height;
const BalanceCarouselCardsView({
super.key,
required this.controller,
required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap,
required this.onLedgerTap,
required this.height,
});
@override
Widget build(BuildContext context) {
final scrollBehavior = ScrollConfiguration.of(context).copyWith(
dragDevices: const {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
},
);
return SizedBox(
height: height,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: ScrollConfiguration(
behavior: scrollBehavior,
child: PageView.builder(
controller: controller.pageController,
onPageChanged: controller.onPageChanged,
itemCount: controller.items.length,
itemBuilder: (context, index) => BalanceCarouselCardItem(
item: controller.items[index],
onTopUp: onTopUp,
onLedgerAddFunds: onLedgerAddFunds,
onWalletTap: onWalletTap,
onLedgerTap: onLedgerTap,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel/cards_view.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel/navigation.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
class BalanceCarousel extends StatelessWidget {
final BalanceCarouselController controller;
final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
const BalanceCarousel({
super.key,
required this.controller,
required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap,
required this.onLedgerTap,
});
@override
Widget build(BuildContext context) {
if (controller.items.isEmpty) {
return const SizedBox.shrink();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.syncPageController();
});
final safeIndex = controller.index.clamp(0, controller.items.length - 1);
return LayoutBuilder(
builder: (context, constraints) {
final cardHeight = WalletCardConfig.cardHeightForWidth(
constraints.maxWidth,
);
return Column(
children: [
BalanceCarouselCardsView(
controller: controller,
onTopUp: onTopUp,
onLedgerAddFunds: onLedgerAddFunds,
onWalletTap: onWalletTap,
onLedgerTap: onLedgerTap,
height: cardHeight,
),
const SizedBox(height: 16),
BalanceCarouselNavigation(controller: controller, index: safeIndex),
],
);
},
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/indicator.dart';
class BalanceCarouselNavigation extends StatelessWidget {
final BalanceCarouselController controller;
final int index;
const BalanceCarouselNavigation({
super.key,
required this.controller,
required this.index,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: index > 0 ? controller.goBack : null,
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 16),
CarouselIndicator(itemCount: controller.items.length, index: index),
const SizedBox(width: 16),
IconButton(
onPressed: index < controller.items.length - 1
? controller.goForward
: null,
icon: const Icon(Icons.arrow_forward),
),
],
);
}
}

View File

@@ -2,14 +2,21 @@ import 'package:flutter/material.dart';
abstract class WalletCardConfig {
static const double cardHeight = 145.0;
static const double elevation = 4.0;
static const double borderRadius = 16.0;
static const double viewportFraction = 0.9;
static const double viewportFraction = 0.96;
static const EdgeInsets cardPadding = EdgeInsets.symmetric(horizontal: 8);
static const EdgeInsets contentPadding = EdgeInsets.all(16);
static const EdgeInsets cardPadding = EdgeInsets.symmetric(horizontal: 6);
static const EdgeInsets contentPadding = EdgeInsets.symmetric(
horizontal: 28,
vertical: 16,
);
static const double dotSize = 8.0;
static const EdgeInsets dotMargin = EdgeInsets.symmetric(horizontal: 4);
static double cardHeightForWidth(double width) {
final adaptiveHeight = width * 0.18;
return adaptiveHeight.clamp(150.0, 230.0);
}
}

View File

@@ -20,47 +20,52 @@ class BalanceHeader extends StatelessWidget {
final subtitleText = subtitle?.trim();
final badgeText = badge?.trim();
return Row(
children: [
Expanded(
child: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
title,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
if (subtitleText != null && subtitleText.isNotEmpty)
Text(
subtitleText,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (badgeText != null && badgeText.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
color: colorScheme.primaryFixed,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badgeText,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSecondary,
fontWeight: FontWeight.w700,
),
),
),
],
],
),
if (subtitleText != null && subtitleText.isNotEmpty)
Text(
subtitleText,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.primaryFixed,
fontWeight: FontWeight.w500,
),
maxLines: 1,
),
],
);
}
}

View File

@@ -1,133 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerAccountCard extends StatelessWidget {
final LedgerAccount account;
final VoidCallback? onTap;
const LedgerAccountCard({super.key, required this.account, this.onTap});
String _formatBalance() {
final money = account.balance?.balance;
if (money == null) return '--';
final amount = parseMoneyAmount(money.amount, fallback: double.nan);
if (amount.isNaN) {
return '${money.amount} ${money.currency}';
}
try {
final currency = currencyStringToCode(money.currency);
final symbol = currencyCodeToSymbol(currency);
if (symbol.trim().isEmpty) {
return '${amountToString(amount)} ${money.currency}';
}
return '${amountToString(amount)} $symbol';
} catch (_) {
return '${amountToString(amount)} ${money.currency}';
}
}
String _formatMaskedBalance() {
final currency = account.currency.trim();
if (currency.isEmpty) return '••••';
try {
final symbol = currencyCodeToSymbol(currencyStringToCode(currency));
if (symbol.trim().isEmpty) {
return '•••• $currency';
}
return '•••• $symbol';
} catch (_) {
return '•••• $currency';
}
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context)!;
final accountName = account.name.trim();
final accountCode = account.accountCode.trim();
final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger;
final subtitle = accountCode.isNotEmpty ? accountCode : null;
final badge = account.currency.trim().isEmpty
? null
: account.currency.toUpperCase();
return Card(
color: colorScheme.onSecondary,
elevation: WalletCardConfig.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
),
child: InkWell(
borderRadius: BorderRadius.circular(WalletCardConfig.borderRadius),
onTap: onTap,
child: Padding(
padding: WalletCardConfig.contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(title: title, subtitle: subtitle, badge: badge),
Row(
children: [
Consumer<LedgerBalanceMaskController>(
builder: (context, controller, _) {
final isMasked = controller.isBalanceMasked(
account.ledgerAccountRef,
);
return Row(
children: [
Text(
isMasked
? _formatMaskedBalance()
: _formatBalance(),
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () => controller.toggleBalanceMask(
account.ledgerAccountRef,
),
child: Icon(
isMasked
? Icons.visibility_off
: Icons.visibility,
size: 24,
color: colorScheme.onSurface,
),
),
],
);
},
),
const SizedBox(width: 12),
LedgerBalanceRefreshButton(
ledgerAccountRef: account.ledgerAccountRef,
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pweb/pages/payout_page/wallet/edit/fields/ledger/balance_formatter.dart';
class LedgerBalanceAmount extends StatelessWidget {
final LedgerAccount account;
const LedgerBalanceAmount({super.key, required this.account});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Consumer<LedgerBalanceMaskController>(
builder: (context, controller, _) {
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef);
final balance = isMasked
? LedgerBalanceFormatter.formatMasked(account)
: LedgerBalanceFormatter.format(account);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
balance,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () {
controller.toggleBalanceMask(account.ledgerAccountRef);
},
child: Icon(
isMasked ? Icons.visibility_off : Icons.visibility,
size: 24,
color: colorScheme.primary,
),
),
],
);
},
);
}
}

View File

@@ -5,7 +5,8 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
class BalanceWidgetProviders extends StatelessWidget {
final Widget child;

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/dashboard/balance/source_actions.dart';
import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart';
class LedgerSourceActions extends StatelessWidget {
final String ledgerAccountRef;
final VoidCallback onAddFunds;
final VoidCallback onWalletDetails;
const LedgerSourceActions({
super.key,
required this.ledgerAccountRef,
required this.onAddFunds,
required this.onWalletDetails,
});
@override
Widget build(BuildContext context) {
const controller = BalanceSourceActionsController();
final state = controller.ledger(
context: context,
ledgerAccountRef: ledgerAccountRef,
onAddFunds: onAddFunds,
onWalletDetails: onWalletDetails,
);
return BalanceActionsBar(state: state);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/dashboard/balance/source_actions.dart';
import 'package:pweb/pages/dashboard/buttons/balance/actions/bar.dart';
class WalletSourceActions extends StatelessWidget {
final String walletRef;
final VoidCallback onAddFunds;
const WalletSourceActions({
super.key,
required this.walletRef,
required this.onAddFunds,
});
@override
Widget build(BuildContext context) {
const controller = BalanceSourceActionsController();
final state = controller.wallet(
context: context,
walletRef: walletRef,
onAddFunds: onAddFunds,
);
return BalanceActionsBar(state: state);
}
}

View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/source_type.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/controllers/dashboard/balance/source_copy.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/pages/dashboard/buttons/balance/ledger_amount.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/actions/ledger.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/actions/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/card_layout.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceSourceCard extends StatelessWidget {
final PaymentSourceType _type;
final Wallet? _wallet;
final LedgerAccount? _ledgerAccount;
final VoidCallback onTap;
final VoidCallback onAddFunds;
static const BalanceSourceCopyController _copyController =
BalanceSourceCopyController();
const BalanceSourceCard.wallet({
super.key,
required Wallet wallet,
required this.onTap,
required this.onAddFunds,
}) : _type = PaymentSourceType.wallet,
_wallet = wallet,
_ledgerAccount = null;
const BalanceSourceCard.ledger({
super.key,
required LedgerAccount account,
required this.onTap,
required this.onAddFunds,
}) : _type = PaymentSourceType.ledger,
_wallet = null,
_ledgerAccount = account;
@override
Widget build(BuildContext context) => switch (_type) {
PaymentSourceType.wallet => _buildWalletCard(context, _wallet!),
PaymentSourceType.ledger => _buildLedgerCard(context, _ledgerAccount!),
};
Widget _buildWalletCard(BuildContext context, Wallet wallet) {
final networkLabel =
(wallet.network == null || wallet.network == ChainNetwork.unspecified)
? null
: wallet.network!.localizedName(context);
final symbol = wallet.tokenSymbol?.trim();
final copyState = _copyController.wallet(wallet.depositAddress);
return BalanceSourceCardLayout(
title: wallet.name,
subtitle: networkLabel,
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
onTap: null,
copyLabel: copyState.label,
canCopy: copyState.canCopy,
onCopy: copyState.canCopy
? () async {
final copied = await _copyController.copy(copyState);
if (!copied || !context.mounted) return;
final loc = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(loc.addressCopied)));
}
: null,
refreshButton: WalletBalanceRefreshButton(
walletRef: wallet.id,
iconOnly: VisibilityState.hidden,
),
actions: WalletSourceActions(
walletRef: wallet.id,
onAddFunds: onAddFunds,
),
amount: BalanceAmount(
wallet: wallet,
onToggleMask: () {
context.read<WalletsController>().toggleBalanceMask(wallet.id);
},
),
);
}
Widget _buildLedgerCard(BuildContext context, LedgerAccount account) {
final loc = AppLocalizations.of(context)!;
final accountName = account.name.trim();
final accountCode = account.accountCode.trim();
final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger;
final badge = account.currency.trim().isEmpty
? null
: account.currency.toUpperCase();
final copyState = _copyController.ledger(accountCode);
return BalanceSourceCardLayout(
title: title,
subtitle: null,
badge: badge,
onTap: onTap,
copyLabel: copyState.label,
canCopy: copyState.canCopy,
onCopy: copyState.canCopy
? () async {
final copied = await _copyController.copy(copyState);
if (!copied || !context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(loc.addressCopied)));
}
: null,
refreshButton: LedgerBalanceRefreshButton(
ledgerAccountRef: account.ledgerAccountRef,
iconOnly: VisibilityState.hidden,
),
actions: LedgerSourceActions(
ledgerAccountRef: account.ledgerAccountRef,
onAddFunds: onAddFunds,
onWalletDetails: onTap,
),
amount: LedgerBalanceAmount(account: account),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/layout/wide_body.dart';
class BalanceSourceCardLayout extends StatelessWidget {
final String title;
final String? subtitle;
final String? badge;
final Widget amount;
final Widget refreshButton;
final Widget actions;
final VoidCallback? onTap;
final String copyLabel;
final bool canCopy;
final VoidCallback? onCopy;
const BalanceSourceCardLayout({
super.key,
required this.title,
required this.subtitle,
required this.badge,
required this.amount,
required this.refreshButton,
required this.actions,
required this.onTap,
required this.copyLabel,
required this.canCopy,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final borderRadius = BorderRadius.circular(WalletCardConfig.borderRadius);
return Card(
color: colorScheme.onSecondary,
elevation: WalletCardConfig.elevation,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
child: SizedBox.expand(
child: Padding(
padding: WalletCardConfig.contentPadding,
child: BalanceSourceBody(
title: title,
subtitle: subtitle,
badge: badge,
amount: amount,
refreshButton: refreshButton,
actions: actions,
copyLabel: copyLabel,
canCopy: canCopy,
onCopy: onCopy,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart';
class LedgerAccountCard extends StatelessWidget {
final LedgerAccount account;
final VoidCallback onAddFunds;
final VoidCallback? onTap;
const LedgerAccountCard({
super.key,
required this.account,
required this.onAddFunds,
this.onTap,
});
@override
Widget build(BuildContext context) {
return BalanceSourceCard.ledger(
account: account,
onTap: onTap ?? () {},
onAddFunds: onAddFunds,
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart';
class WalletCard extends StatelessWidget {
final Wallet wallet;
final VoidCallback onTopUp;
final VoidCallback onTap;
const WalletCard({
super.key,
required this.wallet,
required this.onTopUp,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return BalanceSourceCard.wallet(
wallet: wallet,
onTap: onTap,
onAddFunds: onTopUp,
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
class BalanceAmountWithRefresh extends StatelessWidget {
final Widget amount;
final Widget refreshButton;
const BalanceAmountWithRefresh({
super.key,
required this.amount,
required this.refreshButton,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
minimumSize: const Size(30, 30),
maximumSize: const Size(40, 40),
padding: EdgeInsets.zero,
foregroundColor: colorScheme.primary,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
child: refreshButton,
),
const SizedBox(width: 8),
amount,
],
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
class BalanceCopyableField extends StatelessWidget {
final String label;
final bool canCopy;
final VoidCallback? onCopy;
const BalanceCopyableField({
super.key,
required this.label,
required this.canCopy,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
decoration: BoxDecoration(
color: colorScheme.onSecondary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.primaryFixed, width: 0.6),
),
child: InkWell(
onTap: canCopy ? onCopy : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.copy_rounded,
size: 16,
color: canCopy
? colorScheme.primaryFixed
: colorScheme.primary.withValues(alpha: 0.35),
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: canCopy
? colorScheme.primaryFixed
: colorScheme.primary.withValues(alpha: 0.45),
fontWeight: FontWeight.normal,
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/layout/amount_with_refresh.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/layout/copyable_field.dart';
class BalanceSourceBody extends StatelessWidget {
final String title;
final String? subtitle;
final String? badge;
final Widget amount;
final Widget refreshButton;
final Widget actions;
final String copyLabel;
final bool canCopy;
final VoidCallback? onCopy;
const BalanceSourceBody({
super.key,
required this.title,
required this.subtitle,
required this.badge,
required this.amount,
required this.refreshButton,
required this.actions,
required this.copyLabel,
required this.canCopy,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final sideMaxWidth = constraints.maxWidth * 0.30;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
fit: FlexFit.loose,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: sideMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
BalanceHeader(
title: title,
subtitle: subtitle,
badge: badge,
),
SizedBox(height: constraints.maxHeight * 0.06),
BalanceCopyableField(
label: copyLabel,
canCopy: canCopy,
onCopy: onCopy,
),
],
),
),
),
Expanded(
child: Align(
alignment: Alignment.center,
child: FittedBox(
fit: BoxFit.scaleDown,
child: BalanceAmountWithRefresh(
amount: amount,
refreshButton: refreshButton,
),
),
),
),
Flexible(
fit: FlexFit.loose,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: sideMaxWidth),
child: SizedBox(height: constraints.maxHeight, child: actions),
),
),
],
);
},
);
}
}

View File

@@ -7,20 +7,22 @@ import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/provider/ledger.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart';
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
import 'package:pweb/pages/dashboard/buttons/balance/carousel/carousel.dart';
import 'package:pweb/controllers/dashboard/balance/carousel.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceWidget extends StatelessWidget {
final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
const BalanceWidget({
super.key,
required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap,
required this.onLedgerTap,
});
@@ -45,10 +47,9 @@ class BalanceWidget extends StatelessWidget {
}
final carouselWidget = BalanceCarousel(
items: carousel.items,
currentIndex: carousel.index,
onIndexChanged: carousel.onPageChanged,
controller: carousel,
onTopUp: onTopUp,
onLedgerAddFunds: onLedgerAddFunds,
onWalletTap: onWalletTap,
onLedgerTap: onLedgerTap,
);

View File

@@ -27,6 +27,7 @@ class DashboardPage extends StatefulWidget {
final ValueChanged<Recipient> onRecipientSelected;
final void Function(PaymentType type) onGoToPaymentWithoutRecipient;
final ValueChanged<Wallet> onTopUp;
final ValueChanged<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
@@ -35,6 +36,7 @@ class DashboardPage extends StatefulWidget {
required this.onRecipientSelected,
required this.onGoToPaymentWithoutRecipient,
required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap,
required this.onLedgerTap,
});
@@ -90,6 +92,7 @@ class _DashboardPageState extends State<DashboardPage> {
BalanceWidgetProviders(
child: BalanceWidget(
onTopUp: widget.onTopUp,
onLedgerAddFunds: widget.onLedgerAddFunds,
onWalletTap: widget.onWalletTap,
onLedgerTap: widget.onLedgerTap,
),

View File

@@ -5,7 +5,6 @@ import 'package:provider/provider.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/payments/amount_field.dart';
import 'package:pweb/models/payment/amount/mode.dart';
import 'package:pweb/pages/dashboard/payouts/amount/mode/selector.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -6,7 +6,8 @@ import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pweb/controllers/payments/amount_field.dart';
import 'package:pweb/pages/dashboard/payouts/amount/feild.dart';
import 'package:pweb/pages/dashboard/payouts/amount/field.dart';
class PaymentAmountWidget extends StatelessWidget {
const PaymentAmountWidget({super.key});

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