package main import ( "errors" "fmt" "strconv" "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 (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) { if amount <= 0 { return nil, fmt.Errorf("invalid credit amount") } var reservation CreditReservation err := a.db.Transaction(func(tx *gorm.DB) error { var account CreditAccount if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return err } account = CreditAccount{UserID: userID} if err := tx.Create(&account).Error; err != nil { return err } } var existing CreditReservation if err := tx.Where("task_id = ? AND status = ?", taskID, "reserved").Order("id desc").First(&existing).Error; err == nil { reservation = existing return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } if account.Balance-account.Reserved < amount { return errInsufficientCredits } account.Reserved += amount if err := tx.Save(&account).Error; err != nil { return err } reservation = CreditReservation{UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved"} if err := tx.Create(&reservation).Error; err != nil { return err } return tx.Create(&CreditTransaction{UserID: userID, Type: "reserve", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(taskID), 10), Reason: "download reservation"}).Error }) return &reservation, err } func (a *App) commitCredit(task *Task) error { if task.CreditReservationID == 0 { return nil } return a.db.Transaction(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.db.Transaction(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 }