Compare commits

13 Commits

Author SHA1 Message Date
Arseni
97b16542c2 ledger top up functionality and few small fixes for project architechture and design 2026-03-05 21:49:23 +03:00
Arseni
39c04beb21 Merge remote-tracking branch 'origin/main' into SEND066
merge main into SEND066
2026-03-05 21:12:43 +03:00
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
59 changed files with 1205 additions and 633 deletions

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

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

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

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

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

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

@@ -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,6 +228,7 @@ RouteBase payoutShellRoute() => ShellRoute(
onGoToPaymentWithoutRecipient: (type) =>
_startPayment(context, recipient: null, paymentType: type),
onTopUp: (wallet) => _openWalletTopUp(context, wallet),
onLedgerAddFunds: (account) => _openLedgerAddFunds(context, account),
onWalletTap: (wallet) => _openWalletEdit(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,
),
);
@@ -395,6 +398,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

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

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

View File

@@ -1,17 +1,8 @@
import 'package:flutter/material.dart';
import 'package: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';
import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart';
class WalletCard extends StatelessWidget {
@@ -28,56 +19,10 @@ class WalletCard extends StatelessWidget {
@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(
return BalanceSourceCard.wallet(
wallet: wallet,
onToggleMask: () {
context.read<WalletsController>().toggleBalanceMask(wallet.id);
},
),
Column(
children: [
WalletBalanceRefreshButton(
walletRef: wallet.id,
),
BalanceAddFunds(onTopUp: onTopUp),
],
),
],
),
],
),
),
),
),
onTap: onTap,
onAddFunds: onTopUp,
);
}
}

View File

@@ -4,18 +4,20 @@ import 'package:flutter/gestures.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/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<LedgerAccount> onLedgerAddFunds;
final ValueChanged<Wallet> onWalletTap;
final ValueChanged<LedgerAccount> onLedgerTap;
@@ -25,6 +27,7 @@ class BalanceCarousel extends StatefulWidget {
required this.currentIndex,
required this.onIndexChanged,
required this.onTopUp,
required this.onLedgerAddFunds,
required this.onWalletTap,
required this.onLedgerTap,
});
@@ -101,17 +104,18 @@ class _BalanceCarouselState extends State<BalanceCarousel> {
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!),
final Widget card = switch (item) {
WalletBalanceItem(:final wallet) => WalletCard(
wallet: wallet,
onTopUp: () => widget.onTopUp(wallet),
onTap: () => widget.onWalletTap(wallet),
),
BalanceItemType.ledger => LedgerAccountCard(
account: item.account!,
onTap: () => widget.onLedgerTap(item.account!),
LedgerBalanceItem(:final account) => LedgerAccountCard(
account: account,
onTap: () => widget.onLedgerTap(account),
onAddFunds: () => widget.onLedgerAddFunds(account),
),
BalanceItemType.addAction => const AddBalanceCard(),
AddBalanceActionItem() => const AddBalanceCard(),
};
return Padding(

View File

@@ -3,7 +3,8 @@ import 'package:flutter/foundation.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';
class BalanceCarouselController with ChangeNotifier {
WalletsController? _walletsController;
@@ -73,14 +74,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 +103,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,9 +121,8 @@ 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);
}

View File

@@ -1,133 +1,27 @@
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';
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, 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';
}
}
const LedgerAccountCard({
super.key,
required this.account,
required this.onAddFunds,
this.onTap,
});
@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,
),
],
),
],
),
),
),
return BalanceSourceCard.ledger(
account: account,
onTap: onTap ?? () {},
onAddFunds: onAddFunds,
);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/source_type.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
import 'package:pweb/pages/dashboard/buttons/balance/ledger_amount.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/actions/ledger.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/actions/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/card_layout.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceSourceCard extends StatelessWidget {
final PaymentSourceType _type;
final Wallet? _wallet;
final LedgerAccount? _ledgerAccount;
final VoidCallback onTap;
final VoidCallback onAddFunds;
const BalanceSourceCard.wallet({
super.key,
required Wallet wallet,
required this.onTap,
required this.onAddFunds,
}) : _type = PaymentSourceType.wallet,
_wallet = wallet,
_ledgerAccount = null;
const BalanceSourceCard.ledger({
super.key,
required LedgerAccount account,
required this.onTap,
required this.onAddFunds,
}) : _type = PaymentSourceType.ledger,
_wallet = null,
_ledgerAccount = account;
@override
Widget build(BuildContext context) => switch (_type) {
PaymentSourceType.wallet => _buildWalletCard(context, _wallet!),
PaymentSourceType.ledger => _buildLedgerCard(context, _ledgerAccount!),
};
Widget _buildWalletCard(BuildContext context, Wallet wallet) {
final networkLabel =
(wallet.network == null || wallet.network == ChainNetwork.unspecified)
? null
: wallet.network!.localizedName(context);
final symbol = wallet.tokenSymbol?.trim();
return BalanceSourceCardLayout(
title: wallet.name,
subtitle: networkLabel,
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
onTap: onTap,
actions: WalletSourceActions(
walletRef: wallet.id,
onAddFunds: onAddFunds,
),
amount: BalanceAmount(
wallet: wallet,
onToggleMask: () {
context.read<WalletsController>().toggleBalanceMask(wallet.id);
},
),
);
}
Widget _buildLedgerCard(BuildContext context, LedgerAccount account) {
final loc = AppLocalizations.of(context)!;
final accountName = account.name.trim();
final accountCode = account.accountCode.trim();
final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger;
final subtitle = accountCode.isNotEmpty ? accountCode : null;
final badge = account.currency.trim().isEmpty
? null
: account.currency.toUpperCase();
return BalanceSourceCardLayout(
title: title,
subtitle: subtitle,
badge: badge,
onTap: onTap,
actions: LedgerSourceActions(
ledgerAccountRef: account.ledgerAccountRef,
onAddFunds: onAddFunds,
),
amount: LedgerBalanceAmount(account: account),
);
}
}

View File

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

View File

@@ -12,15 +12,16 @@ import 'package:pweb/pages/dashboard/buttons/balance/controller.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,
});
@@ -49,6 +50,7 @@ class BalanceWidget extends StatelessWidget {
currentIndex: carousel.index,
onIndexChanged: carousel.onPageChanged,
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,7 @@ import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/payout_verification.dart';
import 'package:pweb/utils/payment/payout_verification_flow.dart';
import 'package:pweb/utils/payment/verification_flow.dart';
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';

View File

@@ -5,6 +5,8 @@ import 'package:pshared/utils/money.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
String moneyLabel(Money? money) {
if (money == null) return 'N/A';
@@ -12,10 +14,7 @@ String moneyLabel(Money? money) {
if (amount.isNaN) return '${money.amount} ${money.currency}';
try {
return assetToString(
Asset(
currency: currencyStringToCode(money.currency),
amount: amount,
),
Asset(currency: currencyStringToCode(money.currency), amount: amount),
);
} catch (_) {
return '${money.amount} ${money.currency}';
@@ -31,6 +30,8 @@ String sentAmountLabel(MultiplePayoutsController controller) {
return moneyLabel(requested);
}
String feeLabel(MultiplePayoutsController controller) {
return moneyLabel(controller.aggregateFeeAmount);
String feeLabel(MultiplePayoutsController controller, AppLocalizations l10n) {
final fee = controller.aggregateFeeAmount;
if (fee == null) return l10n.noFee;
return moneyLabel(fee);
}

View File

@@ -4,7 +4,7 @@ import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/models/dashboard/summary_values.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart';
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuoteSummary extends StatelessWidget {
const SourceQuoteSummary({
@@ -21,7 +21,7 @@ class SourceQuoteSummary extends StatelessWidget {
return PaymentSummary(
spacing: spacing,
values: PaymentSummaryValues(
fee: feeLabel(controller),
fee: feeLabel(controller, AppLocalizations.of(context)!),
recipientReceives: moneyLabel(controller.aggregateSettlementAmount),
total: moneyLabel(controller.aggregateDebitAmount),
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
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/source_type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/app/router/payout_routes.dart';
@@ -17,11 +21,35 @@ class TopUpButton extends StatelessWidget {
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final source = context.watch<PaymentSourceController>();
final canTopUp = source.selectedType == PaymentSourceType.wallet;
final selectedType = source.selectedType;
final selectedLedger = source.selectedLedgerAccount;
final canTopUp =
selectedType == PaymentSourceType.wallet ||
(selectedType == PaymentSourceType.ledger && selectedLedger != null);
return ElevatedButton(
style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0),
onPressed: canTopUp ? () => context.pushToWalletTopUp() : null,
onPressed: !canTopUp
? null
: () {
if (selectedType == PaymentSourceType.wallet) {
context.pushToWalletTopUp();
return;
}
if (selectedType == PaymentSourceType.ledger &&
selectedLedger != null) {
context.read<RecipientsProvider>().setCurrentObject(null);
context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: PaymentType.ledger,
destinationLedgerAccountRef:
selectedLedger.ledgerAccountRef,
),
);
}
},
child: Text(loc.topUpBalance),
);
}

View File

@@ -4,7 +4,7 @@ import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/quote/quote.dart';
import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/provider/payment/multiple/provider.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pshared/utils/currency.dart';
@@ -13,8 +13,9 @@ import 'package:pshared/utils/payment/quote_helpers.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
import 'package:pweb/models/payment/multiple_payouts/state.dart';
import 'package:pweb/utils/payment/multiple_csv_parser.dart';
import 'package:pweb/utils/payment/multiple_intent_builder.dart';
import 'package:pweb/utils/payment/multiple/csv_parser.dart';
import 'package:pweb/utils/payment/multiple/intent_builder.dart';
class MultiplePayoutsProvider extends ChangeNotifier {
final MultipleCsvParser _csvParser;
@@ -76,12 +77,12 @@ class MultiplePayoutsProvider extends ChangeNotifier {
return quoteRef != null && quoteRef.isNotEmpty;
}
Money? aggregateDebitAmountFor(Wallet? sourceWallet) {
Money? aggregateDebitAmountForCurrency(String? sourceCurrencyCode) {
if (_rows.isEmpty) return null;
final totals = aggregateMoneyByCurrency(
_quoteItems().map((quote) => quote.amounts?.sourceDebitTotal),
);
return _moneyForSourceCurrency(totals, sourceWallet);
return _moneyForSourceCurrency(totals, sourceCurrencyCode);
}
Money? get requestedSentAmount {
@@ -97,23 +98,23 @@ class MultiplePayoutsProvider extends ChangeNotifier {
return Money(amount: amountToString(total), currency: currency);
}
Money? aggregateSettlementAmountFor(Wallet? sourceWallet) {
Money? aggregateSettlementAmountForCurrency(String? sourceCurrencyCode) {
if (_rows.isEmpty) return null;
final totals = aggregateMoneyByCurrency(
_quoteItems().map((quote) => quote.amounts?.destinationSettlement),
);
return _moneyForSourceCurrency(totals, sourceWallet);
return _moneyForSourceCurrency(totals, sourceCurrencyCode);
}
Money? aggregateFeeAmountFor(Wallet? sourceWallet) {
Money? aggregateFeeAmountForCurrency(String? sourceCurrencyCode) {
if (_rows.isEmpty) return null;
final totals = aggregateMoneyByCurrency(_quoteItems().map(quoteFeeTotal));
return _moneyForSourceCurrency(totals, sourceWallet);
return _moneyForSourceCurrency(totals, sourceCurrencyCode);
}
double? aggregateFeePercentFor(Wallet? sourceWallet) {
final debit = aggregateDebitAmountFor(sourceWallet);
final fee = aggregateFeeAmountFor(sourceWallet);
double? aggregateFeePercentForCurrency(String? sourceCurrencyCode) {
final debit = aggregateDebitAmountForCurrency(sourceCurrencyCode);
final fee = aggregateFeeAmountForCurrency(sourceCurrencyCode);
if (debit == null || fee == null) return null;
final debitValue = parseMoneyAmount(debit.amount, fallback: double.nan);
@@ -126,7 +127,8 @@ class MultiplePayoutsProvider extends ChangeNotifier {
Future<void> quoteFromCsv({
required String fileName,
required String content,
required Wallet sourceWallet,
required PaymentMethodData sourceMethod,
required String sourceCurrencyCode,
}) async {
if (isBusy) return;
@@ -144,18 +146,43 @@ class MultiplePayoutsProvider extends ChangeNotifier {
_sentCount = 0;
final rows = _csvParser.parseRows(content);
final intents = _intentBuilder.buildIntents(sourceWallet, rows);
await _quoteRows(
quotation: quotation,
fileName: fileName,
rows: rows,
sourceMethod: sourceMethod,
sourceCurrencyCode: sourceCurrencyCode,
);
_selectedFileName = fileName;
_rows = rows;
if (quotation.error != null) {
_setErrorObject(quotation.error!);
}
} catch (e) {
_setErrorObject(e);
} finally {
_setState(MultiplePayoutsState.idle);
}
}
await quotation.quotePayments(
intents,
metadata: <String, String>{
'upload_filename': fileName,
'upload_rows': rows.length.toString(),
...?_uploadAmountMetadata(),
},
Future<void> requoteUploadedRows({
required PaymentMethodData sourceMethod,
required String sourceCurrencyCode,
}) async {
if (isBusy || _rows.isEmpty || _selectedFileName == null) return;
final quotation = _quotation;
if (quotation == null) return;
try {
_setState(MultiplePayoutsState.quoting);
_error = null;
_sentCount = 0;
await _quoteRows(
quotation: quotation,
fileName: _selectedFileName!,
rows: _rows,
sourceMethod: sourceMethod,
sourceCurrencyCode: sourceCurrencyCode,
);
if (quotation.error != null) {
@@ -254,13 +281,16 @@ class MultiplePayoutsProvider extends ChangeNotifier {
};
}
Money? _moneyForSourceCurrency(List<Money>? values, Wallet? sourceWallet) {
Money? _moneyForSourceCurrency(
List<Money>? values,
String? sourceCurrencyCode,
) {
if (values == null || values.isEmpty) return null;
if (sourceWallet != null) {
final sourceCurrency = currencyCodeToString(sourceWallet.currency);
if (sourceCurrencyCode != null && sourceCurrencyCode.isNotEmpty) {
final sourceCurrency = sourceCurrencyCode.trim().toUpperCase();
for (final value in values) {
if (value.currency.toUpperCase() == sourceCurrency.toUpperCase()) {
if (value.currency.toUpperCase() == sourceCurrency) {
return value;
}
}
@@ -272,6 +302,32 @@ class MultiplePayoutsProvider extends ChangeNotifier {
List<PaymentQuote> _quoteItems() =>
_quotation?.quotation?.items ?? const <PaymentQuote>[];
Future<void> _quoteRows({
required MultiQuotationProvider quotation,
required String fileName,
required List<CsvPayoutRow> rows,
required PaymentMethodData sourceMethod,
required String sourceCurrencyCode,
}) async {
final intents = _intentBuilder.buildIntents(
sourceMethod: sourceMethod,
sourceCurrency: sourceCurrencyCode,
rows: rows,
);
_selectedFileName = fileName;
_rows = rows;
await quotation.quotePayments(
intents,
metadata: <String, String>{
'upload_filename': fileName,
'upload_rows': rows.length.toString(),
...?_uploadAmountMetadata(),
},
);
}
@override
void dispose() {
_quotation?.removeListener(_onQuotationChanged);

View File

@@ -1,14 +1,10 @@
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/fees/treatment.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/settlement_mode.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/payment/fx_helpers.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
@@ -16,19 +12,11 @@ import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
class MultipleIntentBuilder {
static const String _currency = 'RUB';
List<PaymentIntent> buildIntents(
Wallet sourceWallet,
List<CsvPayoutRow> rows,
) {
final sourceCurrency = currencyCodeToString(sourceWallet.currency);
final hasAsset = (sourceWallet.tokenSymbol ?? '').isNotEmpty;
final sourceAsset = hasAsset
? PaymentAsset(
chain: sourceWallet.network ?? ChainNetwork.unspecified,
tokenSymbol: sourceWallet.tokenSymbol!,
contractAddress: sourceWallet.contractAddress,
)
: null;
List<PaymentIntent> buildIntents({
required PaymentMethodData sourceMethod,
required String sourceCurrency,
required List<CsvPayoutRow> rows,
}) {
final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
baseCurrency: sourceCurrency,
quoteCurrency: _currency,
@@ -39,10 +27,7 @@ class MultipleIntentBuilder {
final amount = Money(amount: row.amount, currency: _currency);
return PaymentIntent(
kind: PaymentKind.payout,
source: ManagedWalletPaymentMethod(
managedWalletRef: sourceWallet.id,
asset: sourceAsset,
),
source: sourceMethod,
destination: CardPaymentMethod(
pan: row.pan,
firstName: row.firstName,

View File

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

View File

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

View File

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