fix: invoice integrity — atomic numbering, safe delete, single create path
- Invoice numbers now come from DB function next_invoice_number() (max+1 per year under advisory lock) with a unique index; the old row-count method reused numbers after deletes and raced concurrent creates, which broke public pay links - Remove dead standalone TeamCreateInvoice page; the TeamInvoices modal is the single create path (page had already drifted) - Invoice delete now asks for confirmation and only un-bills tasks/ submissions not billed on another invoice - Created invoice shows correct "sent" status in list without reload - Invoice/due dates computed at save time, not module load - Reopening a paid invoice clears stale stripe_fee - Stripe webhook markPaid is idempotent (no double receipts) - subcontractor_invoice_items.version_number column stores billing version explicitly; description parsing kept as legacy fallback - Drop unused buildInvoiceStatusByKey/deriveVersionStatus Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
-- Applied remotely via MCP on 2026-06-12; placeholder to align migration history.
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Invoice numbers were generated client-side from a row count, which reuses
|
||||
-- numbers after deletes and collides under concurrency. Replace with a DB
|
||||
-- function that takes max+1 for the year under an advisory lock, and enforce
|
||||
-- uniqueness at the schema level.
|
||||
|
||||
create or replace function public.next_invoice_number()
|
||||
returns text
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
yr text := to_char(now(), 'YYYY');
|
||||
next_n int;
|
||||
begin
|
||||
-- Serialize concurrent invoice creation within this transaction
|
||||
perform pg_advisory_xact_lock(hashtext('invoice_number_' || yr));
|
||||
|
||||
select coalesce(
|
||||
max((regexp_match(invoice_number, '^INV-' || yr || '-(\d+)$'))[1]::int),
|
||||
0
|
||||
) + 1
|
||||
into next_n
|
||||
from public.invoices
|
||||
where invoice_number like 'INV-' || yr || '-%';
|
||||
|
||||
return 'INV-' || yr || '-' || lpad(next_n::text, 3, '0');
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.next_invoice_number() from public;
|
||||
grant execute on function public.next_invoice_number() to authenticated;
|
||||
|
||||
-- Enforce uniqueness going forward (fails if historical duplicates exist —
|
||||
-- those must be reviewed manually first, never auto-renumbered).
|
||||
create unique index if not exists invoices_invoice_number_key
|
||||
on public.invoices (invoice_number);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Sub-invoice billing version was only recoverable by parsing "– R01" out of
|
||||
-- the free-text item description, which breaks the moment anyone edits the
|
||||
-- text. Store the version explicitly on new rows; existing rows stay null and
|
||||
-- readers fall back to the description parser for them.
|
||||
|
||||
alter table public.subcontractor_invoice_items
|
||||
add column if not exists version_number integer;
|
||||
Reference in New Issue
Block a user