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:
@@ -120,7 +120,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
||||
.order('title'),
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('id, status, items:subcontractor_invoice_items(task_id, description)')
|
||||
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
|
||||
.in('status', ['submitted', 'paid']),
|
||||
supabase.rpc('get_next_sub_invoice_number'),
|
||||
]);
|
||||
@@ -133,7 +133,9 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
||||
const invoicedUnitKeys = new Set(
|
||||
(existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
|
||||
if (!item.task_id) return null;
|
||||
return `${item.task_id}:${parseVersionFromDescription(item.description)}`;
|
||||
// Stored version preferred; legacy rows (null) fall back to description parsing
|
||||
const version = item.version_number != null ? Number(item.version_number) : parseVersionFromDescription(item.description);
|
||||
return `${item.task_id}:${version}`;
|
||||
}).filter(Boolean))
|
||||
);
|
||||
|
||||
@@ -265,6 +267,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
||||
valid.map((item, idx) => ({
|
||||
invoice_id: inv.id,
|
||||
task_id: item.task_id || null,
|
||||
version_number: item.task_id ? Number(item.version_number) || 0 : null,
|
||||
description: String(item.description).trim(),
|
||||
quantity: Number(item.quantity) || 1,
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
|
||||
Reference in New Issue
Block a user