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 -3
View File
@@ -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() {
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
<Route path="/finances/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
<Route path="/finances/new" element={<Navigate to="/finances" replace />} />
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
<Route path="/finances/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
<Route path="/invoices" element={<Navigate to="/finances" replace />} />
<Route path="/invoices/new" element={<Navigate to="/finances/new" replace />} />
<Route path="/invoices/new" element={<Navigate to="/finances" replace />} />
<Route path="/invoices/:id" element={<RedirectTeamInvoiceDetail />} />
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
+5 -2
View File
@@ -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,
+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;
}
+1 -1
View File
@@ -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;
}
-541
View File
@@ -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 (
<Layout>
<button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">New Invoice</div>
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Company</div>
<div className="form-group">
<label>Select Company *</label>
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
<option value="">Choose a company...</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{selectedCompany && (
<div className="grid-2" style={{ marginTop: 12 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Bill To</label>
<input
type="text"
value={billTo}
onChange={e => setBillTo(e.target.value)}
placeholder={selectedCompany.name}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Email To</label>
<input
type="email"
list="company-invoice-recipients"
value={invoiceEmail}
onChange={e => setInvoiceEmail(e.target.value)}
placeholder={companyRecipients[0]?.email || 'client@example.com'}
/>
<datalist id="company-invoice-recipients">
{companyRecipients.map(recipient => (
<option key={recipient.id} value={recipient.email}>
{recipient.name ? `${recipient.name} (${recipient.role})` : recipient.role}
</option>
))}
</datalist>
</div>
</div>
)}
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled New Requests</div>
{uninvoicedTasks.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => uninvoicedTasks.forEach(task => { if (!items.some(i => i.task_id === task.id && !i.submission_id)) addTaskAsItem(task); })}
>
+ Add All
</button>
)}
</div>
{!selectedCompanyId ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their unbilled requests.</p>
) : loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : uninvoicedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No unbilled new requests.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{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 (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-initial">New</span>
<span>{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTaskAsItem(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
{selectedCompanyId && (uninvoicedRevisions.length > 0 || loadingTasks) && (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled Client Revisions</div>
{uninvoicedRevisions.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => uninvoicedRevisions.forEach(rev => { if (!items.some(i => i.submission_id === rev.id)) addRevisionAsItem(rev); })}
>
+ Add All
</button>
)}
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{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 (
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-client_revision">Revision</span>
<span>{revServiceType || 'Other'} {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}</span>
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addRevisionAsItem(rev)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
)}
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
{items.some(i => i.description) && (
<select
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
defaultValue=""
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
>
<option value="" disabled>Sort by</option>
<option value="new-first">New first</option>
<option value="revision-first">Revision first</option>
<option value="az">Description AZ</option>
</select>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div
draggable
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
></div>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
</div>
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}></button>
</div>
))}
</div>
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
+ Add Line Item
</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea placeholder="Additional notes, payment instructions, or terms..." value={notes} onChange={e => setNotes(e.target.value)} style={{ minHeight: 80 }} />
</div>
<div className="action-buttons">
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
Finalise & Send
</LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
</div>
</Layout>
);
}
+31 -9
View File
@@ -74,13 +74,13 @@ export function TeamInvoiceDetailPanel({
setSaving(true);
const updates = { status };
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
if (status !== 'paid') updates.paid_at = null;
if (status !== 'paid') {
updates.paid_at = null;
updates.stripe_fee = null; // reopened invoice must not carry a stale fee into the next payment
}
const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
if (!error) {
syncInvoiceState(i => ({ ...i, ...updates }));
// Sync task statuses along invoice lifecycle
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
// Task work status is not synced to invoice lifecycle.
// Per-version status is derived from invoice_items at display time.
if (status === 'paid') {
@@ -235,14 +235,36 @@ export function TeamInvoiceDetailPanel({
alert('Paid invoices cannot be deleted.');
return;
}
if (!window.confirm(`Delete invoice ${invoice?.invoice_number || ''}? Line items will be removed and any work not billed on another invoice becomes billable again.`)) return;
setSaving(true);
try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
// Un-bill only. Never touch task work status — it's independent of invoice lifecycle.
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
const taskIds = [...new Set((freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
const submissionIds = [...new Set((freshItems || []).filter(i => i.submission_id).map(i => i.submission_id))];
// Un-bill only, and only work that isn't also billed on another invoice.
// Never touch task work status — it's independent of invoice lifecycle.
if (taskIds.length > 0) {
const { data: elsewhere } = await supabase
.from('invoice_items')
.select('task_id, submission_id')
.in('task_id', taskIds)
.neq('invoice_id', invoiceId);
const billedElsewhere = new Set((elsewhere || []).filter(i => !i.submission_id).map(i => i.task_id));
const freeTaskIds = taskIds.filter(id => !billedElsewhere.has(id));
if (freeTaskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', freeTaskIds);
}
if (submissionIds.length > 0) {
const { data: elsewhere } = await supabase
.from('invoice_items')
.select('submission_id')
.in('submission_id', submissionIds)
.neq('invoice_id', invoiceId);
const billedElsewhere = new Set((elsewhere || []).map(i => i.submission_id));
const freeSubmissionIds = submissionIds.filter(id => !billedElsewhere.has(id));
if (freeSubmissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', freeSubmissionIds);
}
const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
if (error) throw error;
onInvoiceDeleted?.(invoiceId);
+14 -8
View File
@@ -178,8 +178,9 @@ function CurrencyInput({ value, onChange, placeholder = '0.00', required = false
);
}
const INV_TODAY = new Date().toISOString().split('T')[0];
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Computed fresh at each call so a tab left open overnight never back-dates an invoice
const invToday = () => new Date().toISOString().split('T')[0];
const invNet30 = () => new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
@@ -400,10 +401,12 @@ export default function Invoices() {
if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.');
setInvSaving(true);
try {
const year = new Date().getFullYear();
const { count } = await supabase.from('invoices').select('*', { count: 'exact', head: true }).gte('created_at', `${year}-01-01`);
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
const { data: invoice, error } = await supabase.from('invoices').insert({ company_id: invCompanyId, invoice_number: invoiceNumber, invoice_date: INV_TODAY, due_date: INV_NET30, status: status === 'sent' ? 'draft' : status, bill_to: invBillTo || null, invoice_email: invEmail.trim() || null, notes: invNotes || null, total: invTotal, created_by: currentUser?.id }).select().single();
const today = invToday();
const net30 = invNet30();
// DB function takes max+1 for the year under an advisory lock — no reuse after deletes, no race
const { data: invoiceNumber, error: numberError } = await supabase.rpc('next_invoice_number');
if (numberError || !invoiceNumber) throw numberError || new Error('Could not generate invoice number.');
const { data: invoice, error } = await supabase.from('invoices').insert({ company_id: invCompanyId, invoice_number: invoiceNumber, invoice_date: today, due_date: net30, status: status === 'sent' ? 'draft' : status, bill_to: invBillTo || null, invoice_email: invEmail.trim() || null, notes: invNotes || null, total: invTotal, created_by: currentUser?.id }).select().single();
if (error || !invoice) throw error || new Error('Invoice not created.');
const validItems = invItems.filter(i => i.description);
if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })));
@@ -411,19 +414,22 @@ export default function Invoices() {
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds);
let finalStatus = invoice.status;
if (status === 'sent') {
try {
const dueDate = new Date(INV_NET30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
let attachments = [];
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
const { error: sentError } = await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
if (!sentError) finalStatus = 'sent';
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
}
const createdInvoice = {
...invoice,
status: finalStatus,
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
};
setInvoices(prev => [createdInvoice, ...prev]);
+3 -2
View File
@@ -96,7 +96,7 @@ export default function TeamReports() {
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)'),
supabase
.from('subcontractor_invoices')
.select('status, items:subcontractor_invoice_items(task_id, description)')
.select('status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'approved', 'paid'])
]);
@@ -150,7 +150,8 @@ export default function TeamReports() {
const status = subInvoice.status;
for (const item of (subInvoice.items || [])) {
if (!item.task_id) continue;
const version = parseVersionFromItemDescription(item.description);
// Stored version preferred; legacy rows (null) fall back to description parsing
const version = item.version_number != null ? Number(item.version_number) : parseVersionFromItemDescription(item.description);
const key = `${item.task_id}:${version}`;
if (!subBillingGroups.has(key)) subBillingGroups.set(key, []);
subBillingGroups.get(key).push({ invoice_status: status });
+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;