141 lines
4.3 KiB
Go
141 lines
4.3 KiB
Go
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
|
|
}
|