Chimera Settle service

This commit is contained in:
Stephan D
2026-03-06 15:42:32 +01:00
parent ea5ec79a6e
commit 10bcdb4fe2
43 changed files with 8070 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
package bot
import "strings"
type Command string
const (
CommandStart Command = "start"
CommandHelp Command = "help"
CommandFund Command = "fund"
CommandWithdraw Command = "withdraw"
CommandConfirm Command = "confirm"
CommandCancel Command = "cancel"
)
var supportedCommands = []Command{
CommandStart,
CommandHelp,
CommandFund,
CommandWithdraw,
CommandConfirm,
CommandCancel,
}
func (c Command) Slash() string {
name := strings.TrimSpace(string(c))
if name == "" {
return ""
}
return "/" + name
}
func parseCommand(text string) Command {
text = strings.TrimSpace(text)
if !strings.HasPrefix(text, "/") {
return ""
}
token := text
if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 {
token = token[:idx]
}
token = strings.TrimPrefix(token, "/")
if idx := strings.Index(token, "@"); idx >= 0 {
token = token[:idx]
}
return Command(strings.ToLower(strings.TrimSpace(token)))
}
func supportedCommandsMessage() string {
lines := make([]string, 0, len(supportedCommands)+2)
lines = append(lines, "*Supported Commands*")
lines = append(lines, "")
for _, cmd := range supportedCommands {
lines = append(lines, markdownCommand(cmd))
}
return strings.Join(lines, "\n")
}
func confirmationCommandsMessage() string {
return strings.Join([]string{
"*Confirm Operation*",
"",
"Use " + markdownCommand(CommandConfirm) + " to execute.",
"Use " + markdownCommand(CommandCancel) + " to abort.",
}, "\n")
}
func helpMessage(accountCode string, currency string) string {
accountCode = strings.TrimSpace(accountCode)
currency = strings.ToUpper(strings.TrimSpace(currency))
if accountCode == "" {
accountCode = "N/A"
}
if currency == "" {
currency = "N/A"
}
lines := []string{
"*Treasury Bot Help*",
"",
"*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")",
"",
"*How to use*",
"1. Start funding with " + markdownCommand(CommandFund) + " or withdrawal with " + markdownCommand(CommandWithdraw) + ".",
"2. Enter amount as decimal with dot separator and no currency.",
" Example: " + markdownCode("1250.75"),
"3. Confirm with " + markdownCommand(CommandConfirm) + " or abort with " + markdownCommand(CommandCancel) + ".",
"",
"*Cooldown*",
"After confirmation there is a cooldown window. You can cancel during it with " + markdownCommand(CommandCancel) + ".",
"You will receive a follow-up message with execution success or failure.",
}
return strings.Join(lines, "\n")
}

View File

@@ -0,0 +1,73 @@
package bot
import (
"strings"
"sync"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
)
type DialogState string
const (
DialogStateWaitingAmount DialogState = "waiting_amount"
DialogStateWaitingConfirmation DialogState = "waiting_confirmation"
)
type DialogSession struct {
State DialogState
OperationType storagemodel.TreasuryOperationType
LedgerAccountID string
RequestID string
}
type Dialogs struct {
mu sync.Mutex
sessions map[string]DialogSession
}
func NewDialogs() *Dialogs {
return &Dialogs{
sessions: map[string]DialogSession{},
}
}
func (d *Dialogs) Get(telegramUserID string) (DialogSession, bool) {
if d == nil {
return DialogSession{}, false
}
telegramUserID = strings.TrimSpace(telegramUserID)
if telegramUserID == "" {
return DialogSession{}, false
}
d.mu.Lock()
defer d.mu.Unlock()
session, ok := d.sessions[telegramUserID]
return session, ok
}
func (d *Dialogs) Set(telegramUserID string, session DialogSession) {
if d == nil {
return
}
telegramUserID = strings.TrimSpace(telegramUserID)
if telegramUserID == "" {
return
}
d.mu.Lock()
defer d.mu.Unlock()
d.sessions[telegramUserID] = session
}
func (d *Dialogs) Clear(telegramUserID string) {
if d == nil {
return
}
telegramUserID = strings.TrimSpace(telegramUserID)
if telegramUserID == "" {
return
}
d.mu.Lock()
defer d.mu.Unlock()
delete(d.sessions, telegramUserID)
}

View File

@@ -0,0 +1,18 @@
package bot
import (
"strings"
)
func markdownCode(value string) string {
value = strings.TrimSpace(value)
if value == "" {
value = "N/A"
}
value = strings.ReplaceAll(value, "`", "'")
return "`" + value + "`"
}
func markdownCommand(command Command) string {
return command.Slash()
}

View File

@@ -0,0 +1,527 @@
package bot
import (
"context"
"errors"
"strconv"
"strings"
"time"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
const unauthorizedMessage = "*Unauthorized*\nYour Telegram account is not allowed to perform treasury operations."
const unauthorizedChatMessage = "*Unauthorized Chat*\nThis Telegram chat is not allowed to perform treasury operations."
const amountInputHint = "*Amount format*\nEnter amount as a decimal number using a dot separator and without currency.\nExample: `1250.75`"
type SendTextFunc func(ctx context.Context, chatID string, text string) error
type ScheduleTracker interface {
TrackScheduled(record *storagemodel.TreasuryRequest)
Untrack(requestID string)
}
type AccountProfile struct {
AccountID string
AccountCode string
Currency string
}
type CreateRequestInput struct {
OperationType storagemodel.TreasuryOperationType
TelegramUserID string
LedgerAccountID string
ChatID string
Amount string
}
type TreasuryService interface {
ExecutionDelay() time.Duration
MaxPerOperationLimit() string
GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error)
GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error)
CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error)
ConfirmRequest(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 {
error
LimitKind() string
LimitMax() string
}
type Router struct {
logger mlogger.Logger
service TreasuryService
dialogs *Dialogs
send SendTextFunc
tracker ScheduleTracker
users UserBindingResolver
}
func NewRouter(
logger mlogger.Logger,
service TreasuryService,
send SendTextFunc,
tracker ScheduleTracker,
users UserBindingResolver,
) *Router {
if logger != nil {
logger = logger.Named("treasury_router")
}
return &Router{
logger: logger,
service: service,
dialogs: NewDialogs(),
send: send,
tracker: tracker,
users: users,
}
}
func (r *Router) Enabled() bool {
return r != nil && r.service != nil && r.users != nil
}
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
if !r.Enabled() || update == nil || update.Message == nil {
return false
}
message := update.Message
chatID := strings.TrimSpace(message.ChatID)
userID := strings.TrimSpace(message.FromUserID)
text := strings.TrimSpace(message.Text)
if chatID == "" || userID == "" {
return false
}
command := parseCommand(text)
if r.logger != nil {
r.logger.Debug("Telegram treasury update received",
zap.Int64("update_id", update.UpdateID),
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("command", strings.TrimSpace(string(command))),
zap.String("message_text", text),
zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)),
)
}
binding, err := r.users.ResolveUserBinding(ctx, userID)
if err != nil {
if r.logger != nil {
r.logger.Warn("Failed to resolve treasury user binding",
zap.Error(err),
zap.String("telegram_user_id", userID),
zap.String("chat_id", chatID))
}
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check treasury authorization right now. Please try again.")
return true
}
if binding == nil || strings.TrimSpace(binding.LedgerAccountID) == "" {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedMessage)
return true
}
if !isChatAllowed(chatID, binding.AllowedChatIDs) {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
return true
}
accountID := strings.TrimSpace(binding.LedgerAccountID)
switch command {
case CommandStart:
profile := r.resolveAccountProfile(ctx, accountID)
_ = r.sendText(ctx, chatID, welcomeMessage(profile))
return true
case CommandHelp:
profile := r.resolveAccountProfile(ctx, accountID)
_ = r.sendText(ctx, chatID, helpMessage(displayAccountCode(profile, accountID), profile.Currency))
return true
case CommandFund:
if r.logger != nil {
r.logger.Info("Treasury funding dialog requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
return true
case CommandWithdraw:
if r.logger != nil {
r.logger.Info("Treasury withdrawal dialog requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
return true
case CommandConfirm:
if r.logger != nil {
r.logger.Info("Treasury confirmation requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
r.confirm(ctx, userID, accountID, chatID)
return true
case CommandCancel:
if r.logger != nil {
r.logger.Info("Treasury cancellation requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
r.cancel(ctx, userID, accountID, chatID)
return true
}
session, hasSession := r.dialogs.Get(userID)
if hasSession {
switch session.State {
case DialogStateWaitingAmount:
r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text)
return true
case DialogStateWaitingConfirmation:
_ = r.sendText(ctx, chatID, confirmationCommandsMessage())
return true
}
}
if strings.HasPrefix(text, "/") {
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
return true
}
if strings.TrimSpace(message.ReplyToMessageID) != "" {
return false
}
if text != "" {
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
return true
}
return false
}
func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) {
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
if err != nil {
if r.logger != nil {
r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID))
}
_ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check pending treasury operations right now. Please try again.")
return
}
if active != nil {
_ = r.sendText(ctx, chatID, pendingRequestMessage(active))
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingConfirmation,
LedgerAccountID: accountID,
RequestID: active.RequestID,
})
return
}
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingAmount,
OperationType: operation,
LedgerAccountID: accountID,
})
profile := r.resolveAccountProfile(ctx, accountID)
_ = r.sendText(ctx, chatID, amountPromptMessage(operation, profile, accountID))
}
func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) {
record, err := r.service.CreateRequest(ctx, CreateRequestInput{
OperationType: operation,
TelegramUserID: userID,
LedgerAccountID: accountID,
ChatID: chatID,
Amount: amount,
})
if err != nil {
if record != nil {
_ = r.sendText(ctx, chatID, pendingRequestMessage(record))
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingConfirmation,
LedgerAccountID: accountID,
RequestID: record.RequestID,
})
return
}
if typed, ok := err.(limitError); ok {
switch typed.LimitKind() {
case "per_operation":
_ = r.sendText(ctx, chatID, "*Amount exceeds allowed limit*\n\n*Max per operation:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
return
case "daily":
_ = r.sendText(ctx, chatID, "*Daily amount limit exceeded*\n\n*Max per day:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
return
}
}
if errors.Is(err, merrors.ErrInvalidArg) {
_ = r.sendText(ctx, chatID, "*Invalid amount*\n\n"+amountInputHint+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
return
}
_ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
return
}
if record == nil {
_ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".")
return
}
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingConfirmation,
LedgerAccountID: accountID,
RequestID: record.RequestID,
})
_ = r.sendText(ctx, chatID, confirmationPrompt(record))
}
func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) {
requestID := ""
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
requestID = strings.TrimSpace(session.RequestID)
} else {
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
if err == nil && active != nil {
requestID = strings.TrimSpace(active.RequestID)
}
}
if requestID == "" {
_ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
return
}
record, err := r.service.ConfirmRequest(ctx, requestID, userID)
if err != nil {
_ = r.sendText(ctx, chatID, "*Unable to confirm treasury request.*\n\nUse "+markdownCommand(CommandCancel)+" or create a new request with "+markdownCommand(CommandFund)+" or "+markdownCommand(CommandWithdraw)+".")
return
}
if r.tracker != nil {
r.tracker.TrackScheduled(record)
}
r.dialogs.Clear(userID)
delay := int64(r.service.ExecutionDelay().Seconds())
if delay < 0 {
delay = 0
}
_ = r.sendText(ctx, chatID,
"*Operation confirmed*\n\n"+
"*Execution:* scheduled in "+markdownCode(formatSeconds(delay))+".\n"+
"You can cancel during this cooldown with "+markdownCommand(CommandCancel)+".\n\n"+
"You will receive a follow-up message with execution success or failure.\n\n"+
"*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID)))
}
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
requestID := ""
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
requestID = strings.TrimSpace(session.RequestID)
} else {
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
if err == nil && active != nil {
requestID = strings.TrimSpace(active.RequestID)
}
}
if requestID == "" {
r.dialogs.Clear(userID)
_ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
return
}
record, err := r.service.CancelRequest(ctx, requestID, userID)
if err != nil {
_ = r.sendText(ctx, chatID, "*Unable to cancel treasury request.*")
return
}
if r.tracker != nil {
r.tracker.Untrack(record.RequestID)
}
r.dialogs.Clear(userID)
_ = r.sendText(ctx, chatID, "*Operation cancelled*\n\n*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID)))
}
func (r *Router) sendText(ctx context.Context, chatID string, text string) error {
if r == nil || r.send == nil {
return nil
}
chatID = strings.TrimSpace(chatID)
text = strings.TrimSpace(text)
if chatID == "" || text == "" {
return nil
}
if err := r.send(ctx, chatID, text); err != nil {
if r.logger != nil {
r.logger.Warn("Failed to send treasury bot response",
zap.Error(err),
zap.String("chat_id", chatID),
zap.String("message_text", text))
}
return err
}
return nil
}
func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) {
if r == nil || r.logger == nil || update == nil || update.Message == nil {
return
}
message := update.Message
r.logger.Warn("unauthorized_access",
zap.String("event", "unauthorized_access"),
zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)),
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
zap.String("message_text", strings.TrimSpace(message.Text)),
zap.Time("timestamp", time.Now()),
)
}
func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
if record == nil {
return "*Pending treasury operation already exists.*\n\nUse " + markdownCommand(CommandCancel) + "."
}
return "*Pending Treasury Operation*\n\n" +
"*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
"*Request ID:* " + markdownCode(strings.TrimSpace(record.RequestID)) + "\n" +
"*Status:* " + markdownCode(strings.TrimSpace(string(record.Status))) + "\n" +
"*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
"Wait for execution or cancel with " + markdownCommand(CommandCancel) + "."
}
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
if record == nil {
return "*Request created.*\n\nUse " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + "."
}
title := "*Funding request created.*"
if record.OperationType == storagemodel.TreasuryOperationWithdraw {
title = "*Withdrawal request created.*"
}
return title + "\n\n" +
"*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
"*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
confirmationCommandsMessage()
}
func welcomeMessage(profile *AccountProfile) string {
accountCode := displayAccountCode(profile, "")
currency := ""
if profile != nil {
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
}
if accountCode == "" {
accountCode = "N/A"
}
if currency == "" {
currency = "N/A"
}
return "*Sendico Treasury Bot*\n\n" +
"*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n" +
"Use " + markdownCommand(CommandFund) + " to credit your account and " + markdownCommand(CommandWithdraw) + " to debit it.\n" +
"After entering an amount, use " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + ".\n" +
"Use " + markdownCommand(CommandHelp) + " for detailed usage."
}
func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string {
title := "*Funding request*"
if operation == storagemodel.TreasuryOperationWithdraw {
title = "*Withdrawal request*"
}
accountCode := displayAccountCode(profile, fallbackAccountID)
currency := ""
if profile != nil {
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
}
if accountCode == "" {
accountCode = "N/A"
}
if currency == "" {
currency = "N/A"
}
return title + "\n\n" +
"*Account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n\n" +
amountInputHint
}
func requestAccountDisplay(record *storagemodel.TreasuryRequest) string {
if record == nil {
return ""
}
if code := strings.TrimSpace(record.LedgerAccountCode); code != "" {
return code
}
return strings.TrimSpace(record.LedgerAccountID)
}
func displayAccountCode(profile *AccountProfile, fallbackAccountID string) string {
if profile != nil {
if code := strings.TrimSpace(profile.AccountCode); code != "" {
return code
}
if id := strings.TrimSpace(profile.AccountID); id != "" {
return id
}
}
return strings.TrimSpace(fallbackAccountID)
}
func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID string) *AccountProfile {
if r == nil || r.service == nil {
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
}
profile, err := r.service.GetAccountProfile(ctx, ledgerAccountID)
if err != nil {
if r.logger != nil {
r.logger.Warn("Failed to resolve treasury account profile",
zap.Error(err),
zap.String("ledger_account_id", strings.TrimSpace(ledgerAccountID)))
}
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
}
if profile == nil {
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
}
if strings.TrimSpace(profile.AccountID) == "" {
profile.AccountID = strings.TrimSpace(ledgerAccountID)
}
return profile
}
func isChatAllowed(chatID string, allowedChatIDs []string) bool {
chatID = strings.TrimSpace(chatID)
if chatID == "" {
return false
}
if len(allowedChatIDs) == 0 {
return true
}
for _, allowed := range allowedChatIDs {
if strings.TrimSpace(allowed) == chatID {
return true
}
}
return false
}
func formatSeconds(value int64) string {
if value == 1 {
return "1 second"
}
return strconv.FormatInt(value, 10) + " seconds"
}

View File

@@ -0,0 +1,362 @@
package bot
import (
"context"
"testing"
"time"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model"
)
type fakeService struct{}
type fakeUserBindingResolver struct {
bindings map[string]*UserBinding
err error
}
func (f fakeUserBindingResolver) ResolveUserBinding(_ context.Context, telegramUserID string) (*UserBinding, error) {
if f.err != nil {
return nil, f.err
}
if f.bindings == nil {
return nil, nil
}
return f.bindings[telegramUserID], nil
}
func (fakeService) ExecutionDelay() time.Duration {
return 30 * time.Second
}
func (fakeService) MaxPerOperationLimit() string {
return "1000000"
}
func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func (fakeService) GetAccountProfile(_ context.Context, ledgerAccountID string) (*AccountProfile, error) {
return &AccountProfile{
AccountID: ledgerAccountID,
AccountCode: ledgerAccountID,
Currency: "USD",
}, nil
}
func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func (fakeService) ConfirmRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func (fakeService) CancelRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
AllowedChatIDs: []string{"100"},
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "100",
FromUserID: "999",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterUnknownChatGetsDenied(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
AllowedChatIDs: []string{"100"},
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "999",
FromUserID: "123",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedChatMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "999",
FromUserID: "123",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != amountPromptMessage(
storagemodel.TreasuryOperationFund,
&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"},
"acct-1",
) {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "999",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "123",
Text: "/start",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != welcomeMessage(&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"}) {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterHelpAuthorizedShowsHelp(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "123",
Text: "/help",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != helpMessage("acct-1", "USD") {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterStartUnauthorizedGetsDenied(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "999",
Text: "/start",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
fakeUserBindingResolver{
bindings: map[string]*UserBinding{
"123": {
TelegramUserID: "123",
LedgerAccountID: "acct-1",
},
},
},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "123",
Text: "hello",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != supportedCommandsMessage() {
t.Fatalf("unexpected message: %q", sent[0])
}
}

View File

@@ -0,0 +1,11 @@
package treasury
import "time"
type Config struct {
ExecutionDelay time.Duration
PollInterval time.Duration
MaxAmountPerOperation string
MaxDailyAmount string
}

View File

@@ -0,0 +1,312 @@
package ledger
import (
"context"
"crypto/tls"
"fmt"
"net/url"
"strings"
"time"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
)
const ledgerConnectorID = "ledger"
type Config struct {
Endpoint string
Timeout time.Duration
Insecure bool
}
type Account struct {
AccountID string
AccountCode string
Currency string
OrganizationRef string
}
type Balance struct {
AccountID string
Amount string
Currency string
}
type PostRequest struct {
AccountID string
OrganizationRef string
Amount string
Currency string
Reference string
IdempotencyKey string
}
type OperationResult struct {
Reference string
}
type Client interface {
GetAccount(ctx context.Context, accountID string) (*Account, error)
GetBalance(ctx context.Context, accountID string) (*Balance, error)
ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error)
ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error)
Close() error
}
type grpcConnectorClient interface {
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
}
type connectorClient struct {
cfg Config
conn *grpc.ClientConn
client grpcConnectorClient
}
func New(cfg Config) (Client, error) {
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
if cfg.Endpoint == "" {
return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint")
}
if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" {
cfg.Endpoint = normalized
if insecure {
cfg.Insecure = true
}
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
dialOpts := []grpc.DialOption{}
if cfg.Insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...)
if err != nil {
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint))
}
return &connectorClient{
cfg: cfg,
conn: conn,
client: connectorv1.NewConnectorServiceClient(conn),
}, nil
}
func (c *connectorClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{
AccountRef: &connectorv1.AccountRef{
ConnectorId: ledgerConnectorID,
AccountId: accountID,
},
})
if err != nil {
return nil, err
}
account := resp.GetAccount()
if account == nil {
return nil, merrors.NoData("ledger account not found")
}
accountCode := strings.TrimSpace(account.GetLabel())
organizationRef := strings.TrimSpace(account.GetOwnerRef())
if organizationRef == "" && account.GetProviderDetails() != nil {
details := account.GetProviderDetails().AsMap()
if organizationRef == "" {
organizationRef = firstDetailValue(details, "organization_ref", "organizationRef", "org_ref")
}
if accountCode == "" {
accountCode = firstDetailValue(details, "account_code", "accountCode", "code", "ledger_account_code")
}
}
return &Account{
AccountID: accountID,
AccountCode: accountCode,
Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())),
OrganizationRef: organizationRef,
}, nil
}
func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{
AccountRef: &connectorv1.AccountRef{
ConnectorId: ledgerConnectorID,
AccountId: accountID,
},
})
if err != nil {
return nil, err
}
balance := resp.GetBalance()
if balance == nil || balance.GetAvailable() == nil {
return nil, merrors.Internal("ledger balance is unavailable")
}
return &Balance{
AccountID: accountID,
Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())),
}, nil
}
func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req)
}
func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req)
}
func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) {
req.AccountID = strings.TrimSpace(req.AccountID)
req.OrganizationRef = strings.TrimSpace(req.OrganizationRef)
req.Amount = strings.TrimSpace(req.Amount)
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
req.Reference = strings.TrimSpace(req.Reference)
req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey)
if req.AccountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref")
}
if req.Amount == "" || req.Currency == "" {
return nil, merrors.InvalidArgument("ledger amount is required", "amount")
}
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key")
}
params := map[string]any{
"organization_ref": req.OrganizationRef,
"operation": operation,
"description": "chsettle treasury operation",
"metadata": map[string]any{
"reference": req.Reference,
},
}
operationReq := &connectorv1.Operation{
Type: opType,
IdempotencyKey: req.IdempotencyKey,
Money: &moneyv1.Money{
Amount: req.Amount,
Currency: req.Currency,
},
Params: structFromMap(params),
}
account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID}
switch opType {
case connectorv1.OperationType_CREDIT:
operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
case connectorv1.OperationType_DEBIT:
operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq})
if err != nil {
return nil, err
}
if resp.GetReceipt() == nil {
return nil, merrors.Internal("ledger receipt is unavailable")
}
if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil {
message := strings.TrimSpace(receiptErr.GetMessage())
if message == "" {
message = "ledger operation failed"
}
return nil, merrors.InvalidArgument(message)
}
reference := strings.TrimSpace(resp.GetReceipt().GetOperationId())
if reference == "" {
reference = req.Reference
}
return &OperationResult{Reference: reference}, nil
}
func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(ctx, c.cfg.Timeout)
}
func structFromMap(values map[string]any) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func normalizeEndpoint(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw, false
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "http", "grpc":
return parsed.Host, true
case "https", "grpcs":
return parsed.Host, false
default:
return raw, false
}
}
func firstDetailValue(values map[string]any, keys ...string) string {
if len(values) == 0 || len(keys) == 0 {
return ""
}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if value, ok := values[key]; ok {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" {
return text
}
}
}
return ""
}

View File

@@ -0,0 +1,235 @@
package ledger
import (
"context"
"fmt"
"net"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type DiscoveryConfig struct {
Logger mlogger.Logger
Registry *discovery.Registry
Timeout time.Duration
}
type discoveryEndpoint struct {
address string
insecure bool
raw string
}
func (e discoveryEndpoint) key() string {
return fmt.Sprintf("%s|%t", e.address, e.insecure)
}
type discoveryClient struct {
logger mlogger.Logger
registry *discovery.Registry
timeout time.Duration
mu sync.Mutex
client Client
endpointKey string
}
func NewDiscoveryClient(cfg DiscoveryConfig) (Client, error) {
if cfg.Registry == nil {
return nil, merrors.InvalidArgument("treasury ledger discovery registry is required", "registry")
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
logger := cfg.Logger
if logger != nil {
logger = logger.Named("treasury_ledger_discovery")
}
return &discoveryClient{
logger: logger,
registry: cfg.Registry,
timeout: cfg.Timeout,
}, nil
}
func (c *discoveryClient) Close() error {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil {
err := c.client.Close()
c.client = nil
c.endpointKey = ""
return err
}
return nil
}
func (c *discoveryClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.GetAccount(ctx, accountID)
}
func (c *discoveryClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.GetBalance(ctx, accountID)
}
func (c *discoveryClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.ExternalCredit(ctx, req)
}
func (c *discoveryClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.ExternalDebit(ctx, req)
}
func (c *discoveryClient) resolveClient(_ context.Context) (Client, error) {
if c == nil || c.registry == nil {
return nil, merrors.Internal("treasury ledger discovery is unavailable")
}
endpoint, err := c.resolveEndpoint()
if err != nil {
return nil, err
}
key := endpoint.key()
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil && c.endpointKey == key {
return c.client, nil
}
if c.client != nil {
_ = c.client.Close()
c.client = nil
c.endpointKey = ""
}
next, err := New(Config{
Endpoint: endpoint.address,
Timeout: c.timeout,
Insecure: endpoint.insecure,
})
if err != nil {
return nil, err
}
c.client = next
c.endpointKey = key
if c.logger != nil {
c.logger.Info("Discovered ledger endpoint selected",
zap.String("service", string(mservice.Ledger)),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
return c.client, nil
}
func (c *discoveryClient) resolveEndpoint() (discoveryEndpoint, error) {
entries := c.registry.List(time.Now(), true)
type match struct {
entry discovery.RegistryEntry
opMatch bool
}
matches := make([]match, 0, len(entries))
requiredOps := discovery.LedgerServiceOperations()
for _, entry := range entries {
if !matchesService(entry.Service, mservice.Ledger) {
continue
}
matches = append(matches, match{
entry: entry,
opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps),
})
}
if len(matches) == 0 {
return discoveryEndpoint{}, merrors.NoData("discovery: ledger service unavailable")
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].opMatch != matches[j].opMatch {
return matches[i].opMatch
}
if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority {
return matches[i].entry.RoutingPriority > matches[j].entry.RoutingPriority
}
if matches[i].entry.ID != matches[j].entry.ID {
return matches[i].entry.ID < matches[j].entry.ID
}
return matches[i].entry.InstanceID < matches[j].entry.InstanceID
})
return parseDiscoveryEndpoint(matches[0].entry.InvokeURI)
}
func matchesService(service string, candidate mservice.Type) bool {
service = strings.TrimSpace(service)
if service == "" || strings.TrimSpace(string(candidate)) == "" {
return false
}
return strings.EqualFold(service, strings.TrimSpace(string(candidate)))
}
func parseDiscoveryEndpoint(raw string) (discoveryEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri is required")
}
if !strings.Contains(raw, "://") {
if _, _, splitErr := net.SplitHostPort(raw); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" {
if err != nil {
return discoveryEndpoint{}, err
}
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
switch scheme {
case "grpc":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: address, insecure: true, raw: raw}, nil
case "grpcs":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: address, insecure: false, raw: raw}, nil
case "dns", "passthrough":
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
default:
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: unsupported invoke uri scheme")
}
}

View File

@@ -0,0 +1,205 @@
package treasury
import (
"context"
"strings"
"time"
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/bot"
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger"
"github.com/tech/sendico/gateway/chsettle/storage"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type Module struct {
logger mlogger.Logger
service *Service
router *bot.Router
scheduler *Scheduler
ledger ledger.Client
}
func NewModule(
logger mlogger.Logger,
repo storage.TreasuryRequestsStore,
users storage.TreasuryTelegramUsersStore,
ledgerClient ledger.Client,
cfg Config,
send bot.SendTextFunc,
) (*Module, error) {
if logger != nil {
logger = logger.Named("treasury")
}
if users == nil {
return nil, merrors.InvalidArgument("treasury telegram users store is required", "users")
}
service, err := NewService(
logger,
repo,
ledgerClient,
cfg.ExecutionDelay,
cfg.MaxAmountPerOperation,
cfg.MaxDailyAmount,
)
if err != nil {
return nil, err
}
module := &Module{
logger: logger,
service: service,
ledger: ledgerClient,
}
module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval)
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, &botUsersAdapter{store: users})
return module, nil
}
func (m *Module) Enabled() bool {
return m != nil && m.router != nil && m.router.Enabled() && m.scheduler != nil
}
func (m *Module) Start() {
if m == nil || m.scheduler == nil {
return
}
m.scheduler.Start()
}
func (m *Module) Shutdown() {
if m == nil {
return
}
if m.scheduler != nil {
m.scheduler.Shutdown()
}
if m.ledger != nil {
_ = m.ledger.Close()
}
}
func (m *Module) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
if m == nil || m.router == nil {
return false
}
return m.router.HandleUpdate(ctx, update)
}
type botServiceAdapter struct {
svc *Service
}
type botUsersAdapter struct {
store storage.TreasuryTelegramUsersStore
}
func (a *botUsersAdapter) ResolveUserBinding(ctx context.Context, telegramUserID string) (*bot.UserBinding, error) {
if a == nil || a.store == nil {
return nil, merrors.Internal("treasury users store unavailable")
}
record, err := a.store.FindByTelegramUserID(ctx, telegramUserID)
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
return &bot.UserBinding{
TelegramUserID: strings.TrimSpace(record.TelegramUserID),
LedgerAccountID: strings.TrimSpace(record.LedgerAccountID),
AllowedChatIDs: normalizeChatIDs(record.AllowedChatIDs),
}, nil
}
func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) {
if a == nil || a.svc == nil {
return 0
}
return a.svc.ExecutionDelay()
}
func (a *botServiceAdapter) MaxPerOperationLimit() string {
if a == nil || a.svc == nil {
return ""
}
return a.svc.MaxPerOperationLimit()
}
func (a *botServiceAdapter) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return a.svc.GetActiveRequestForAccount(ctx, ledgerAccountID)
}
func (a *botServiceAdapter) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*bot.AccountProfile, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
profile, err := a.svc.GetAccountProfile(ctx, ledgerAccountID)
if err != nil {
return nil, err
}
if profile == nil {
return nil, nil
}
return &bot.AccountProfile{
AccountID: strings.TrimSpace(profile.AccountID),
AccountCode: strings.TrimSpace(profile.AccountCode),
Currency: strings.TrimSpace(profile.Currency),
}, nil
}
func (a *botServiceAdapter) CreateRequest(ctx context.Context, input bot.CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return a.svc.CreateRequest(ctx, CreateRequestInput{
OperationType: input.OperationType,
TelegramUserID: input.TelegramUserID,
LedgerAccountID: input.LedgerAccountID,
ChatID: input.ChatID,
Amount: input.Amount,
})
}
func (a *botServiceAdapter) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return a.svc.ConfirmRequest(ctx, requestID, telegramUserID)
}
func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
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

@@ -0,0 +1,327 @@
package treasury
import (
"context"
"strings"
"sync"
"time"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type NotifyFunc func(ctx context.Context, chatID string, text string) error
type Scheduler struct {
logger mlogger.Logger
service *Service
notify NotifyFunc
safetySweepInterval time.Duration
cancel context.CancelFunc
wg sync.WaitGroup
timersMu sync.Mutex
timers map[string]*time.Timer
}
func NewScheduler(logger mlogger.Logger, service *Service, notify NotifyFunc, safetySweepInterval time.Duration) *Scheduler {
if logger != nil {
logger = logger.Named("treasury_scheduler")
}
if safetySweepInterval <= 0 {
safetySweepInterval = 30 * time.Second
}
return &Scheduler{
logger: logger,
service: service,
notify: notify,
safetySweepInterval: safetySweepInterval,
timers: map[string]*time.Timer{},
}
}
func (s *Scheduler) Start() {
if s == nil || s.service == nil || s.cancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
// Rebuild in-memory timers from DB on startup.
s.hydrateTimers(ctx)
// Safety pass for overdue items at startup.
s.sweep(ctx)
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(s.safetySweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sweep(ctx)
}
}
}()
}
func (s *Scheduler) Shutdown() {
if s == nil || s.cancel == nil {
return
}
s.cancel()
s.wg.Wait()
s.timersMu.Lock()
for requestID, timer := range s.timers {
if timer != nil {
timer.Stop()
}
delete(s.timers, requestID)
}
s.timersMu.Unlock()
}
func (s *Scheduler) TrackScheduled(record *storagemodel.TreasuryRequest) {
if s == nil || s.service == nil || record == nil {
return
}
if strings.TrimSpace(record.RequestID) == "" {
return
}
if record.Status != storagemodel.TreasuryRequestStatusScheduled {
return
}
requestID := strings.TrimSpace(record.RequestID)
when := record.ScheduledAt
if when.IsZero() {
when = time.Now()
}
delay := time.Until(when)
if delay <= 0 {
s.Untrack(requestID)
go s.executeAndNotifyByID(context.Background(), requestID)
return
}
s.timersMu.Lock()
if existing := s.timers[requestID]; existing != nil {
existing.Stop()
}
s.timers[requestID] = time.AfterFunc(delay, func() {
s.Untrack(requestID)
s.executeAndNotifyByID(context.Background(), requestID)
})
s.timersMu.Unlock()
}
func (s *Scheduler) Untrack(requestID string) {
if s == nil {
return
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return
}
s.timersMu.Lock()
if timer := s.timers[requestID]; timer != nil {
timer.Stop()
}
delete(s.timers, requestID)
s.timersMu.Unlock()
}
func (s *Scheduler) hydrateTimers(ctx context.Context) {
if s == nil || s.service == nil {
return
}
scheduled, err := s.service.ScheduledRequests(ctx, 1000)
if err != nil {
s.logger.Warn("Failed to hydrate scheduled treasury requests", zap.Error(err))
return
}
for _, record := range scheduled {
s.TrackScheduled(&record)
}
}
func (s *Scheduler) sweep(ctx context.Context) {
if s == nil || s.service == nil {
return
}
now := time.Now()
confirmed, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
storagemodel.TreasuryRequestStatusConfirmed,
}, now, 100)
if err != nil {
s.logger.Warn("Failed to list confirmed treasury requests", zap.Error(err))
return
}
for _, request := range confirmed {
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
}
scheduled, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
storagemodel.TreasuryRequestStatusScheduled,
}, now, 100)
if err != nil {
s.logger.Warn("Failed to list scheduled treasury requests", zap.Error(err))
return
}
for _, request := range scheduled {
s.Untrack(strings.TrimSpace(request.RequestID))
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
}
}
func (s *Scheduler) executeAndNotifyByID(ctx context.Context, requestID string) {
if s == nil || s.service == nil {
return
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return
}
runCtx := ctx
if runCtx == nil {
runCtx = context.Background()
}
withTimeout, cancel := context.WithTimeout(runCtx, 30*time.Second)
defer cancel()
result, err := s.service.ExecuteRequest(withTimeout, requestID)
if err != nil {
s.logger.Warn("Failed to execute treasury request", zap.Error(err), zap.String("request_id", requestID))
return
}
if result == nil || result.Request == nil {
s.logger.Debug("Treasury execution produced no result", zap.String("request_id", requestID))
return
}
if s.notify == nil {
s.logger.Warn("Treasury execution notifier is unavailable", zap.String("request_id", requestID))
return
}
text := executionMessage(result)
if strings.TrimSpace(text) == "" {
s.logger.Debug("Treasury execution result has no notification text",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
return
}
chatID := strings.TrimSpace(result.Request.ChatID)
if chatID == "" {
s.logger.Warn("Treasury execution notification skipped: empty chat_id",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)))
return
}
s.logger.Info("Sending treasury execution notification",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
zap.String("chat_id", chatID),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
notifyCtx := context.Background()
if ctx != nil {
notifyCtx = ctx
}
notifyCtx, notifyCancel := context.WithTimeout(notifyCtx, 15*time.Second)
defer notifyCancel()
if err := s.notify(notifyCtx, chatID, text); err != nil {
s.logger.Warn("Failed to notify treasury execution result",
zap.Error(err),
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
zap.String("chat_id", chatID),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
return
}
s.logger.Info("Treasury execution notification sent",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
zap.String("chat_id", chatID),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
}
func executionMessage(result *ExecutionResult) string {
if result == nil || result.Request == nil {
return ""
}
request := result.Request
switch request.Status {
case storagemodel.TreasuryRequestStatusExecuted:
op := "Funding"
sign := "+"
if request.OperationType == storagemodel.TreasuryOperationWithdraw {
op = "Withdrawal"
sign = "-"
}
balanceAmount := "unavailable"
balanceCurrency := strings.TrimSpace(request.Currency)
if result.NewBalance != nil {
if strings.TrimSpace(result.NewBalance.Amount) != "" {
balanceAmount = strings.TrimSpace(result.NewBalance.Amount)
}
if strings.TrimSpace(result.NewBalance.Currency) != "" {
balanceCurrency = strings.TrimSpace(result.NewBalance.Currency)
}
}
return "*" + op + " completed*\n\n" +
"*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
"*Amount:* " + markdownCode(sign+strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
"*New balance:* " + markdownCode(balanceAmount+" "+balanceCurrency) + "\n\n" +
"*Reference:* " + markdownCode(strings.TrimSpace(request.RequestID))
case storagemodel.TreasuryRequestStatusFailed:
reason := strings.TrimSpace(request.ErrorMessage)
if reason == "" && result.ExecutionError != nil {
reason = strings.TrimSpace(result.ExecutionError.Error())
}
if reason == "" {
reason = "Unknown error."
}
return "*Execution failed*\n\n" +
"*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
"*Amount:* " + markdownCode(strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
"*Status:* " + markdownCode("FAILED") + "\n" +
"*Reason:* " + markdownCode(compactForMarkdown(reason)) + "\n\n" +
"*Request ID:* " + markdownCode(strings.TrimSpace(request.RequestID))
default:
return ""
}
}
func requestAccountCode(request *storagemodel.TreasuryRequest) string {
if request == nil {
return ""
}
if code := strings.TrimSpace(request.LedgerAccountCode); code != "" {
return code
}
return strings.TrimSpace(request.LedgerAccountID)
}
func markdownCode(value string) string {
value = strings.TrimSpace(value)
if value == "" {
value = "N/A"
}
value = strings.ReplaceAll(value, "`", "'")
return "`" + value + "`"
}
func compactForMarkdown(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "Unknown error."
}
value = strings.ReplaceAll(value, "\r\n", " ")
value = strings.ReplaceAll(value, "\n", " ")
value = strings.ReplaceAll(value, "\r", " ")
return strings.Join(strings.Fields(value), " ")
}

View File

@@ -0,0 +1,457 @@
package treasury
import (
"context"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/tech/sendico/gateway/chsettle/internal/service/treasury/ledger"
"github.com/tech/sendico/gateway/chsettle/storage"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
var ErrActiveTreasuryRequest = errors.New("active treasury request exists")
type CreateRequestInput struct {
OperationType storagemodel.TreasuryOperationType
TelegramUserID string
LedgerAccountID string
ChatID string
Amount string
}
type AccountProfile struct {
AccountID string
AccountCode string
Currency string
}
type ExecutionResult struct {
Request *storagemodel.TreasuryRequest
NewBalance *ledger.Balance
ExecutionError error
}
type Service struct {
logger mlogger.Logger
repo storage.TreasuryRequestsStore
ledger ledger.Client
validator *Validator
executionDelay time.Duration
}
func NewService(
logger mlogger.Logger,
repo storage.TreasuryRequestsStore,
ledgerClient ledger.Client,
executionDelay time.Duration,
maxPerOperation string,
maxDaily string,
) (*Service, error) {
if logger == nil {
return nil, merrors.InvalidArgument("logger is required", "logger")
}
if repo == nil {
return nil, merrors.InvalidArgument("treasury repository is required", "repo")
}
if ledgerClient == nil {
return nil, merrors.InvalidArgument("ledger client is required", "ledger_client")
}
if executionDelay <= 0 {
executionDelay = 30 * time.Second
}
validator, err := NewValidator(repo, maxPerOperation, maxDaily)
if err != nil {
return nil, err
}
return &Service{
logger: logger.Named("treasury_service"),
repo: repo,
ledger: ledgerClient,
validator: validator,
executionDelay: executionDelay,
}, nil
}
func (s *Service) ExecutionDelay() time.Duration {
if s == nil {
return 0
}
return s.executionDelay
}
func (s *Service) MaxPerOperationLimit() string {
if s == nil || s.validator == nil {
return ""
}
return s.validator.MaxPerOperation()
}
func (s *Service) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindActiveByLedgerAccountID(ctx, ledgerAccountID)
}
func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindByRequestID(ctx, requestID)
}
func (s *Service) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error) {
if s == nil || s.ledger == nil {
return nil, merrors.Internal("treasury service unavailable")
}
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
if ledgerAccountID == "" {
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
}
account, err := s.ledger.GetAccount(ctx, ledgerAccountID)
if err != nil {
return nil, err
}
if account == nil {
return nil, merrors.NoData("ledger account not found")
}
return &AccountProfile{
AccountID: ledgerAccountID,
AccountCode: resolveAccountCode(account, ledgerAccountID),
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
}, nil
}
func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil || s.ledger == nil || s.validator == nil {
return nil, merrors.Internal("treasury service unavailable")
}
input.TelegramUserID = strings.TrimSpace(input.TelegramUserID)
input.LedgerAccountID = strings.TrimSpace(input.LedgerAccountID)
input.ChatID = strings.TrimSpace(input.ChatID)
input.Amount = strings.TrimSpace(input.Amount)
switch input.OperationType {
case storagemodel.TreasuryOperationFund, storagemodel.TreasuryOperationWithdraw:
default:
return nil, merrors.InvalidArgument("treasury operation is invalid", "operation_type")
}
if input.TelegramUserID == "" {
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
}
if input.LedgerAccountID == "" {
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
}
if input.ChatID == "" {
return nil, merrors.InvalidArgument("chat_id is required", "chat_id")
}
active, err := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
if err != nil {
return nil, err
}
if active != nil {
return active, ErrActiveTreasuryRequest
}
amountRat, normalizedAmount, err := s.validator.ValidateAmount(input.Amount)
if err != nil {
return nil, err
}
if err := s.validator.ValidateDailyLimit(ctx, input.LedgerAccountID, amountRat, time.Now()); err != nil {
return nil, err
}
account, err := s.ledger.GetAccount(ctx, input.LedgerAccountID)
if err != nil {
return nil, err
}
if account == nil || strings.TrimSpace(account.Currency) == "" {
return nil, merrors.Internal("ledger account currency is unavailable")
}
if strings.TrimSpace(account.OrganizationRef) == "" {
return nil, merrors.Internal("ledger account organization is unavailable")
}
requestID := newRequestID()
record := &storagemodel.TreasuryRequest{
RequestID: requestID,
OperationType: input.OperationType,
TelegramUserID: input.TelegramUserID,
LedgerAccountID: input.LedgerAccountID,
LedgerAccountCode: resolveAccountCode(account, input.LedgerAccountID),
OrganizationRef: account.OrganizationRef,
ChatID: input.ChatID,
Amount: normalizedAmount,
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
Status: storagemodel.TreasuryRequestStatusCreated,
IdempotencyKey: fmt.Sprintf("chsettle:%s", requestID),
Active: true,
}
if err := s.repo.Create(ctx, record); err != nil {
if errors.Is(err, storage.ErrDuplicate) {
active, fetchErr := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
if fetchErr != nil {
return nil, fetchErr
}
if active != nil {
return active, ErrActiveTreasuryRequest
}
return nil, err
}
return nil, err
}
s.logRequest(record, "created", nil)
return record, nil
}
func (s *Service) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
requestID = strings.TrimSpace(requestID)
telegramUserID = strings.TrimSpace(telegramUserID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
record, err := s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, merrors.NoData("treasury request not found")
}
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
return nil, merrors.Unauthorized("treasury request ownership mismatch")
}
switch record.Status {
case storagemodel.TreasuryRequestStatusScheduled:
return record, nil
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed:
now := time.Now()
record.ConfirmedAt = now
record.ScheduledAt = now.Add(s.executionDelay)
record.Status = storagemodel.TreasuryRequestStatusScheduled
record.Active = true
record.ErrorMessage = ""
default:
return nil, merrors.InvalidArgument("treasury request cannot be confirmed in current status", "status")
}
if err := s.repo.Update(ctx, record); err != nil {
return nil, err
}
s.logRequest(record, "scheduled", nil)
return record, nil
}
func (s *Service) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
requestID = strings.TrimSpace(requestID)
telegramUserID = strings.TrimSpace(telegramUserID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
record, err := s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, merrors.NoData("treasury request not found")
}
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
return nil, merrors.Unauthorized("treasury request ownership mismatch")
}
switch record.Status {
case storagemodel.TreasuryRequestStatusCancelled:
return record, nil
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed, storagemodel.TreasuryRequestStatusScheduled:
record.Status = storagemodel.TreasuryRequestStatusCancelled
record.CancelledAt = time.Now()
record.Active = false
default:
return nil, merrors.InvalidArgument("treasury request cannot be cancelled in current status", "status")
}
if err := s.repo.Update(ctx, record); err != nil {
return nil, err
}
s.logRequest(record, "cancelled", nil)
return record, nil
}
func (s *Service) ExecuteRequest(ctx context.Context, requestID string) (*ExecutionResult, error) {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
record, err := s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
switch record.Status {
case storagemodel.TreasuryRequestStatusExecuted,
storagemodel.TreasuryRequestStatusCancelled,
storagemodel.TreasuryRequestStatusFailed:
return nil, nil
case storagemodel.TreasuryRequestStatusScheduled:
claimed, err := s.repo.ClaimScheduled(ctx, requestID)
if err != nil {
return nil, err
}
if !claimed {
return nil, nil
}
record, err = s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
}
if record.Status != storagemodel.TreasuryRequestStatusConfirmed {
return nil, nil
}
return s.executeClaimed(ctx, record)
}
func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.TreasuryRequest) (*ExecutionResult, error) {
if record == nil {
return nil, merrors.InvalidArgument("treasury request is required", "request")
}
postReq := ledger.PostRequest{
AccountID: record.LedgerAccountID,
OrganizationRef: record.OrganizationRef,
Amount: record.Amount,
Currency: record.Currency,
Reference: record.RequestID,
IdempotencyKey: record.IdempotencyKey,
}
var (
opResult *ledger.OperationResult
err error
)
switch record.OperationType {
case storagemodel.TreasuryOperationFund:
opResult, err = s.ledger.ExternalCredit(ctx, postReq)
case storagemodel.TreasuryOperationWithdraw:
opResult, err = s.ledger.ExternalDebit(ctx, postReq)
default:
err = merrors.InvalidArgument("treasury operation is invalid", "operation_type")
}
now := time.Now()
if err != nil {
record.Status = storagemodel.TreasuryRequestStatusFailed
record.Active = false
record.ExecutedAt = now
record.ErrorMessage = strings.TrimSpace(err.Error())
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
return nil, saveErr
}
s.logRequest(record, "failed", err)
return &ExecutionResult{
Request: record,
ExecutionError: err,
}, nil
}
if opResult != nil {
record.LedgerReference = strings.TrimSpace(opResult.Reference)
}
record.Status = storagemodel.TreasuryRequestStatusExecuted
record.Active = false
record.ExecutedAt = now
record.ErrorMessage = ""
balance, balanceErr := s.ledger.GetBalance(ctx, record.LedgerAccountID)
if balanceErr != nil {
record.ErrorMessage = strings.TrimSpace(balanceErr.Error())
}
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
return nil, saveErr
}
s.logRequest(record, "executed", nil)
return &ExecutionResult{
Request: record,
NewBalance: balance,
ExecutionError: balanceErr,
}, nil
}
func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindDueByStatus(ctx, statuses, now, limit)
}
func (s *Service) ScheduledRequests(ctx context.Context, limit int64) ([]storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindDueByStatus(
ctx,
[]storagemodel.TreasuryRequestStatus{storagemodel.TreasuryRequestStatusScheduled},
time.Now().Add(10*365*24*time.Hour),
limit,
)
}
func (s *Service) ParseAmount(value string) (*big.Rat, error) {
return parseAmountRat(value)
}
func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string, err error) {
if s == nil || s.logger == nil || record == nil {
return
}
fields := []zap.Field{
zap.String("request_id", strings.TrimSpace(record.RequestID)),
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
zap.String("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)),
zap.String("chat_id", strings.TrimSpace(record.ChatID)),
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
zap.String("amount", strings.TrimSpace(record.Amount)),
zap.String("currency", strings.TrimSpace(record.Currency)),
zap.String("status", status),
zap.String("ledger_reference", strings.TrimSpace(record.LedgerReference)),
zap.String("error_message", strings.TrimSpace(record.ErrorMessage)),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
s.logger.Info("treasury_request", fields...)
}
func newRequestID() string {
return "TG-TREASURY-" + strings.ToUpper(bson.NewObjectID().Hex())
}
func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string {
if account != nil {
if code := strings.TrimSpace(account.AccountCode); code != "" {
return code
}
if code := strings.TrimSpace(account.AccountID); code != "" {
return code
}
}
return strings.TrimSpace(fallbackAccountID)
}

View File

@@ -0,0 +1,178 @@
package treasury
import (
"context"
"math/big"
"regexp"
"strings"
"time"
"github.com/tech/sendico/gateway/chsettle/storage"
storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
var treasuryAmountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
type LimitKind string
const (
LimitKindPerOperation LimitKind = "per_operation"
LimitKindDaily LimitKind = "daily"
)
type LimitError struct {
Kind LimitKind
Max string
}
func (e *LimitError) Error() string {
if e == nil {
return "limit exceeded"
}
switch e.Kind {
case LimitKindPerOperation:
return "max amount per operation exceeded"
case LimitKindDaily:
return "max daily amount exceeded"
default:
return "limit exceeded"
}
}
func (e *LimitError) LimitKind() string {
if e == nil {
return ""
}
return string(e.Kind)
}
func (e *LimitError) LimitMax() string {
if e == nil {
return ""
}
return e.Max
}
type Validator struct {
repo storage.TreasuryRequestsStore
maxPerOperation *big.Rat
maxDaily *big.Rat
maxPerOperationRaw string
maxDailyRaw string
}
func NewValidator(repo storage.TreasuryRequestsStore, maxPerOperation string, maxDaily string) (*Validator, error) {
validator := &Validator{
repo: repo,
maxPerOperationRaw: strings.TrimSpace(maxPerOperation),
maxDailyRaw: strings.TrimSpace(maxDaily),
}
if validator.maxPerOperationRaw != "" {
value, err := parseAmountRat(validator.maxPerOperationRaw)
if err != nil {
return nil, merrors.InvalidArgument("treasury max_amount_per_operation is invalid", "treasury.limits.max_amount_per_operation")
}
validator.maxPerOperation = value
}
if validator.maxDailyRaw != "" {
value, err := parseAmountRat(validator.maxDailyRaw)
if err != nil {
return nil, merrors.InvalidArgument("treasury max_daily_amount is invalid", "treasury.limits.max_daily_amount")
}
validator.maxDaily = value
}
return validator, nil
}
func (v *Validator) MaxPerOperation() string {
if v == nil {
return ""
}
return v.maxPerOperationRaw
}
func (v *Validator) MaxDaily() string {
if v == nil {
return ""
}
return v.maxDailyRaw
}
func (v *Validator) ValidateAmount(amount string) (*big.Rat, string, error) {
amount = strings.TrimSpace(amount)
value, err := parseAmountRat(amount)
if err != nil {
return nil, "", err
}
if v != nil && v.maxPerOperation != nil && value.Cmp(v.maxPerOperation) > 0 {
return nil, "", &LimitError{
Kind: LimitKindPerOperation,
Max: v.maxPerOperationRaw,
}
}
return value, amount, nil
}
func (v *Validator) ValidateDailyLimit(ctx context.Context, ledgerAccountID string, amount *big.Rat, now time.Time) error {
if v == nil || v.maxDaily == nil || v.repo == nil {
return nil
}
if amount == nil {
return merrors.InvalidArgument("amount is required", "amount")
}
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
dayEnd := dayStart.Add(24 * time.Hour)
records, err := v.repo.ListByAccountAndStatuses(
ctx,
ledgerAccountID,
[]storagemodel.TreasuryRequestStatus{
storagemodel.TreasuryRequestStatusCreated,
storagemodel.TreasuryRequestStatusConfirmed,
storagemodel.TreasuryRequestStatusScheduled,
storagemodel.TreasuryRequestStatusExecuted,
},
dayStart,
dayEnd,
)
if err != nil {
return err
}
total := new(big.Rat)
for _, record := range records {
next, err := parseAmountRat(record.Amount)
if err != nil {
return merrors.Internal("treasury request amount is invalid")
}
total.Add(total, next)
}
total.Add(total, amount)
if total.Cmp(v.maxDaily) > 0 {
return &LimitError{
Kind: LimitKindDaily,
Max: v.maxDailyRaw,
}
}
return nil
}
func parseAmountRat(value string) (*big.Rat, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, merrors.InvalidArgument("amount is required", "amount")
}
if !treasuryAmountPattern.MatchString(value) {
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
}
amount := new(big.Rat)
if _, ok := amount.SetString(value); !ok {
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
}
if amount.Sign() <= 0 {
return nil, merrors.InvalidArgument("amount must be positive", "amount")
}
return amount, nil
}