Files
member-console/internal/db/migrations/00013_invoice_numbers.sql
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

86 lines
3.8 KiB
SQL

-- +goose Up
-- invoice-numbers (design.md D1-D4; upstream data model v16.2, identifier
-- policy arbitration 2026-08-29): core.invoices gains a platform-assigned
-- reference number, scoped per billing account, assigned from a
-- transactional gapless counter on core.accounts at issuance
-- (AssignNextInvoiceNumber: UPDATE ... SET next_invoice_number =
-- next_invoice_number + 1 ... RETURNING next_invoice_number - 1, never a
-- Postgres sequence -- a sequence burns values on rollback, and gapless
-- numbering of issued invoices is statutory in several jurisdictions).
--
-- This reworks the first cut (2026-08-28), which stored Stripe's own
-- customer-facing number under a single global partial unique index. That
-- mechanism is superseded, not amended: the composite per-account index
-- below replaces the global one, and the format changes from Stripe's
-- "A1B2C3D4-0001" to the platform's own zero-padded decimal, assigned at
-- issuance rather than copied from Stripe. Stripe's number moves to
-- stripe.invoice_mappings.stripe_invoice_number (Stripe-stream migration)
-- as an external reference.
--
-- Grandfathering: any invoice_number already stored under the first cut
-- (quoted to a member as Stripe's number) is kept verbatim by the backfill
-- below -- it is never touched, never renumbered, and never counted against
-- an account's counter. Every account's counter starts at 1 and advances
-- only past the rows this migration backfills for it, so an account may
-- show both a grandfathered Stripe-format number and the platform's own
-- decimal form side by side for a while. The composite unique index
-- tolerates this: "0001" and "A1B2C3D4-0001" cannot collide.
-- The column itself, and its nullability rationale, are unchanged from the
-- first cut: nullable because drafts never carry a number, and (as of this
-- rework) a row only gains one at issuance via the account's counter below.
ALTER TABLE core.invoices
ADD COLUMN invoice_number VARCHAR(50);
ALTER TABLE core.accounts
ADD COLUMN next_invoice_number INTEGER NOT NULL DEFAULT 1;
CREATE UNIQUE INDEX uq_invoices_account_invoice_number
ON core.invoices (billing_account_id, invoice_number) WHERE invoice_number IS NOT NULL;
-- Backfill: every non-draft invoice that still lacks a number (grandfathered
-- rows already have one and are left untouched) gets one, in created_at
-- order per billing account, and that account's counter is advanced past
-- the numbers just assigned here -- never past a grandfathered row's value,
-- which is not a sequential integer and is not counted.
CREATE TEMP TABLE invoice_number_backfill ON COMMIT DROP AS
SELECT invoice_id, billing_account_id,
row_number() OVER (PARTITION BY billing_account_id ORDER BY created_at, invoice_id) AS n
FROM core.invoices
WHERE invoice_number IS NULL AND status <> 'draft';
UPDATE core.invoices i
SET invoice_number = lpad(b.n::text, 4, '0'),
updated_at = NOW()
FROM invoice_number_backfill b
WHERE i.invoice_id = b.invoice_id;
UPDATE core.accounts a
SET next_invoice_number = c.max_n + 1
FROM (
SELECT billing_account_id, max(n) AS max_n
FROM invoice_number_backfill
GROUP BY billing_account_id
) c
WHERE a.billing_account_id = c.billing_account_id;
-- Added after the backfill so the backfill itself never has to satisfy it
-- mid-flight: every non-draft row now carries a number, so the CHECK is
-- true for the entire table from the moment it is added.
ALTER TABLE core.invoices
ADD CONSTRAINT chk_invoices_issued_have_number CHECK (status = 'draft' OR invoice_number IS NOT NULL);
-- +goose Down
ALTER TABLE core.invoices
DROP CONSTRAINT chk_invoices_issued_have_number;
DROP INDEX core.uq_invoices_account_invoice_number;
ALTER TABLE core.accounts
DROP COLUMN next_invoice_number;
CREATE UNIQUE INDEX uq_invoices_invoice_number
ON core.invoices (invoice_number) WHERE invoice_number IS NOT NULL;