Files
am-downloader/credits.go
T

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
}