Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ data/*
LightningTipBot
LightningTipBot.exe
BitcoinDeepaBot
test_pay_api.sh
test_pay_api.sh

.DS_Store
12 changes: 12 additions & 0 deletions internal/lnbits/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,15 @@ type SavingsPot struct {
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
User *User `gorm:"foreignKey:UserID;references:ID"`
}

type StandingOrder struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"user_id" gorm:"index"`
PotName string `json:"pot_name"`
DayOfMonth int `json:"day_of_month"`
Amount int64 `json:"amount"`
Active bool `json:"active" gorm:"default:true"`
LastExecutedAt *time.Time `json:"last_executed_at"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
3 changes: 3 additions & 0 deletions internal/telegram/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ func (bot *TipBot) Start() {
// register telegram handlers
bot.registerTelegramHandlers()

// start standing order scheduler
NewStandingOrderScheduler(bot).Start()

// download bot avatar once
bot.downloadMyProfilePicture()

Expand Down
4 changes: 4 additions & 0 deletions internal/telegram/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ func AutoMigration() *Databases {
if err != nil {
panic(err)
}
err = orm.AutoMigrate(&lnbits.StandingOrder{})
if err != nil {
panic(err)
}

txLogger, err := gorm.Open(sqlite.Open(internal.Configuration.Database.TransactionsPath), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true, FullSaveAssociations: true})
if err != nil {
Expand Down
16 changes: 16 additions & 0 deletions internal/telegram/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,22 @@ func (bot TipBot) getHandler() []InterceptionWrapper {
},
},
},
{
Endpoints: []interface{}{"/so"},
Handler: bot.soHandler,
Interceptor: &Interceptor{
Before: []intercept.Func{
bot.requirePrivateChatInterceptor,
bot.localizerInterceptor,
bot.logMessageInterceptor,
bot.requireUserInterceptor,
bot.lockInterceptor,
},
OnDefer: []intercept.Func{
bot.unlockInterceptor,
},
},
},
{
Endpoints: []interface{}{"/send", &btnSendMenuEnter},
Handler: bot.sendHandler,
Expand Down
127 changes: 127 additions & 0 deletions internal/telegram/standing_order_scheduler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package telegram

import (
"fmt"
"time"

"github.com/LightningTipBot/LightningTipBot/internal/lnbits"
"github.com/LightningTipBot/LightningTipBot/internal/utils"
log "github.com/sirupsen/logrus"
)

// StandingOrderScheduler runs hourly and executes standing orders on the
// configured day of month, clamping days 29–31 to the last day of short months.
type StandingOrderScheduler struct {
bot *TipBot
CheckInterval time.Duration
}

func NewStandingOrderScheduler(bot *TipBot) *StandingOrderScheduler {
return &StandingOrderScheduler{
bot: bot,
CheckInterval: 1 * time.Hour,
}
}

func (s *StandingOrderScheduler) Start() {
go s.run()
}

func (s *StandingOrderScheduler) run() {
for {
s.processDueOrders()
time.Sleep(s.CheckInterval)
}
}

// effectiveDayForMonth returns the day the order should fire in the given month.
// If the configured day exceeds the month's last day (e.g. day 31 in April),
// it clamps to the last day so salary-day users are never skipped.
func effectiveDayForMonth(configuredDay int, t time.Time) int {
// time.Date with day=0 of next month gives last day of current month
lastDay := time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, t.Location()).Day()
if configuredDay > lastDay {
return lastDay
}
return configuredDay
}

// shouldExecuteToday returns true if the order has not already run today.
// Protects against double-execution when the bot restarts mid-day.
func shouldExecuteToday(order lnbits.StandingOrder, now time.Time) bool {
if order.LastExecutedAt == nil {
return true
}
last := *order.LastExecutedAt
return last.Year() != now.Year() || last.Month() != now.Month() || last.Day() != now.Day()
}

func (s *StandingOrderScheduler) processDueOrders() {
now := time.Now()
today := now.Day()

var orders []lnbits.StandingOrder
if err := s.bot.DB.Users.Where("active = true").Find(&orders).Error; err != nil {
log.Errorf("[StandingOrderScheduler] Failed to fetch orders: %v", err)
return
}

for _, order := range orders {
if effectiveDayForMonth(order.DayOfMonth, now) != today {
continue
}
if !shouldExecuteToday(order, now) {
continue
}

// Load the user
var user lnbits.User
if err := s.bot.DB.Users.Where("id = ?", order.UserID).First(&user).Error; err != nil {
log.Errorf("[StandingOrderScheduler] User not found for order %s: %v", order.ID, err)
continue
}

// Skip banned or wallet-less users silently
if user.Banned || user.Wallet == nil {
continue
}

if err := s.executeOrder(&order, &user); err != nil {
s.notifyFailure(&user, order, err)
} else {
s.notifySuccess(&user, order)
}
}
}

func (s *StandingOrderScheduler) executeOrder(order *lnbits.StandingOrder, user *lnbits.User) error {
if err := s.bot.TransferToPot(user, order.PotName, order.Amount); err != nil {
return err
}

now := time.Now()
order.LastExecutedAt = &now
if err := s.bot.DB.Users.Save(order).Error; err != nil {
log.Errorf("[StandingOrderScheduler] Failed to update LastExecutedAt for order %s: %v", order.ID, err)
}
Comment on lines +97 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the TransferToPot function in pots.go
fd "pots.go" internal/telegram

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 92


🏁 Script executed:

# Get the executeOrder method to see the full context
sed -n '97,106p' internal/telegram/standing_order_scheduler.go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 479


🏁 Script executed:

# Check the TransferToPot function implementation
sed -n '95,131p' internal/telegram/pots.go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 1374


🏁 Script executed:

# Check the standing order deletion logic mentioned
sed -n '71,80p' internal/telegram/standing_orders.go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 401


🏁 Script executed:

# Search for any database transaction patterns in the codebase
rg -A 5 "\.WithTxn\|\.Transaction\|BeginTx" internal/telegram --type go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 49


🏁 Script executed:

# Check the Save method implementation to understand its behavior
rg -B 5 -A 10 "func.*Save.*order" internal/telegram --type go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 49


🏁 Script executed:

# Find where executeOrder is called
rg -B 3 -A 3 "executeOrder" internal/telegram --type go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 1072


🏁 Script executed:

# Check the scheduler logic to understand execution frequency and retry behavior
rg -B 10 -A 10 "hourly\|cron\|ticker" internal/telegram/standing_order_scheduler.go --type go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 49


🏁 Script executed:

# Check if there's any wrapping transaction in the scheduler
sed -n '1,150p' internal/telegram/standing_order_scheduler.go | head -80

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 2439


🏁 Script executed:

# Look at the full standing_order_scheduler.go to understand the context
wc -l internal/telegram/standing_order_scheduler.go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 116


🏁 Script executed:

# Get the full standing_order_scheduler.go to see the complete flow
cat -n internal/telegram/standing_order_scheduler.go | sed -n '50,127p'

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 3243


🏁 Script executed:

# Check what the Save method does - search for the database model and Save implementation
rg -B 2 -A 15 "func.*\(.*gorm\.DB.*\).*Save" internal --type go | head -50

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 49


🏁 Script executed:

# Check if there are any other places where standing orders are deleted or updated
rg "StandingOrder" internal/telegram --type go -l

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 204


🏁 Script executed:

# Check the actual risk: does Save overwrite with stale data?
rg -B 5 -A 10 "\.Save\(" internal/telegram --type go | grep -A 10 "order"

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 1924


🏁 Script executed:

# Check GORM Save behavior - look for how it handles deleted records
rg -B 5 -A 15 "type StandingOrder struct" internal/lnbits --type go

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 1192


🏁 Script executed:

# Check if there's a primary key or how Save identifies records
rg "ID.*primary\|gorm.*tag" internal/lnbits/models.go --type go | head -20

Repository: CeyLabs/BitcoinDeepaBot

Length of output: 132


Wrap transfer and execution state in a single transaction.

The transfer completes successfully, but if Save(order) fails, the LastExecutedAt timestamp is never persisted—only logged. Since processDueOrders() reads fresh orders from the database each hour and shouldExecuteToday() checks the database LastExecutedAt, a failed save causes the same order to re-execute on the next hourly check, triggering a duplicate transfer. Additionally, GORM's Save() will INSERT a new record if the primary key is missing; if the order is concurrently deleted, Save() can recreate it. Both the transfer and timestamp update must be atomic, either within a transaction wrapping both operations or via an UPDATE-only approach that fails atomically.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/telegram/standing_order_scheduler.go` around lines 97 - 106,
executeOrder currently calls s.bot.TransferToPot then updates
order.LastExecutedAt with s.bot.DB.Users.Save(order), which can fail and cause
duplicate transfers or unintended INSERTs; wrap the transfer plus timestamp
update in a single atomic DB transaction (or perform an UPDATE-only statement)
so both succeed or both roll back: start a transaction on s.bot.DB.Users (or
higher-level DB handle), set LastExecutedAt, call s.bot.TransferToPot inside
that transaction, and then perform an UPDATE that only updates the existing
record (not Save) to persist LastExecutedAt (or rollback and return error if any
step fails); key symbols to modify: StandingOrderScheduler.executeOrder,
s.bot.TransferToPot, s.bot.DB.Users.Save (replace with tx and UPDATE-only), and
ensure processDueOrders/shouldExecuteToday semantics remain based on the
DB-updated LastExecutedAt.

return nil
}

func (s *StandingOrderScheduler) notifySuccess(user *lnbits.User, order lnbits.StandingOrder) {
log.Infof("[StandingOrderScheduler] Executed order %s for user %s: %s → pot '%s'",
order.ID, user.Name, utils.FormatSats(order.Amount), order.PotName)
msg := fmt.Sprintf(
"✅ *Standing Order Executed*\n\n📅 Day %d of month\n💰 *%s* transferred to pot *'%s'*",
order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName,
)
s.bot.trySendMessage(user.Telegram, msg)
}

func (s *StandingOrderScheduler) notifyFailure(user *lnbits.User, order lnbits.StandingOrder, err error) {
log.Errorf("[StandingOrderScheduler] Failed to execute order %s for user %s: %v", order.ID, user.Name, err)
msg := fmt.Sprintf(
"⚠️ *Standing Order Failed*\n\n📅 Day %d of month\n💰 %s → pot *'%s'*\n\n🚫 Reason: %s",
order.DayOfMonth, utils.FormatSats(order.Amount), order.PotName, err.Error(),
)
s.bot.trySendMessage(user.Telegram, msg)
}
Loading
Loading