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
+2 -30
View File
@@ -19,37 +19,9 @@ export function getRevisionChargeQuantity(versionNumber, revisionType) {
return version >= 2 ? 1 : 0;
}
// Parses version number from an invoice item description like "Project • Task R01"
// Parses version number from a subcontractor invoice item description like
// "Project • Task R01". Legacy fallback only — new rows store version_number directly.
export function parseVersionFromItemDescription(description = '') {
const match = String(description).match(/[\-]\s*R(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
// Builds a Map<"taskId:version", "sent"|"paid"> from invoice_items rows
// (each row must have invoice.status joined)
export function buildInvoiceStatusByKey(invoiceItems = []) {
const map = new Map();
for (const item of invoiceItems) {
const status = item.invoice?.status;
if (!status || !['sent', 'paid'].includes(status)) continue;
const version = parseVersionFromItemDescription(item.description);
const key = `${item.task_id}:${version}`;
const existing = map.get(key);
// paid beats sent
if (!existing || (status === 'paid' && existing !== 'paid')) {
map.set(key, status);
}
}
return map;
}
// Per-version status: invoice state takes priority over task work state
export function deriveVersionStatus(task, version, invoiceStatusByKey) {
const invoiceStatus = invoiceStatusByKey?.get(`${task.id}:${version}`);
if (invoiceStatus === 'paid') return 'paid';
if (invoiceStatus === 'sent') return 'invoiced';
// Past version — work moved on, implicitly approved
if (version < (task.current_version ?? 0)) return 'client_approved';
// Current version — use task work status
return task.status;
}