From 8eacd86b045d3ad15f592c5343ebaaa2d3fcbbe0 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Fri, 12 Jun 2026 10:02:28 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20invoice=20integrity=20=E2=80=94=20atomic?= =?UTF-8?q?=20numbering,=20safe=20delete,=20single=20create=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.jsx | 5 +- src/components/SubcontractorInvoiceForm.jsx | 7 +- src/lib/invoiceVersionRules.js | 32 +- src/pages/external/ExternalMyInvoices.jsx | 2 +- src/pages/team/TeamCreateInvoice.jsx | 541 ------------------ src/pages/team/TeamInvoiceDetail.jsx | 40 +- src/pages/team/TeamInvoices.jsx | 22 +- src/pages/team/TeamReports.jsx | 5 +- supabase/config.toml | 3 + supabase/functions/stripe-webhook/index.ts | 10 + ...60612021739_remote_history_placeholder.sql | 1 + .../20260612100000_add_invoice_number_fn.sql | 37 ++ ...dd_version_number_to_sub_invoice_items.sql | 7 + 13 files changed, 116 insertions(+), 596 deletions(-) delete mode 100644 src/pages/team/TeamCreateInvoice.jsx create mode 100644 supabase/migrations/20260612021739_remote_history_placeholder.sql create mode 100644 supabase/migrations/20260612100000_add_invoice_number_fn.sql create mode 100644 supabase/migrations/20260612100001_add_version_number_to_sub_invoice_items.sql diff --git a/src/App.jsx b/src/App.jsx index a25b23f..b5fd035 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -44,7 +44,6 @@ const CompaniesPage = lazy(() => import('./pages/Companies')); const CompanyDetail = lazy(() => import('./pages/CompanyDetail')); const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices')); const TaskDetail = lazy(() => import('./pages/TaskDetail')); -const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice')); const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO')); const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail')); const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail')); @@ -111,13 +110,13 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/SubcontractorInvoiceForm.jsx b/src/components/SubcontractorInvoiceForm.jsx index d46e645..d0072f8 100644 --- a/src/components/SubcontractorInvoiceForm.jsx +++ b/src/components/SubcontractorInvoiceForm.jsx @@ -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, diff --git a/src/lib/invoiceVersionRules.js b/src/lib/invoiceVersionRules.js index 881f029..a6ff45d 100644 --- a/src/lib/invoiceVersionRules.js +++ b/src/lib/invoiceVersionRules.js @@ -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; -} diff --git a/src/pages/external/ExternalMyInvoices.jsx b/src/pages/external/ExternalMyInvoices.jsx index 8a8a7ac..73fb6c5 100644 --- a/src/pages/external/ExternalMyInvoices.jsx +++ b/src/pages/external/ExternalMyInvoices.jsx @@ -55,7 +55,7 @@ function asArray(value) { } function parseItemVersionNumber(item) { - if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number); + if (item?.version_number != null && Number.isFinite(Number(item.version_number))) return Number(item.version_number); const match = String(item?.description || '').match(/\bR(\d{2})\b/i); return match ? Number(match[1]) : 0; } diff --git a/src/pages/team/TeamCreateInvoice.jsx b/src/pages/team/TeamCreateInvoice.jsx deleted file mode 100644 index 108f80e..0000000 --- a/src/pages/team/TeamCreateInvoice.jsx +++ /dev/null @@ -1,541 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; -import Layout from '../../components/Layout'; -import LoadingButton from '../../components/LoadingButton'; -import { supabase } from '../../lib/supabase'; -import { useAuth } from '../../context/AuthContext'; -import { generateInvoicePDF } from '../../lib/invoice'; -import { blobToEmailAttachment, sendEmail } from '../../lib/email'; -import { withTimeout } from '../../lib/withTimeout'; -import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions'; -import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules'; - -// Computed at module load time — stable for the lifetime of the invoice creation session -const INVOICE_TODAY = new Date().toISOString().split('T')[0]; -const INVOICE_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - -function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) { - return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id }; -} - -const buildNewItemDescription = (task) => { - const projectName = task.project?.name || 'No Project'; - const taskTitle = task.title || task.service_type || 'Untitled'; - return `${projectName} • ${taskTitle}`; -}; - -const buildRevisionItemDescription = (revision) => { - const projectName = revision.task?.project?.name || 'No Project'; - const taskTitle = revision.task?.title || revision.service_type || 'Revision'; - const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0'); - return `${projectName} • ${taskTitle} • Revision ${versionLabel}`; -}; - -export default function CreateInvoice() { - const navigate = useNavigate(); - const { currentUser } = useAuth(); - - const [companies, setCompanies] = useState([]); - const [selectedCompanyId, setSelectedCompanyId] = useState(''); - const [uninvoicedTasks, setUninvoicedTasks] = useState([]); - const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]); - const [priceList, setPriceList] = useState([]); - const [billTo, setBillTo] = useState(''); - const [invoiceEmail, setInvoiceEmail] = useState(''); - const [companyRecipients, setCompanyRecipients] = useState([]); - const [items, setItems] = useState([newItem()]); - const [notes, setNotes] = useState(''); - const [saving, setSaving] = useState(false); - const [loadingTasks, setLoadingTasks] = useState(false); - const dragItem = useRef(null); - const today = INVOICE_TODAY; - const net30 = INVOICE_NET30; - - useEffect(() => { - supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setCompanies(data || [])); - }, []); - - useEffect(() => { - if (!selectedCompanyId) { - setUninvoicedTasks([]); - setUninvoicedRevisions([]); - setPriceList([]); - setItems([newItem()]); - setBillTo(''); - setInvoiceEmail(''); - setCompanyRecipients([]); - return; - } - setBillTo(companies.find(c => c.id === selectedCompanyId)?.name || ''); - setLoadingTasks(true); - Promise.all([ - supabase.from('projects').select('id').eq('company_id', selectedCompanyId), - supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId), - supabase.from('company_members').select('profile:profiles(id, name, email, role, company_id)').eq('company_id', selectedCompanyId), - supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', selectedCompanyId).in('role', ['client', 'external']).order('name'), - ]).then(async ([{ data: projects }, { data: prices }, { data: memberRows }, { data: primaryUsers }]) => { - setPriceList(prices || []); - const recipientMap = new Map(); - (memberRows || []).forEach(row => { - if (row.profile?.email) recipientMap.set(row.profile.id, row.profile); - }); - (primaryUsers || []).forEach(user => { - if (user.email) recipientMap.set(user.id, user); - }); - const recipients = [...recipientMap.values()] - .sort((a, b) => { - if (a.role === 'client' && b.role !== 'client') return -1; - if (a.role !== 'client' && b.role === 'client') return 1; - return (a.name || '').localeCompare(b.name || ''); - }); - setCompanyRecipients(recipients); - setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || ''); - if (projects && projects.length > 0) { - const projectIds = projects.map(p => p.id); - const { data: companyTasks } = await supabase - .from('tasks') - .select('*, project:projects(name), submissions(service_type, type, version_number)') - .in('project_id', projectIds) - .in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']); - const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean); - const { data: revisions } = companyTaskIds.length > 0 - ? await supabase - .from('submissions') - .select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))') - .eq('type', 'revision') - .eq('invoiced', false) - .in('task_id', companyTaskIds) - .order('submitted_at', { ascending: false }) - : { data: [] }; - const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => { - return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) }; - }); - setUninvoicedTasks(tasksWithService); - // Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges - const revMap = new Map(); - for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) { - if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue; - const key = `${rev.task_id}:${rev.version_number}`; - if (!revMap.has(key)) revMap.set(key, rev); - } - setUninvoicedRevisions([...revMap.values()]); - } else { - setUninvoicedTasks([]); - setUninvoicedRevisions([]); - } - setLoadingTasks(false); - }); - }, [selectedCompanyId, companies]); - - const addTaskAsItem = (task) => { - const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new'); - const description = buildNewItemDescription(task); - setItems(prev => { - if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) { - return [newItem(description, price?.price || '', 1, task.id)]; - } - return [...prev, newItem(description, price?.price || '', 1, task.id)]; - }); - }; - - const getRevisionServiceType = (revision) => { - return pickInitialServiceType(revision.task?.submissions, revision.service_type || revision.task?.title || 'Revision'); - }; - - const addRevisionAsItem = (revision) => { - const serviceLabel = getRevisionServiceType(revision); - const description = buildRevisionItemDescription(revision); - const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision'); - const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number, revision?.revision_type); - const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1; - const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0; - setItems(prev => { - if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) { - return [newItem(description, unitPrice, quantity, revision.task_id, revision.id)]; - } - return [...prev, newItem(description, unitPrice, quantity, revision.task_id, revision.id)]; - }); - }; - - const updateItem = (id, field, value) => { - setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item)); - }; - - const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id)); - - const handleDrop = (targetIndex) => { - if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; } - setItems(prev => { - const next = [...prev]; - const [moved] = next.splice(dragItem.current, 1); - next.splice(targetIndex, 0, moved); - return next; - }); - dragItem.current = null; - }; - - const sortItems = (mode) => { - setItems(prev => { - const next = [...prev]; - if (mode === 'new-first') return next.sort((a, b) => (!!a.submission_id) - (!!b.submission_id)); - if (mode === 'revision-first') return next.sort((a, b) => (!!b.submission_id) - (!!a.submission_id)); - if (mode === 'az') return next.sort((a, b) => a.description.localeCompare(b.description)); - return next; - }); - }; - - const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0); - - const handleSave = async (status) => { - if (!selectedCompanyId) return alert('Please select a company.'); - if (items.every(i => !i.description)) return alert('Please add at least one line item.'); - if (status === 'sent' && !invoiceEmail.trim()) return alert('Please enter an email recipient before finalizing and sending.'); - if (status === 'sent' && !selectedCompany) return alert('Please select a valid company before finalizing and sending.'); - setSaving(true); - try { - const year = new Date().getFullYear(); - const { count, error: countError } = await supabase - .from('invoices') - .select('*', { count: 'exact', head: true }) - .gte('created_at', `${year}-01-01`); - if (countError) throw countError; - - const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`; - const initialStatus = status === 'sent' ? 'draft' : status; - const { data: invoice, error } = await supabase.from('invoices').insert({ - company_id: selectedCompanyId, - invoice_number: invoiceNumber, - invoice_date: today, - due_date: net30, - status: initialStatus, - bill_to: billTo || null, - invoice_email: invoiceEmail.trim() || null, - notes: notes || null, - total, - created_by: currentUser?.id, - }).select().single(); - if (error || !invoice) throw error || new Error('Invoice record was not created.'); - - const validItems = items.filter(i => i.description); - if (validItems.length > 0) { - const { error: itemError } = await supabase.from('invoice_items').insert( - validItems.map(item => ({ - invoice_id: invoice.id, - task_id: item.task_id || null, - submission_id: item.submission_id || null, - description: item.description, - quantity: Number(item.quantity) || 1, - unit_price: Number(item.unit_price) || 0, - })) - ); - if (itemError) throw itemError; - } - - const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))]; - if (taskIds.length > 0) { - const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds); - if (taskError) throw taskError; - } - - const submissionIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))]; - if (submissionIds.length > 0) { - const { error: submissionError } = await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds); - if (submissionError) throw submissionError; - } - - let nextInvoice = invoice; - if (status === 'sent') { - try { - const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); - const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`; - const invoiceForPdf = { ...invoice, status: 'sent' }; - const pdfItems = validItems.map(item => ({ - description: item.description, - quantity: Number(item.quantity) || 1, - unit_price: Number(item.unit_price) || 0, - })); - const emailData = { - invoiceNumber, - billTo: billTo || selectedCompany.name, - total: `$${total.toFixed(2)}`, - dueDate, - payUrl, - notes: notes || '', - }; - - let attachments = []; - let attachmentWarning = ''; - try { - const invoicePdf = await withTimeout( - generateInvoicePDF(invoiceForPdf, selectedCompany, pdfItems, { save: false }), - 8000, - 'Invoice PDF generation' - ); - const attachment = await withTimeout( - blobToEmailAttachment(invoicePdf, `${invoiceNumber}.pdf`), - 5000, - 'Invoice attachment encoding' - ); - attachments = [attachment]; - } catch (attachmentError) { - console.error('Invoice PDF attachment skipped during creation:', attachmentError); - attachmentWarning = ' The invoice email was sent without the PDF attachment.'; - } - - await withTimeout( - sendEmail('invoice_sent', invoiceEmail.trim(), emailData, attachments), - 12000, - 'Sending invoice email' - ); - - const { data: sentInvoice, error: sentError } = await supabase - .from('invoices') - .update({ status: 'sent' }) - .eq('id', invoice.id) - .select() - .single(); - if (sentError) throw sentError; - nextInvoice = sentInvoice || { ...invoice, status: 'sent' }; - if (attachmentWarning) { - alert(`Invoice sent successfully.${attachmentWarning}`); - } - } catch (sendError) { - console.error('Failed to send invoice during creation:', sendError); - alert(`Invoice was saved as a draft, but the email was not sent: ${sendError.message}`); - } - } - - navigate(`/invoices/${invoice.id}`, { state: { invoice: nextInvoice } }); - } catch (saveError) { - console.error('Failed to save invoice:', saveError); - alert(`Failed to save invoice: ${saveError.message || 'Unknown error'}`); - } finally { - setSaving(false); - } - }; - - const selectedCompany = companies.find(c => c.id === selectedCompanyId); - - return ( - - - -
-
-
New Invoice
-
Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)
-
-
- -
-
Company
-
- - -
- {selectedCompany && ( -
-
- - setBillTo(e.target.value)} - placeholder={selectedCompany.name} - /> -
-
- - setInvoiceEmail(e.target.value)} - placeholder={companyRecipients[0]?.email || 'client@example.com'} - /> - - {companyRecipients.map(recipient => ( - - ))} - -
-
- )} -
- -
-
-
Unbilled New Requests
- {uninvoicedTasks.length > 0 && !loadingTasks && ( - - )} -
- {!selectedCompanyId ? ( -

Select a company to see their unbilled requests.

- ) : loadingTasks ? ( -

Loading...

- ) : uninvoicedTasks.length === 0 ? ( -

No unbilled new requests.

- ) : ( -
- {uninvoicedTasks.map(task => { - const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new'); - const alreadyAdded = items.some(i => i.task_id === task.id && !i.submission_id); - return ( -
-
-
{buildNewItemDescription(task)}
-
- New - {task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'} -
-
- -
- ); - })} -
- )} -
- - {selectedCompanyId && (uninvoicedRevisions.length > 0 || loadingTasks) && ( -
-
-
Unbilled Client Revisions
- {uninvoicedRevisions.length > 0 && !loadingTasks && ( - - )} -
- {loadingTasks ? ( -

Loading...

- ) : ( -
- {uninvoicedRevisions.map(rev => { - const revServiceType = getRevisionServiceType(rev); - const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision'); - const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number, rev?.revision_type); - const alreadyAdded = items.some(i => i.submission_id === rev.id); - return ( -
-
-
{buildRevisionItemDescription(rev)}
-
- Revision - {revServiceType || 'Other'} • {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'} -
-
- -
- ); - })} -
- )} -
- )} - -
-
-
Line Items
- {items.some(i => i.description) && ( - - )} -
- -
- {['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => ( -
3 ? 'right' : 'left' }}>{h}
- ))} -
- -
- {items.map((item, index) => ( -
e.preventDefault()} - onDrop={() => handleDrop(index)} - style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }} - > -
{ dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }} - style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }} - >⠿
- - {item.submission_id ? 'Revision' : 'New'} - - updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} /> - updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} /> - updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} /> -
- ${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)} -
- -
- ))} -
- - - -
-
-
Total
-
${total.toFixed(2)}
-
-
-
- -
-
Notes
-