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:
+2
-3
@@ -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>} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 A–Z</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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,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;
|
||||
Reference in New Issue
Block a user