341 lines
14 KiB
Go
341 lines
14 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"textmachine/platform/internal/money"
|
|
)
|
|
|
|
var (
|
|
// ErrInsufficientCredit is a refusal, not a failure: the account has less than the run needs.
|
|
ErrInsufficientCredit = errors.New("pgstore: insufficient credit")
|
|
// ErrNoReservation means the hold this settlement refers to is not open.
|
|
ErrNoReservation = errors.New("pgstore: no open reservation")
|
|
// ErrDuplicateHold is a second hold on an attempt id that already has one. It is an error, not
|
|
// a no-op: a hold that debits nothing reserves nothing while reporting that it did.
|
|
ErrDuplicateHold = errors.New("pgstore: attempt already has a hold")
|
|
// ErrNotOwner is a book that does not belong to the account being charged for it.
|
|
ErrNotOwner = errors.New("pgstore: book belongs to another account")
|
|
// ErrNoAccount separates "this account has nothing" from "this account does not exist" — the
|
|
// difference between a balance of zero and a typo in an admin command.
|
|
ErrNoAccount = errors.New("pgstore: no such account")
|
|
)
|
|
|
|
// Grant credits an account and reports whether this call is what credited it. The free tier is one
|
|
// of these and nothing more.
|
|
//
|
|
// (source, sourceID) is the idempotency key, scoped to the account by the schema. applied is false
|
|
// when the key was already spent: the caller must say so rather than print a success it did not
|
|
// cause.
|
|
func (s *Store) Grant(ctx context.Context, userID string, amount money.MicroUSD, source, sourceID, note string, now time.Time) (applied bool, err error) {
|
|
if amount <= 0 {
|
|
return false, fmt.Errorf("pgstore: grant must be positive, got %d", amount)
|
|
}
|
|
if source == "" || sourceID == "" {
|
|
return false, errors.New("pgstore: grant needs an idempotency key")
|
|
}
|
|
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
|
applied, err = appendLedger(ctx, tx, userID, "grant", amount, source, sourceID, note, now)
|
|
return err
|
|
})
|
|
return applied, err
|
|
}
|
|
|
|
// Adjust corrects a balance. A ledger row is never edited: the correction is another row, which is
|
|
// what keeps the sum reproducible. The note is mandatory, in the DDL as well as here.
|
|
func (s *Store) Adjust(ctx context.Context, userID string, amount money.MicroUSD, source, sourceID, note string, now time.Time) (applied bool, err error) {
|
|
if amount == 0 || note == "" {
|
|
return false, errors.New("pgstore: an adjustment needs a non-zero amount and a reason")
|
|
}
|
|
if source == "" || sourceID == "" {
|
|
return false, errors.New("pgstore: adjustment needs an idempotency key")
|
|
}
|
|
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
|
applied, err = appendLedger(ctx, tx, userID, "adjustment", amount, source, sourceID, note, now)
|
|
return err
|
|
})
|
|
return applied, err
|
|
}
|
|
|
|
// Balance is what the account may still spend, read from the cache that every ledger write updates
|
|
// in its own transaction.
|
|
func (s *Store) Balance(ctx context.Context, userID string) (money.MicroUSD, error) {
|
|
var v *int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
select b.balance_micro_usd
|
|
from users u left join account_balances b on b.user_id = u.id
|
|
where u.id = $1`, userID).Scan(&v)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, ErrNoAccount
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: balance: %w", err)
|
|
}
|
|
if v == nil {
|
|
return 0, nil // an account with no ledger rows has no credit, which is not an error
|
|
}
|
|
return money.MicroUSD(*v), nil
|
|
}
|
|
|
|
// Account is what an operator needs to see about one account's money.
|
|
type Account struct {
|
|
Balance money.MicroUSD
|
|
Reserved money.MicroUSD
|
|
// LedgerSum is recomputed from the rows. It exists to be COMPARED with Balance, and both are
|
|
// read in one snapshot: reading them separately reports drift that a concurrent grant caused
|
|
// between the two queries.
|
|
LedgerSum money.MicroUSD
|
|
}
|
|
|
|
// ReadAccount returns the money view in a single consistent snapshot.
|
|
func (s *Store) ReadAccount(ctx context.Context, userID string) (Account, error) {
|
|
var a Account
|
|
const q = `
|
|
select coalesce((select balance_micro_usd from account_balances where user_id = $1), 0),
|
|
coalesce((select sum(amount_micro_usd) from credit_ledger where user_id = $1), 0),
|
|
coalesce((select sum(amount_micro_usd) from reservations
|
|
where user_id = $1 and state = 'open'), 0)
|
|
from users where id = $1`
|
|
err := s.pool.QueryRow(ctx, q, userID).Scan(&a.Balance, &a.LedgerSum, &a.Reserved)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Account{}, ErrNoAccount
|
|
}
|
|
if err != nil {
|
|
return Account{}, fmt.Errorf("pgstore: read account: %w", err)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// Reservation is an open hold as an operator sees it.
|
|
type Reservation struct {
|
|
EngineRunID string
|
|
BookID string
|
|
Amount money.MicroUSD
|
|
Ceiling money.MicroUSD
|
|
OpenedAt time.Time
|
|
}
|
|
|
|
// OpenReservations lists holds that were taken and never closed. Without this they are money that
|
|
// is gone from the balance and invisible to everything that could give it back.
|
|
func (s *Store) OpenReservations(ctx context.Context, userID string) ([]Reservation, error) {
|
|
const q = `
|
|
select engine_run_id, book_id, amount_micro_usd, ceiling_micro_usd, opened_at
|
|
from reservations where user_id = $1 and state = 'open' order by opened_at`
|
|
rows, err := s.pool.Query(ctx, q, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pgstore: open reservations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []Reservation
|
|
for rows.Next() {
|
|
var r Reservation
|
|
if err := rows.Scan(&r.EngineRunID, &r.BookID, &r.Amount, &r.Ceiling, &r.OpenedAt); err != nil {
|
|
return nil, fmt.Errorf("pgstore: scan reservation: %w", err)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Hold reserves credit before a run is spawned. Together with the per-book ceiling handed to the
|
|
// engine it is the enforcement half of the money design: the hold makes the credit unavailable to
|
|
// the next run, and the engine stops itself at the ceiling, so an overspend is impossible even
|
|
// while the platform is blind. The event stream is freshness only.
|
|
//
|
|
// The ceiling to hand the engine is the amount held; read it back with OpenReservations.
|
|
func (s *Store) Hold(ctx context.Context, userID, bookID, engineRunID string, amount money.MicroUSD, now time.Time) error {
|
|
if amount <= 0 {
|
|
return fmt.Errorf("pgstore: hold must be positive, got %d", amount)
|
|
}
|
|
return s.inTx(ctx, func(tx pgx.Tx) error {
|
|
// account_balances is locked FIRST here and in every other operation. A path that locked
|
|
// the reservation first would invert the order against this one and deadlock — measured,
|
|
// not hypothetical.
|
|
balance, err := lockBalance(ctx, tx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if balance < amount {
|
|
return ErrInsufficientCredit
|
|
}
|
|
// The book must belong to the account being charged. The foreign key only proves the book
|
|
// exists, which is not the same question.
|
|
tag, err := tx.Exec(ctx, `
|
|
insert into reservations (engine_run_id, user_id, book_id, amount_micro_usd, ceiling_micro_usd, state, opened_at)
|
|
select $1, $2, $3, $4, $4, 'open', $5 from books where id = $3 and owner_id = $2`,
|
|
engineRunID, userID, bookID, int64(amount), now)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: open reservation: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotOwner
|
|
}
|
|
applied, err := appendLedger(ctx, tx, userID, "hold", -amount, "run", engineRunID, "", now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !applied {
|
|
// The ledger already holds this attempt id, so nothing was debited. Reporting success
|
|
// would spawn a run against credit that was never reserved.
|
|
return ErrDuplicateHold
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Settle closes a reservation with what the attempt actually cost: the hold comes back and the real
|
|
// cost is charged, in one transaction. Settling twice is refused — the reservation is no longer
|
|
// open — which is what makes it safe on a retried path.
|
|
//
|
|
// A cost above the hold is CAPPED at the hold and the row says so. Spending more than was reserved
|
|
// means the engine's ceiling did not hold, and the account is not the place to absorb that.
|
|
func (s *Store) Settle(ctx context.Context, engineRunID string, spent money.MicroUSD, now time.Time) error {
|
|
if spent < 0 {
|
|
return fmt.Errorf("pgstore: spend cannot be negative, got %d", spent)
|
|
}
|
|
return s.inTx(ctx, func(tx pgx.Tx) error {
|
|
userID, held, err := closeReservation(ctx, tx, engineRunID, "settled", now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
note := ""
|
|
if spent > held {
|
|
note = fmt.Sprintf("capped at the hold; the engine reported %s", spent.USD())
|
|
spent = held
|
|
}
|
|
if _, err := appendLedger(ctx, tx, userID, "hold_release", held, "run_release", engineRunID, "", now); err != nil {
|
|
return err
|
|
}
|
|
_, err = appendLedger(ctx, tx, userID, "settlement", -spent, "run_settle", engineRunID, note, now)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// Release gives a reservation back untouched: the run never started, or it cost nothing.
|
|
func (s *Store) Release(ctx context.Context, engineRunID string, now time.Time) error {
|
|
return s.inTx(ctx, func(tx pgx.Tx) error {
|
|
userID, held, err := closeReservation(ctx, tx, engineRunID, "released", now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = appendLedger(ctx, tx, userID, "hold_release", held, "run_release", engineRunID, "", now)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// lockBalance takes the account's row lock and returns the balance under it. Every money operation
|
|
// starts here, so they all take their locks in the same order.
|
|
func lockBalance(ctx context.Context, tx pgx.Tx, userID string) (money.MicroUSD, error) {
|
|
var v int64
|
|
err := tx.QueryRow(ctx, `select balance_micro_usd from account_balances where user_id = $1 for update`, userID).Scan(&v)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, ErrInsufficientCredit // no ledger row yet means no credit
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pgstore: read balance: %w", err)
|
|
}
|
|
return money.MicroUSD(v), nil
|
|
}
|
|
|
|
func closeReservation(ctx context.Context, tx pgx.Tx, engineRunID, state string, now time.Time) (string, money.MicroUSD, error) {
|
|
// Read the owner unlocked, lock the balance, then close under the state guard. The guard is
|
|
// what makes the unlocked read safe: a reservation closed by someone else in between makes the
|
|
// update match nothing.
|
|
var userID string
|
|
err := tx.QueryRow(ctx, `select user_id from reservations where engine_run_id = $1`, engineRunID).Scan(&userID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", 0, ErrNoReservation
|
|
}
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("pgstore: find reservation: %w", err)
|
|
}
|
|
if _, err := lockBalance(ctx, tx, userID); err != nil && !errors.Is(err, ErrInsufficientCredit) {
|
|
return "", 0, err
|
|
}
|
|
var amount int64
|
|
err = tx.QueryRow(ctx, `
|
|
update reservations set state = $2, closed_at = $3
|
|
where engine_run_id = $1 and state = 'open'
|
|
returning amount_micro_usd`, engineRunID, state, now).Scan(&amount)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", 0, ErrNoReservation
|
|
}
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("pgstore: close reservation: %w", err)
|
|
}
|
|
return userID, money.MicroUSD(amount), nil
|
|
}
|
|
|
|
// appendLedger writes one row and moves the cached balance with it, in the caller's transaction.
|
|
// The two are never written apart: a cache that can lag its source is a second answer about money.
|
|
// applied is false when the idempotency key was already spent.
|
|
func appendLedger(ctx context.Context, tx pgx.Tx, userID, kind string, amount money.MicroUSD, source, sourceID, note string, now time.Time) (bool, error) {
|
|
tag, err := tx.Exec(ctx, `
|
|
insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id, note, created_at)
|
|
values ($1, $2, $3, $4, $5, $6, $7)
|
|
on conflict (user_id, source, source_id) do nothing`,
|
|
userID, kind, int64(amount), source, sourceID, note, now)
|
|
if err != nil {
|
|
// A typo in an account id is the commonest way an operator gets here, and Balance already
|
|
// answers it with ErrNoAccount. Reporting the same fact as a raw constraint name reads as a
|
|
// broken database (PD-56).
|
|
var pg *pgconn.PgError
|
|
if errors.As(err, &pg) && pg.ConstraintName == "credit_ledger_user_id_fkey" {
|
|
return false, ErrNoAccount
|
|
}
|
|
return false, fmt.Errorf("pgstore: append ledger: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return false, nil
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
insert into account_balances (user_id, balance_micro_usd, updated_at)
|
|
values ($1, $2, $3)
|
|
on conflict (user_id) do update
|
|
set balance_micro_usd = account_balances.balance_micro_usd + excluded.balance_micro_usd,
|
|
updated_at = excluded.updated_at`,
|
|
userID, int64(amount), now); err != nil {
|
|
return false, fmt.Errorf("pgstore: update balance: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (s *Store) inTx(ctx context.Context, fn func(pgx.Tx) error) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
if err := fn(tx); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("pgstore: commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteBook removes a book and the CLOSED reservations that referenced it. An OPEN one blocks the
|
|
// delete (the foreign key is RESTRICT), which is the point: removing a book with money reserved
|
|
// against it would leave the hold in the ledger with nothing left to release it.
|
|
//
|
|
// Closed reservations carry no financial fact the ledger does not already hold — they are
|
|
// operational state — so removing them with the book loses nothing.
|
|
func (s *Store) DeleteBook(ctx context.Context, bookID string) error {
|
|
return s.inTx(ctx, func(tx pgx.Tx) error {
|
|
if _, err := tx.Exec(ctx,
|
|
`delete from reservations where book_id = $1 and state <> 'open'`, bookID); err != nil {
|
|
return fmt.Errorf("pgstore: clear reservations: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `delete from books where id = $1`, bookID); err != nil {
|
|
return fmt.Errorf("pgstore: delete book: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|