improved tgsettle messages + storage fixes
This commit is contained in:
@@ -55,8 +55,9 @@ func (s *Service) sweepExpiredConfirmations(ctx context.Context) {
|
||||
s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, pending := range expired {
|
||||
if pending == nil || strings.TrimSpace(pending.RequestID) == "" {
|
||||
for i := range expired {
|
||||
pending := &expired[i]
|
||||
if strings.TrimSpace(pending.RequestID) == "" {
|
||||
continue
|
||||
}
|
||||
result := &model.ConfirmationResult{
|
||||
|
||||
@@ -162,19 +162,18 @@ func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]*storagemodel.PendingConfirmation, error) {
|
||||
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]storagemodel.PendingConfirmation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
result := make([]*storagemodel.PendingConfirmation, 0)
|
||||
result := make([]storagemodel.PendingConfirmation, 0)
|
||||
for _, record := range f.records {
|
||||
if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
cp := *record
|
||||
result = append(result, &cp)
|
||||
result = append(result, *record)
|
||||
if int64(len(result)) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type Command string
|
||||
|
||||
const (
|
||||
CommandStart Command = "start"
|
||||
CommandHelp Command = "help"
|
||||
CommandFund Command = "fund"
|
||||
CommandWithdraw Command = "withdraw"
|
||||
CommandConfirm Command = "confirm"
|
||||
@@ -14,6 +15,7 @@ const (
|
||||
|
||||
var supportedCommands = []Command{
|
||||
CommandStart,
|
||||
CommandHelp,
|
||||
CommandFund,
|
||||
CommandWithdraw,
|
||||
CommandConfirm,
|
||||
@@ -56,3 +58,29 @@ func supportedCommandsMessage() string {
|
||||
func confirmationCommandsMessage() string {
|
||||
return "Confirm operation?\n\n" + CommandConfirm.Slash() + "\n" + CommandCancel.Slash()
|
||||
}
|
||||
|
||||
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: " + accountCode + " (" + currency + ")",
|
||||
"",
|
||||
"How to use:",
|
||||
"1) Start funding with " + CommandFund.Slash() + " or withdrawal with " + CommandWithdraw.Slash(),
|
||||
"2) Enter amount as decimal, dot separator, no currency (example: 1250.75)",
|
||||
"3) Confirm with " + CommandConfirm.Slash() + " or abort with " + CommandCancel.Slash(),
|
||||
"",
|
||||
"After confirmation there is a cooldown window. You can cancel during it with " + CommandCancel.Slash() + ".",
|
||||
"You will receive a follow-up message with execution success or failure.",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
const unauthorizedMessage = "Sorry, your Telegram account is not authorized to perform treasury operations."
|
||||
const unauthorizedChatMessage = "Sorry, this Telegram chat is not authorized to perform treasury operations."
|
||||
|
||||
var welcomeMessage = "Welcome to tgsettle treasury bot.\n\nUse " + CommandFund.Slash() + " to credit your account and " + CommandWithdraw.Slash() + " to debit it.\nAfter entering an amount, use " + CommandConfirm.Slash() + " or " + CommandCancel.Slash() + "."
|
||||
const amountInputHint = "Enter 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
|
||||
|
||||
@@ -26,6 +26,12 @@ type ScheduleTracker interface {
|
||||
Untrack(requestID string)
|
||||
}
|
||||
|
||||
type AccountProfile struct {
|
||||
AccountID string
|
||||
AccountCode string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
@@ -39,6 +45,7 @@ type TreasuryService interface {
|
||||
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)
|
||||
@@ -119,6 +126,18 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
if !r.allowAnyChat {
|
||||
if _, ok := r.allowedChats[chatID]; !ok {
|
||||
r.logUnauthorized(update)
|
||||
@@ -134,21 +153,49 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
|
||||
return true
|
||||
}
|
||||
|
||||
command := parseCommand(text)
|
||||
switch command {
|
||||
case CommandStart:
|
||||
_ = r.sendText(ctx, chatID, welcomeMessage)
|
||||
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
|
||||
}
|
||||
@@ -182,7 +229,10 @@ func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhook
|
||||
func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err != 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))
|
||||
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, "Unable to check pending treasury operations right now. Please try again.")
|
||||
return
|
||||
}
|
||||
if active != nil {
|
||||
@@ -199,7 +249,8 @@ func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatI
|
||||
OperationType: operation,
|
||||
LedgerAccountID: accountID,
|
||||
})
|
||||
_ = r.sendText(ctx, chatID, "Enter amount:")
|
||||
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) {
|
||||
@@ -231,7 +282,7 @@ func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID st
|
||||
}
|
||||
}
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
_ = r.sendText(ctx, chatID, "Invalid amount.\n\nEnter another amount or "+CommandCancel.Slash())
|
||||
_ = r.sendText(ctx, chatID, "Invalid amount.\n\n"+amountInputHint+"\n\nEnter another amount or "+CommandCancel.Slash())
|
||||
return
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash())
|
||||
@@ -276,7 +327,7 @@ func (r *Router) confirm(ctx context.Context, userID string, accountID string, c
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
_ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\nYou can cancel during this cooldown with "+CommandCancel.Slash()+".\n\nYou will receive a follow-up message with execution success or failure.\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
}
|
||||
|
||||
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
@@ -315,7 +366,16 @@ func (r *Router) sendText(ctx context.Context, chatID string, text string) error
|
||||
if chatID == "" || text == "" {
|
||||
return nil
|
||||
}
|
||||
return r.send(ctx, chatID, text)
|
||||
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) {
|
||||
@@ -337,6 +397,7 @@ func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
|
||||
return "You already have a pending treasury operation.\n\n" + CommandCancel.Slash()
|
||||
}
|
||||
return "You already have a pending treasury operation.\n\n" +
|
||||
"Account: " + requestAccountDisplay(record) + "\n" +
|
||||
"Request ID: " + strings.TrimSpace(record.RequestID) + "\n" +
|
||||
"Status: " + strings.TrimSpace(string(record.Status)) + "\n" +
|
||||
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
|
||||
@@ -352,11 +413,89 @@ func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
|
||||
title = "Withdrawal request created."
|
||||
}
|
||||
return title + "\n\n" +
|
||||
"Account: " + strings.TrimSpace(record.LedgerAccountID) + "\n" +
|
||||
"Account: " + requestAccountDisplay(record) + "\n" +
|
||||
"Amount: " + 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 "Welcome to Sendico treasury bot.\n\nAttached account: " + accountCode + " (" + currency + ").\nUse " + CommandFund.Slash() + " to credit your account and " + CommandWithdraw.Slash() + " to debit it.\nAfter entering an amount, use " + CommandConfirm.Slash() + " or " + CommandCancel.Slash() + ".\nUse " + CommandHelp.Slash() + " for detailed usage."
|
||||
}
|
||||
|
||||
func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string {
|
||||
action := "fund"
|
||||
if operation == storagemodel.TreasuryOperationWithdraw {
|
||||
action = "withdraw"
|
||||
}
|
||||
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 "Preparing to " + action + " account " + accountCode + " (" + 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 formatSeconds(value int64) string {
|
||||
if value == 1 {
|
||||
return "1 second"
|
||||
|
||||
@@ -24,6 +24,14 @@ func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storage
|
||||
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
|
||||
}
|
||||
@@ -124,7 +132,11 @@ func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != "Enter amount:" {
|
||||
if sent[0] != amountPromptMessage(
|
||||
storagemodel.TreasuryOperationFund,
|
||||
&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"},
|
||||
"acct-1",
|
||||
) {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
@@ -186,7 +198,38 @@ func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != welcomeMessage {
|
||||
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,
|
||||
nil,
|
||||
map[string]string{"123": "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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type Config struct {
|
||||
|
||||
type Account struct {
|
||||
AccountID string
|
||||
AccountCode string
|
||||
Currency string
|
||||
OrganizationRef string
|
||||
}
|
||||
@@ -130,14 +131,20 @@ func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Ac
|
||||
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 {
|
||||
if value, ok := account.GetProviderDetails().AsMap()["organization_ref"]; ok {
|
||||
organizationRef = strings.TrimSpace(fmt.Sprint(value))
|
||||
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
|
||||
@@ -285,3 +292,21 @@ func normalizeEndpoint(raw string) (string, bool) {
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -120,6 +120,24 @@ func (a *botServiceAdapter) GetActiveRequestForAccount(ctx context.Context, ledg
|
||||
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")
|
||||
|
||||
@@ -145,7 +145,7 @@ func (s *Scheduler) hydrateTimers(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
for _, record := range scheduled {
|
||||
s.TrackScheduled(record)
|
||||
s.TrackScheduled(&record)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,17 +200,53 @@ func (s *Scheduler) executeAndNotifyByID(ctx context.Context, requestID string)
|
||||
s.logger.Warn("Failed to execute treasury request", zap.Error(err), zap.String("request_id", requestID))
|
||||
return
|
||||
}
|
||||
if result == nil || result.Request == nil || s.notify == nil {
|
||||
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
|
||||
}
|
||||
if err := s.notify(ctx, strings.TrimSpace(result.Request.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)))
|
||||
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 {
|
||||
@@ -237,7 +273,7 @@ func executionMessage(result *ExecutionResult) string {
|
||||
}
|
||||
}
|
||||
return op + " completed.\n\n" +
|
||||
"Account: " + strings.TrimSpace(request.LedgerAccountID) + "\n" +
|
||||
"Account: " + requestAccountCode(request) + "\n" +
|
||||
"Amount: " + sign + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" +
|
||||
"New balance: " + balanceAmount + " " + balanceCurrency + "\n\n" +
|
||||
"Reference: " + strings.TrimSpace(request.RequestID)
|
||||
@@ -250,7 +286,7 @@ func executionMessage(result *ExecutionResult) string {
|
||||
reason = "Unknown error."
|
||||
}
|
||||
return "Execution failed.\n\n" +
|
||||
"Account: " + strings.TrimSpace(request.LedgerAccountID) + "\n" +
|
||||
"Account: " + requestAccountCode(request) + "\n" +
|
||||
"Amount: " + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" +
|
||||
"Status: FAILED\n\n" +
|
||||
"Reason:\n" + reason + "\n\n" +
|
||||
@@ -259,3 +295,13 @@ func executionMessage(result *ExecutionResult) string {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ type CreateRequestInput struct {
|
||||
Amount string
|
||||
}
|
||||
|
||||
type AccountProfile struct {
|
||||
AccountID string
|
||||
AccountCode string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type ExecutionResult struct {
|
||||
Request *storagemodel.TreasuryRequest
|
||||
NewBalance *ledger.Balance
|
||||
@@ -103,6 +109,29 @@ func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemod
|
||||
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")
|
||||
@@ -156,17 +185,18 @@ func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (
|
||||
|
||||
requestID := newRequestID()
|
||||
record := &storagemodel.TreasuryRequest{
|
||||
RequestID: requestID,
|
||||
OperationType: input.OperationType,
|
||||
TelegramUserID: input.TelegramUserID,
|
||||
LedgerAccountID: input.LedgerAccountID,
|
||||
OrganizationRef: account.OrganizationRef,
|
||||
ChatID: input.ChatID,
|
||||
Amount: normalizedAmount,
|
||||
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
|
||||
Status: storagemodel.TreasuryRequestStatusCreated,
|
||||
IdempotencyKey: fmt.Sprintf("tgsettle:%s", requestID),
|
||||
Active: true,
|
||||
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("tgsettle:%s", requestID),
|
||||
Active: true,
|
||||
}
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicate) {
|
||||
@@ -364,14 +394,14 @@ func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.Treas
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]*storagemodel.TreasuryRequest, error) {
|
||||
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) {
|
||||
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")
|
||||
}
|
||||
@@ -395,10 +425,14 @@ func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string
|
||||
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))
|
||||
@@ -409,3 +443,15 @@ func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string
|
||||
func newRequestID() string {
|
||||
return "TGSETTLE-" + strings.ToUpper(bson.NewObjectID().Hex()[:8])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -143,9 +143,6 @@ func (v *Validator) ValidateDailyLimit(ctx context.Context, ledgerAccountID stri
|
||||
}
|
||||
total := new(big.Rat)
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
next, err := parseAmountRat(record.Amount)
|
||||
if err != nil {
|
||||
return merrors.Internal("treasury request amount is invalid")
|
||||
|
||||
Reference in New Issue
Block a user