Files
member-console/internal/billing/queries/billing_accounts.sql
T

111 lines
5.0 KiB
SQL

-- name: CreateBillingAccount :one
INSERT INTO core.accounts (org_id, name, status, metadata)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetBillingAccountByID :one
SELECT * FROM core.accounts
WHERE billing_account_id = $1;
-- name: ListBillingAccountsByOrgID :many
SELECT * FROM core.accounts
WHERE org_id = $1
ORDER BY created_at ASC;
-- name: UpdateBillingAccountStatus :one
UPDATE core.accounts
SET status = $2
WHERE billing_account_id = $1
RETURNING *;
-- name: ListBillingAccountsPage :many
-- Operator billing-accounts view, paged and searched (operator-list-scale
-- UX-4; operator-billing-views D8). No status facet: core.accounts.status
-- carries no CHECK-enforced vocabulary and operator-list-scale's "Status
-- filters exist where a status vocabulary exists" requirement does not
-- name accounts, so this query offers search + pagination only.
--
-- sqlc.narg(q): NULL matches every row; set, a case-insensitive substring
-- match against the account's own name, or its organization via
-- sqlc.narg(org_ids) (design D3: "billing: organization name plus the
-- view's leading identifier"). Organization-name search resolves through
-- a pre-resolved ID array rather than a join to core.organizations: no
-- existing internal/billing query crosses that module boundary
-- (grep 'core\.' internal/billing/queries/ turns up none), so
-- operator_billing.go's matchingOrgIDs resolves matching org IDs in Go and
-- passes them here, mirroring the grants lane's product-name pattern.
-- sqlc.narg(exclude_ids): NULL excludes nothing; set, the rows whose id is
-- in the array are left out of the page AND out of count(*) OVER(), so the
-- pager's total counts only what renders. The operator billing views hand
-- it the ids whose stripe mapping records the environment the API key is
-- not in (stripe-environment-stamp D7), resolved in the stripe store the
-- same way org_ids and invoice_ids are resolved in Go, because no query
-- here crosses that module boundary.
-- count(*) OVER() carries the true total for the filtered set (design D2).
SELECT a.billing_account_id, a.org_id, a.name, a.status, a.metadata, a.created_at, a.updated_at,
count(*) OVER() AS total_count
FROM core.accounts a
WHERE (sqlc.narg(q)::text IS NULL
OR a.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR a.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR a.billing_account_id <> ALL(sqlc.narg(exclude_ids)::uuid[]))
ORDER BY a.created_at DESC
LIMIT sqlc.arg(page_limit)::int OFFSET sqlc.arg(page_offset)::int;
-- name: CountBillingAccountsPage :one
-- The same predicates as ListBillingAccountsPage, without paging: the caller runs it twice,
-- once with sqlc.narg(exclude_ids) and once without, and the difference is
-- how many rows the environment filter holds back from the view the
-- operator is looking at. The billing views' absence line reports that
-- number under the search and the facet in force
-- (stripe-environment-stamp D7), which the page query's count(*) OVER()
-- cannot give, because it counts the rows that were shown.
SELECT count(*)::bigint AS total_count
FROM core.accounts a
WHERE (sqlc.narg(q)::text IS NULL
OR a.name ILIKE '%' || sqlc.narg(q)::text || '%'
OR a.org_id = ANY(sqlc.narg(org_ids)::uuid[]))
AND (sqlc.narg(exclude_ids)::uuid[] IS NULL
OR a.billing_account_id <> ALL(sqlc.narg(exclude_ids)::uuid[]));
-- name: GetLatestSubscriptionByBillingAccountID :one
-- Most recent subscription for a billing account (any status). Used by the
-- per-org composite's billing-summary section.
SELECT * FROM core.subscriptions
WHERE billing_account_id = $1
ORDER BY created_at DESC
LIMIT 1;
-- name: GetLatestInvoiceByBillingAccountID :one
-- Most recent invoice for a billing account (any status). Used by the
-- per-org composite's billing-summary section.
SELECT * FROM core.invoices
WHERE billing_account_id = $1
ORDER BY created_at DESC
LIMIT 1;
-- name: GetOutstandingBalanceByBillingAccountID :one
-- Sum of (amount_due - amount_paid) across invoices with status='open' — the
-- only actionable non-terminal invoice status per design/billing/model.md.
-- Returns 0 if no open invoices exist.
SELECT COALESCE(SUM(amount_due - amount_paid), 0)::BIGINT AS outstanding_balance
FROM core.invoices
WHERE billing_account_id = $1
AND status = 'open';
-- name: AssignNextInvoiceNumber :one
-- The transactional, gapless counter invoice-numbers D1 specifies: the
-- account's next_invoice_number is incremented and the prior value
-- returned in the same statement, so the caller runs this inside the same
-- transaction that inserts the invoice row (invoice.finalized projection).
-- Never a Postgres sequence — a sequence burns values on rollback, and this
-- counter must not: a projection that fails after this call rolls back
-- with the transaction, leaving the counter exactly where it was. Row
-- locking is implicit in the UPDATE and serializes only within one
-- account.
UPDATE core.accounts
SET next_invoice_number = next_invoice_number + 1, updated_at = NOW()
WHERE billing_account_id = $1
RETURNING next_invoice_number - 1 AS assigned;