Merge remote-tracking branch 'origin/main' into SEND066

merge main into SEND066
This commit is contained in:
Arseni
2026-03-05 21:12:43 +03:00
22 changed files with 528 additions and 265 deletions

View File

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

View File

@@ -50,10 +50,3 @@ treasury:
limits: limits:
max_amount_per_operation: "" max_amount_per_operation: ""
max_daily_amount: "" 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 ( import (
"context" "context"
"os" "os"
"strings"
"time" "time"
"github.com/tech/sendico/gateway/tgsettle/internal/service/gateway" "github.com/tech/sendico/gateway/tgsettle/internal/service/gateway"
@@ -38,8 +37,6 @@ type config struct {
*grpcapp.Config `yaml:",inline"` *grpcapp.Config `yaml:",inline"`
Gateway gatewayConfig `yaml:"gateway"` Gateway gatewayConfig `yaml:"gateway"`
Treasury treasuryConfig `yaml:"treasury"` Treasury treasuryConfig `yaml:"treasury"`
Ledger ledgerConfig `yaml:"ledger"` // deprecated: use treasury.ledger
Telegram telegramConfig `yaml:"telegram"` // deprecated: use treasury.telegram
} }
type gatewayConfig struct { type gatewayConfig struct {
@@ -50,20 +47,9 @@ type gatewayConfig struct {
SuccessReaction string `yaml:"success_reaction"` 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 { type treasuryConfig struct {
ExecutionDelay time.Duration `yaml:"execution_delay"` ExecutionDelay time.Duration `yaml:"execution_delay"`
PollInterval time.Duration `yaml:"poll_interval"` PollInterval time.Duration `yaml:"poll_interval"`
Telegram telegramConfig `yaml:"telegram"`
Ledger ledgerConfig `yaml:"ledger"` Ledger ledgerConfig `yaml:"ledger"`
Limits treasuryLimitsConfig `yaml:"limits"` Limits treasuryLimitsConfig `yaml:"limits"`
} }
@@ -145,8 +131,6 @@ func (i *Imp) Start() error {
if cfg.Messaging != nil { if cfg.Messaging != nil {
msgSettings = cfg.Messaging.Settings msgSettings = cfg.Messaging.Settings
} }
treasuryTelegram := treasuryTelegramConfig(cfg, i.logger)
treasuryLedger := treasuryLedgerConfig(cfg, i.logger)
gwCfg := gateway.Config{ gwCfg := gateway.Config{
Rail: cfg.Gateway.Rail, Rail: cfg.Gateway.Rail,
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv, TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
@@ -159,12 +143,8 @@ func (i *Imp) Start() error {
Treasury: gateway.TreasuryConfig{ Treasury: gateway.TreasuryConfig{
ExecutionDelay: cfg.Treasury.ExecutionDelay, ExecutionDelay: cfg.Treasury.ExecutionDelay,
PollInterval: cfg.Treasury.PollInterval, PollInterval: cfg.Treasury.PollInterval,
Telegram: gateway.TelegramConfig{
AllowedChats: treasuryTelegram.AllowedChats,
Users: telegramUsers(treasuryTelegram.Users),
},
Ledger: gateway.LedgerConfig{ Ledger: gateway.LedgerConfig{
Timeout: treasuryLedger.Timeout, Timeout: cfg.Treasury.Ledger.Timeout,
}, },
Limits: gateway.TreasuryLimitsConfig{ Limits: gateway.TreasuryLimitsConfig{
MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation, MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation,
@@ -228,46 +208,3 @@ func (i *Imp) loadConfig() (*config, error) {
} }
return cfg, nil 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 Treasury TreasuryConfig
} }
type TelegramConfig struct {
AllowedChats []string
Users []TelegramUserBinding
}
type TelegramUserBinding struct {
TelegramUserID string
LedgerAccount string
}
type TreasuryConfig struct { type TreasuryConfig struct {
ExecutionDelay time.Duration ExecutionDelay time.Duration
PollInterval time.Duration PollInterval time.Duration
Telegram TelegramConfig
Ledger LedgerConfig Ledger LedgerConfig
Limits TreasuryLimitsConfig Limits TreasuryLimitsConfig
} }
@@ -181,39 +170,13 @@ func (s *Service) Shutdown() {
} }
func (s *Service) startTreasuryModule() { 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 return
} }
if s.cfg.DiscoveryRegistry == nil { if s.cfg.DiscoveryRegistry == nil {
s.logger.Warn("Treasury module disabled: discovery registry is unavailable") s.logger.Warn("Treasury module disabled: discovery registry is unavailable")
return 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 ledgerTimeout := s.cfg.Treasury.Ledger.Timeout
if ledgerTimeout <= 0 { if ledgerTimeout <= 0 {
@@ -241,10 +204,9 @@ func (s *Service) startTreasuryModule() {
module, err := treasurysvc.NewModule( module, err := treasurysvc.NewModule(
s.logger, s.logger,
s.repo.TreasuryRequests(), s.repo.TreasuryRequests(),
s.repo.TreasuryTelegramUsers(),
ledgerClient, ledgerClient,
treasurysvc.Config{ treasurysvc.Config{
AllowedChats: s.cfg.Treasury.Telegram.AllowedChats,
Users: users,
ExecutionDelay: executionDelay, ExecutionDelay: executionDelay,
PollInterval: pollInterval, PollInterval: pollInterval,
MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation, MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation,

View File

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

View File

@@ -14,5 +14,5 @@ func markdownCode(value string) string {
} }
func markdownCommand(command Command) 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) 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 { type limitError interface {
error error
LimitKind() string LimitKind() string
@@ -65,9 +75,7 @@ type Router struct {
send SendTextFunc send SendTextFunc
tracker ScheduleTracker tracker ScheduleTracker
allowedChats map[string]struct{} users UserBindingResolver
userAccounts map[string]string
allowAnyChat bool
} }
func NewRouter( func NewRouter(
@@ -75,43 +83,23 @@ func NewRouter(
service TreasuryService, service TreasuryService,
send SendTextFunc, send SendTextFunc,
tracker ScheduleTracker, tracker ScheduleTracker,
allowedChats []string, users UserBindingResolver,
userAccounts map[string]string,
) *Router { ) *Router {
if logger != nil { if logger != nil {
logger = logger.Named("treasury_router") 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{ return &Router{
logger: logger, logger: logger,
service: service, service: service,
dialogs: NewDialogs(), dialogs: NewDialogs(),
send: send, send: send,
tracker: tracker, tracker: tracker,
allowedChats: allowed, users: users,
userAccounts: users,
allowAnyChat: len(allowed) == 0,
} }
} }
func (r *Router) Enabled() bool { 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 { 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 { binding, err := r.users.ResolveUserBinding(ctx, userID)
if _, ok := r.allowedChats[chatID]; !ok { if err != nil {
r.logUnauthorized(update) if r.logger != nil {
_ = r.sendText(ctx, chatID, unauthorizedChatMessage) r.logger.Warn("Failed to resolve treasury user binding",
return true 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
} }
if binding == nil || strings.TrimSpace(binding.LedgerAccountID) == "" {
accountID, ok := r.userAccounts[userID]
if !ok || strings.TrimSpace(accountID) == "" {
r.logUnauthorized(update) r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedMessage) _ = r.sendText(ctx, chatID, unauthorizedMessage)
return true return true
} }
if !isChatAllowed(chatID, binding.AllowedChatIDs) {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
return true
}
accountID := strings.TrimSpace(binding.LedgerAccountID)
switch command { switch command {
case CommandStart: case CommandStart:
@@ -507,6 +503,22 @@ func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID stri
return profile 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 { func formatSeconds(value int64) string {
if value == 1 { if value == 1 {
return "1 second" return "1 second"

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ type Module struct {
func NewModule( func NewModule(
logger mlogger.Logger, logger mlogger.Logger,
repo storage.TreasuryRequestsStore, repo storage.TreasuryRequestsStore,
users storage.TreasuryTelegramUsersStore,
ledgerClient ledger.Client, ledgerClient ledger.Client,
cfg Config, cfg Config,
send bot.SendTextFunc, send bot.SendTextFunc,
@@ -33,6 +34,9 @@ func NewModule(
if logger != nil { if logger != nil {
logger = logger.Named("treasury") logger = logger.Named("treasury")
} }
if users == nil {
return nil, merrors.InvalidArgument("treasury telegram users store is required", "users")
}
service, err := NewService( service, err := NewService(
logger, logger,
repo, repo,
@@ -45,23 +49,13 @@ func NewModule(
return nil, err 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{ module := &Module{
logger: logger, logger: logger,
service: service, service: service,
ledger: ledgerClient, ledger: ledgerClient,
} }
module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval) 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 return module, nil
} }
@@ -99,6 +93,28 @@ type botServiceAdapter struct {
svc *Service 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) { func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) {
if a == nil || a.svc == nil { if a == nil || a.svc == nil {
return 0 return 0
@@ -164,3 +180,26 @@ func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string,
} }
return a.svc.CancelRequest(ctx, requestID, telegramUserID) 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 { 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 { func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string {

View File

@@ -5,6 +5,7 @@ const (
telegramConfirmationsCollection = "telegram_confirmations" telegramConfirmationsCollection = "telegram_confirmations"
pendingConfirmationsCollection = "pending_confirmations" pendingConfirmationsCollection = "pending_confirmations"
treasuryRequestsCollection = "treasury_requests" treasuryRequestsCollection = "treasury_requests"
treasuryTelegramUsersCollection = "treasury_telegram_users"
) )
func (*PaymentRecord) Collection() string { func (*PaymentRecord) Collection() string {
@@ -22,3 +23,7 @@ func (*PendingConfirmation) Collection() string {
func (*TreasuryRequest) Collection() string { func (*TreasuryRequest) Collection() string {
return treasuryRequestsCollection 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"` 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 tg storage.TelegramConfirmationsStore
pending storage.PendingConfirmationsStore pending storage.PendingConfirmationsStore
treasury storage.TreasuryRequestsStore treasury storage.TreasuryRequestsStore
users storage.TreasuryTelegramUsersStore
outbox gatewayoutbox.Store 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")) result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests"))
return nil, err 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) outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
if err != nil { if err != nil {
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) 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.tg = tgStore
result.pending = pendingStore result.pending = pendingStore
result.treasury = treasuryStore result.treasury = treasuryStore
result.users = treasuryUsersStore
result.outbox = outboxStore result.outbox = outboxStore
result.logger.Info("Payment gateway MongoDB storage initialised") result.logger.Info("Payment gateway MongoDB storage initialised")
return result, nil return result, nil
@@ -110,6 +117,10 @@ func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore {
return r.treasury return r.treasury
} }
func (r *Repository) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore {
return r.users
}
func (r *Repository) Outbox() gatewayoutbox.Store { func (r *Repository) Outbox() gatewayoutbox.Store {
return r.outbox 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("operationType"), record.OperationType).
Set(repository.Field("telegramUserId"), record.TelegramUserID). Set(repository.Field("telegramUserId"), record.TelegramUserID).
Set(repository.Field("ledgerAccountId"), record.LedgerAccountID). Set(repository.Field("ledgerAccountId"), record.LedgerAccountID).
Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode).
Set(repository.Field("organizationRef"), record.OrganizationRef). Set(repository.Field("organizationRef"), record.OrganizationRef).
Set(repository.Field("chatId"), record.ChatID). Set(repository.Field("chatId"), record.ChatID).
Set(repository.Field("amount"), record.Amount). Set(repository.Field("amount"), record.Amount).
Set(repository.Field("currency"), record.Currency). Set(repository.Field("currency"), record.Currency).
Set(repository.Field(fieldTreasuryStatus), record.Status). 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(fieldTreasuryIdempotencyKey), record.IdempotencyKey).
Set(repository.Field("ledgerReference"), record.LedgerReference).
Set(repository.Field("errorMessage"), record.ErrorMessage).
Set(repository.Field(fieldTreasuryActive), record.Active) 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 _, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, record.RequestID), patch); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { 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)) t.logger.Warn("Failed to update treasury request", zap.Error(err), zap.String("request_id", record.RequestID))

View File

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

View File

@@ -15,6 +15,7 @@ type Repository interface {
TelegramConfirmations() TelegramConfirmationsStore TelegramConfirmations() TelegramConfirmationsStore
PendingConfirmations() PendingConfirmationsStore PendingConfirmations() PendingConfirmationsStore
TreasuryRequests() TreasuryRequestsStore TreasuryRequests() TreasuryRequestsStore
TreasuryTelegramUsers() TreasuryTelegramUsersStore
} }
type PaymentsStore interface { type PaymentsStore interface {
@@ -46,3 +47,7 @@ type TreasuryRequestsStore interface {
Update(ctx context.Context, record *model.TreasuryRequest) error Update(ctx context.Context, record *model.TreasuryRequest) error
ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]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,23 +1,29 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/money.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/payment.dart';
import 'package:pshared/models/payment/quote/status_type.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/csv_row.dart';
import 'package:pweb/models/payment/multiple_payouts/state.dart'; import 'package:pweb/models/payment/multiple_payouts/state.dart';
import 'package:pweb/providers/multiple_payouts.dart'; import 'package:pweb/providers/multiple_payouts.dart';
import 'package:pweb/services/payments/csv_input.dart'; import 'package:pweb/services/payments/csv_input.dart';
class MultiplePayoutsController extends ChangeNotifier { class MultiplePayoutsController extends ChangeNotifier {
final CsvInputService _csvInput; final CsvInputService _csvInput;
MultiplePayoutsProvider? _provider; MultiplePayoutsProvider? _provider;
PaymentSourceController? _sourceController; PaymentSourceController? _sourceController;
_PickState _pickState = _PickState.idle; _PickState _pickState = _PickState.idle;
Exception? _uiError; Exception? _uiError;
String? _lastSourceKey;
MultiplePayoutsController({required CsvInputService csvInput}) MultiplePayoutsController({required CsvInputService csvInput})
: _csvInput = csvInput; : _csvInput = csvInput;
@@ -37,6 +43,7 @@ class MultiplePayoutsController extends ChangeNotifier {
_sourceController?.removeListener(_onSourceChanged); _sourceController?.removeListener(_onSourceChanged);
_sourceController = sourceController; _sourceController = sourceController;
_sourceController?.addListener(_onSourceChanged); _sourceController?.addListener(_onSourceChanged);
_lastSourceKey = _currentSourceKey;
shouldNotify = true; shouldNotify = true;
} }
if (shouldNotify) { if (shouldNotify) {
@@ -60,16 +67,16 @@ class MultiplePayoutsController extends ChangeNotifier {
_provider?.quoteStatusType ?? QuoteStatusType.missing; _provider?.quoteStatusType ?? QuoteStatusType.missing;
Duration? get quoteTimeLeft => _provider?.quoteTimeLeft; Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null; bool get canSend => (_provider?.canSend ?? false) && _selectedSource != null;
Money? get aggregateDebitAmount => Money? get aggregateDebitAmount =>
_provider?.aggregateDebitAmountFor(_selectedWallet); _provider?.aggregateDebitAmountForCurrency(_selectedSourceCurrencyCode);
Money? get requestedSentAmount => _provider?.requestedSentAmount; Money? get requestedSentAmount => _provider?.requestedSentAmount;
Money? get aggregateSettlementAmount => Money? get aggregateSettlementAmount => _provider
_provider?.aggregateSettlementAmountFor(_selectedWallet); ?.aggregateSettlementAmountForCurrency(_selectedSourceCurrencyCode);
Money? get aggregateFeeAmount => Money? get aggregateFeeAmount =>
_provider?.aggregateFeeAmountFor(_selectedWallet); _provider?.aggregateFeeAmountForCurrency(_selectedSourceCurrencyCode);
double? get aggregateFeePercent => double? get aggregateFeePercent =>
_provider?.aggregateFeePercentFor(_selectedWallet); _provider?.aggregateFeePercentForCurrency(_selectedSourceCurrencyCode);
Future<void> pickAndQuote() async { Future<void> pickAndQuote() async {
if (_pickState == _PickState.picking) return; if (_pickState == _PickState.picking) return;
@@ -84,15 +91,16 @@ class MultiplePayoutsController extends ChangeNotifier {
try { try {
final picked = await _csvInput.pickCsv(); final picked = await _csvInput.pickCsv();
if (picked == null) return; if (picked == null) return;
final wallet = _selectedWallet; final source = _selectedSource;
if (wallet == null) { if (source == null) {
_setUiError(StateError('Select source wallet first')); _setUiError(StateError('Select source of funds first'));
return; return;
} }
await provider.quoteFromCsv( await provider.quoteFromCsv(
fileName: picked.name, fileName: picked.name,
content: picked.content, content: picked.content,
sourceWallet: wallet, sourceMethod: source.method,
sourceCurrencyCode: source.currencyCode,
); );
} catch (e) { } catch (e) {
_setUiError(e); _setUiError(e);
@@ -131,10 +139,78 @@ class MultiplePayoutsController extends ChangeNotifier {
} }
void _onSourceChanged() { void _onSourceChanged() {
final currentSourceKey = _currentSourceKey;
final sourceChanged = currentSourceKey != _lastSourceKey;
_lastSourceKey = currentSourceKey;
if (sourceChanged) {
unawaited(_requoteWithUploadedRows());
}
notifyListeners(); 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) { void _setUiError(Object error) {
_uiError = error is Exception ? error : Exception(error.toString()); _uiError = error is Exception ? error : Exception(error.toString());

View File

@@ -5,6 +5,8 @@ import 'package:pshared/utils/money.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
String moneyLabel(Money? money) { String moneyLabel(Money? money) {
if (money == null) return 'N/A'; if (money == null) return 'N/A';
@@ -12,10 +14,7 @@ String moneyLabel(Money? money) {
if (amount.isNaN) return '${money.amount} ${money.currency}'; if (amount.isNaN) return '${money.amount} ${money.currency}';
try { try {
return assetToString( return assetToString(
Asset( Asset(currency: currencyStringToCode(money.currency), amount: amount),
currency: currencyStringToCode(money.currency),
amount: amount,
),
); );
} catch (_) { } catch (_) {
return '${money.amount} ${money.currency}'; return '${money.amount} ${money.currency}';
@@ -31,6 +30,8 @@ String sentAmountLabel(MultiplePayoutsController controller) {
return moneyLabel(requested); return moneyLabel(requested);
} }
String feeLabel(MultiplePayoutsController controller) { String feeLabel(MultiplePayoutsController controller, AppLocalizations l10n) {
return moneyLabel(controller.aggregateFeeAmount); 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/models/dashboard/summary_values.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.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/pages/dashboard/payouts/summary/widget.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuoteSummary extends StatelessWidget { class SourceQuoteSummary extends StatelessWidget {
const SourceQuoteSummary({ const SourceQuoteSummary({
@@ -21,7 +21,7 @@ class SourceQuoteSummary extends StatelessWidget {
return PaymentSummary( return PaymentSummary(
spacing: spacing, spacing: spacing,
values: PaymentSummaryValues( values: PaymentSummaryValues(
fee: feeLabel(controller), fee: feeLabel(controller, AppLocalizations.of(context)!),
recipientReceives: moneyLabel(controller.aggregateSettlementAmount), recipientReceives: moneyLabel(controller.aggregateSettlementAmount),
total: moneyLabel(controller.aggregateDebitAmount), total: moneyLabel(controller.aggregateDebitAmount),
), ),

View File

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

View File

@@ -1,14 +1,10 @@
import 'package:pshared/models/money.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/fees/treatment.dart'; import 'package:pshared/models/payment/fees/treatment.dart';
import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/card.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/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:pshared/utils/payment/fx_helpers.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.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 { class MultipleIntentBuilder {
static const String _currency = 'RUB'; static const String _currency = 'RUB';
List<PaymentIntent> buildIntents( List<PaymentIntent> buildIntents({
Wallet sourceWallet, required PaymentMethodData sourceMethod,
List<CsvPayoutRow> rows, required String sourceCurrency,
) { required 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;
final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
baseCurrency: sourceCurrency, baseCurrency: sourceCurrency,
quoteCurrency: _currency, quoteCurrency: _currency,
@@ -39,10 +27,7 @@ class MultipleIntentBuilder {
final amount = Money(amount: row.amount, currency: _currency); final amount = Money(amount: row.amount, currency: _currency);
return PaymentIntent( return PaymentIntent(
kind: PaymentKind.payout, kind: PaymentKind.payout,
source: ManagedWalletPaymentMethod( source: sourceMethod,
managedWalletRef: sourceWallet.id,
asset: sourceAsset,
),
destination: CardPaymentMethod( destination: CardPaymentMethod(
pan: row.pan, pan: row.pan,
firstName: row.firstName, firstName: row.firstName,