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:
Krao Hasanee
2026-06-12 10:02:28 -04:00
parent aff3d98929
commit 8eacd86b04
13 changed files with 116 additions and 596 deletions
+3
View File
@@ -3,3 +3,6 @@ verify_jwt = false
[functions.create-user]
verify_jwt = false
[functions.stripe-webhook]
verify_jwt = false
@@ -25,6 +25,16 @@ serve(async (req) => {
// Helper: retrieve fee from a payment intent and mark invoice paid
async function markPaid(paymentIntentId: string, invoice_id: string) {
// Idempotency: Stripe retries events, and card payments fire both
// checkout.session.completed and payment_intent.succeeded. Never
// overwrite paid_at or re-send the receipt for an already-paid invoice.
const { data: existing } = await supabase
.from('invoices')
.select('status')
.eq('id', invoice_id)
.single();
if (existing?.status === 'paid') return;
let stripe_fee: number | null = null;
try {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
@@ -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;