123 lines
7.5 KiB
SQL
123 lines
7.5 KiB
SQL
-- Money. Every amount here is whole micro-USD and stays integral through the generated layer.
|
|
--
|
|
-- ⚠ The ::bigint casts on the AGGREGATES are load-bearing and were arrived at by measurement, not
|
|
-- taste. `sum(bigint)` is `numeric` in Postgres, and sqlc types a coalesced aggregate WITHOUT a cast
|
|
-- as `interface{}` — money with no type at all, which is the worst of the three outcomes. With the
|
|
-- cast it is `int64`: integral, and one explicit conversion away from money.MicroUSD in the domain
|
|
-- layer. What sqlc will NOT do either way is apply the `*.*_micro_usd` override to a computed
|
|
-- column — that override reaches real table columns only (measured; an alias-targeted override does
|
|
-- not reach them either). So the aggregates below are the one place in this file where the money
|
|
-- TYPE is restored by hand, and credits.go says so at each site.
|
|
|
|
-- name: Balance :one
|
|
-- What the account may still spend, read from the cache that every ledger write updates in its own
|
|
-- transaction.
|
|
--
|
|
-- ⚠ The coalesce is not a simplification, it is a CORRECTION forced by measurement. The column is
|
|
-- `not null` in the table, so sqlc types it non-nullable and does not notice that the LEFT JOIN can
|
|
-- still produce NULL — an account that exists with no ledger rows. Left as `b.balance_micro_usd`,
|
|
-- the generated Scan would ERROR on exactly that account instead of answering "no credit". The two
|
|
-- facts stay distinguishable without the pointer: a missing ACCOUNT is still no row at all, because
|
|
-- the outer `from users` decides that, and a missing BALANCE row is the zero this coalesce supplies.
|
|
select coalesce(b.balance_micro_usd, 0)::bigint as balance_micro_usd
|
|
from users u left join account_balances b on b.user_id = u.id
|
|
where u.id = sqlc.arg(user_id);
|
|
|
|
-- name: ReadAccount :one
|
|
-- The three are read in ONE snapshot on purpose: reading them separately reports drift that a
|
|
-- concurrent grant caused between the queries. LedgerSum exists to be COMPARED with Balance.
|
|
select coalesce((select ab.balance_micro_usd from account_balances ab
|
|
where ab.user_id = sqlc.arg(user_id)), 0)::bigint as balance_micro_usd,
|
|
coalesce((select sum(cl.amount_micro_usd) from credit_ledger cl
|
|
where cl.user_id = sqlc.arg(user_id)), 0)::bigint as ledger_sum_micro_usd,
|
|
coalesce((select sum(r.amount_micro_usd) from reservations r
|
|
where r.user_id = sqlc.arg(user_id) and r.state = 'open'), 0)::bigint as reserved_micro_usd
|
|
from users u where u.id = sqlc.arg(user_id);
|
|
|
|
-- name: OpenReservations :many
|
|
-- 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.
|
|
select engine_run_id, book_id, amount_micro_usd, ceiling_micro_usd, opened_at
|
|
from reservations where user_id = sqlc.arg(user_id) and state = 'open' order by opened_at;
|
|
|
|
-- name: OpenReservation :execrows
|
|
-- The book must belong to the account being charged. The foreign key only proves the book exists,
|
|
-- which is not the same question — hence the `select ... from books where owner_id`, whose zero rows
|
|
-- ARE the refusal.
|
|
insert into reservations (engine_run_id, user_id, book_id, amount_micro_usd, ceiling_micro_usd, state, opened_at)
|
|
select sqlc.arg(engine_run_id), sqlc.arg(user_id), sqlc.arg(book_id), sqlc.arg(amount),
|
|
sqlc.arg(amount), 'open', sqlc.arg(now)
|
|
from books where id = sqlc.arg(book_id) and owner_id = sqlc.arg(user_id);
|
|
|
|
-- name: LockAttemptUnit :one
|
|
-- The reconciler's belief that an attempt never spawned is re-checked HERE, in the same transaction
|
|
-- as the money, against the row rather than against the sweep's snapshot.
|
|
select unit_name from run_attempts where id = sqlc.arg(id) for update;
|
|
|
|
-- name: LockAccountBalance :one
|
|
-- Every money operation starts here, so they all take their locks in the same order.
|
|
-- ⚠ Named LockAccountBalance and not LockBalance: sqlc derives an unexported const from the query
|
|
-- name, and `lockBalance` is already the hand-written helper this one serves. Same for
|
|
-- LockBookForUpdate, MarkReservationClosed and InsertLedgerEntry.
|
|
select balance_micro_usd from account_balances where user_id = sqlc.arg(user_id) for update;
|
|
|
|
-- name: AccountExists :one
|
|
-- No balance row is TWO different facts, and answering both with "insufficient credit" told an
|
|
-- operator who mistyped an account id that the account was broke (PD-82). Postgres refuses FOR
|
|
-- UPDATE on the nullable side of an outer join, so there is no one-query form of this question.
|
|
select true from users where id = sqlc.arg(id);
|
|
|
|
-- name: ReservationOwner :one
|
|
select user_id from reservations where engine_run_id = sqlc.arg(engine_run_id);
|
|
|
|
-- name: MarkReservationClosed :one
|
|
-- The state guard is what makes the unlocked owner read above safe: a reservation closed by someone
|
|
-- else in between makes this update match nothing.
|
|
update reservations set state = sqlc.arg(state), closed_at = sqlc.arg(now)::timestamptz
|
|
where engine_run_id = sqlc.arg(engine_run_id) and state = 'open'
|
|
returning amount_micro_usd;
|
|
|
|
-- name: InsertLedgerEntry :execrows
|
|
-- The ledger is APPEND-ONLY in the code that writes it, not in the schema: no statement in this
|
|
-- package updates or deletes a row here, and TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt walks
|
|
-- every SQL string to keep that true.
|
|
insert into credit_ledger (user_id, kind, amount_micro_usd, source, source_id, note, created_at)
|
|
values (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(amount), sqlc.arg(source), sqlc.arg(source_id),
|
|
sqlc.arg(note), sqlc.arg(now))
|
|
on conflict (user_id, source, source_id) do nothing;
|
|
|
|
-- name: MoveBalance :exec
|
|
-- Written in the SAME transaction as the ledger row above, always. A cache that can lag its source
|
|
-- is a second answer about money.
|
|
insert into account_balances (user_id, balance_micro_usd, updated_at)
|
|
values (sqlc.arg(user_id), sqlc.arg(amount), sqlc.arg(now))
|
|
on conflict (user_id) do update
|
|
set balance_micro_usd = account_balances.balance_micro_usd + excluded.balance_micro_usd,
|
|
updated_at = excluded.updated_at;
|
|
|
|
-- name: CreditHeldBy :one
|
|
-- ⚠ The book with the LARGEST hold, not the oldest: naming a book whose $0.03 hold was merely opened
|
|
-- first, beside another holding $9.60, sends the user to cancel a run that frees nothing. Ties go to
|
|
-- the older one, so the answer is stable between two reads.
|
|
select coalesce((select r1.book_id from reservations r1
|
|
where r1.user_id = sqlc.arg(user_id) and r1.state = 'open'
|
|
and r1.book_id <> sqlc.arg(except_book_id)
|
|
group by r1.book_id
|
|
order by sum(r1.amount_micro_usd) desc, min(r1.opened_at) limit 1), '')::text as book_id,
|
|
coalesce((select sum(r2.amount_micro_usd) from reservations r2
|
|
where r2.user_id = sqlc.arg(user_id) and r2.state = 'open'
|
|
and r2.book_id <> sqlc.arg(except_book_id)), 0)::bigint as held_micro_usd;
|
|
|
|
-- name: LockBookForUpdate :one
|
|
-- The FIRST lock of every transaction in this package that touches more than one of:
|
|
-- books → runs → run_attempts → account_balances → reservations. A missing book is not an error
|
|
-- here: the caller's own statements find nothing and decide what that means.
|
|
select id from books where id = sqlc.arg(id) for update;
|
|
|
|
-- name: DeleteClosedReservationsOfBook :exec
|
|
-- Closed reservations carry no financial fact the ledger does not already hold. An OPEN one blocks
|
|
-- the book delete through the foreign key, which is the point.
|
|
delete from reservations where book_id = sqlc.arg(book_id) and state <> 'open';
|
|
|
|
-- name: DeleteBook :exec
|
|
delete from books where id = sqlc.arg(id);
|