13 Commits

Author SHA1 Message Date
b481de9ffc Merge pull request 'New comments section in the requests/responses' (#679) from bff-677 into main
Reviewed-on: #679
2026-03-05 19:29:10 +00:00
Stephan D
0c29e7686d New comments section in the requests/responses 2026-03-05 20:28:28 +01:00
5b26a70a15 Merge pull request 'New comments section in the requests/responses' (#678) from bff-677 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #678
2026-03-05 19:28:05 +00:00
Stephan D
b832c2a7c4 New comments section in the requests/responses 2026-03-05 20:27:45 +01:00
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
31 changed files with 582 additions and 265 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -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 "TGTREASURY-" + 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

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

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

View File

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

View File

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

View File

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

View File

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