package main import ( "errors" "fmt" "strconv" "strings" "time" "gorm.io/gorm" ) var ( errInsufficientCredits = errors.New("insufficient credits") errReservationState = errors.New("invalid credit reservation state") ) // reserveCredit places one credit on hold for a task. The hold is released on // failure or converted into a debit after a successful new download. func reserveCreditTx(tx *gorm.DB, userID, taskID uint, amount int64) (*CreditReservation, error) { if amount <= 0 { return nil, fmt.Errorf("invalid credit amount") } var account CreditAccount if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } account = CreditAccount{UserID: userID} if err := tx.Create(&account).Error; err != nil { if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil { return nil, err } } } var existing CreditReservation if err := tx.Where("task_id = ? AND status = ?", taskID, "reserved"). Order("id desc").First(&existing).Error; err == nil { return &existing, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if account.Balance-account.Reserved < amount { return nil, errInsufficientCredits } account.Reserved += amount if err := tx.Save(&account).Error; err != nil { return nil, err } reservation := &CreditReservation{ UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved", } if err := tx.Create(reservation).Error; err != nil { return nil, err } if err := tx.Create(&CreditTransaction{ UserID: userID, Type: "reserve", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(taskID), 10), Reason: "download reservation", }).Error; err != nil { return nil, err } return reservation, nil } func isSQLiteBusy(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "sqlite_busy") || strings.Contains(msg, "database is locked") } func (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) { var reservation *CreditReservation err := a.transactionRetry(func(tx *gorm.DB) error { var txErr error reservation, txErr = reserveCreditTx(tx, userID, taskID, amount) return txErr }) return reservation, err } func (a *App) commitCredit(task *Task) error { if task.CreditReservationID == 0 { return nil } return a.transactionRetry(func(tx *gorm.DB) error { var r CreditReservation if err := tx.First(&r, task.CreditReservationID).Error; err != nil { return err } if r.Status == "committed" { return nil } if r.Status != "reserved" { return errReservationState } var account CreditAccount if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil { return err } account.Reserved -= r.Amount account.Balance -= r.Amount if account.Reserved < 0 || account.Balance < 0 { return errReservationState } if err := tx.Save(&account).Error; err != nil { return err } now := time.Now() r.Status, r.CompletedAt = "committed", &now if err := tx.Save(&r).Error; err != nil { return err } return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "debit", Amount: -r.Amount, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "successful download"}).Error }) } // refundCredit releases a reservation without debiting the user's balance. // It is idempotent so download failure paths can safely call fail repeatedly. func (a *App) refundCredit(task *Task) error { if task.CreditReservationID == 0 { return nil } return a.transactionRetry(func(tx *gorm.DB) error { var r CreditReservation if err := tx.First(&r, task.CreditReservationID).Error; err != nil { return err } if r.Status == "refunded" { return nil } if r.Status == "committed" { // A committed reservation belongs to a completed download. A later // storage/retry error must never debit or refund it a second time. return nil } if r.Status != "reserved" { return errReservationState } var account CreditAccount if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil { return err } account.Reserved -= r.Amount if account.Reserved < 0 { return errReservationState } if err := tx.Save(&account).Error; err != nil { return err } now := time.Now() r.Status, r.CompletedAt = "refunded", &now if err := tx.Save(&r).Error; err != nil { return err } return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "refund", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "download failed"}).Error }) } func (a *App) creditBalance(userID uint) (CreditAccount, error) { var account CreditAccount err := a.db.Where("user_id = ?", userID).First(&account).Error return account, err }