Add Stripe fee tracking on paid invoices + backfill function
- Store stripe_fee on invoices when webhook receives checkout.session.completed - Display Stripe fee and net received in InvoiceDetail when paid via Stripe - Add backfill-stripe-fees edge function to populate fee on existing paid invoices - Migration: add stripe_fee column to invoices table - Includes all pending portal changes (brand book, sign survey, task/project/company updates, etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
function newItem(description = '', unit_price = '', quantity = 1, task_id = null) {
|
||||
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id };
|
||||
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 };
|
||||
}
|
||||
|
||||
export default function CreateInvoice() {
|
||||
@@ -15,11 +15,14 @@ export default function CreateInvoice() {
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState('');
|
||||
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
|
||||
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
|
||||
const [priceList, setPriceList] = 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 dragOver = useRef(null);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const net30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
@@ -29,7 +32,7 @@ export default function CreateInvoice() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId) { setUninvoicedTasks([]); setPriceList([]); setItems([newItem()]); return; }
|
||||
if (!selectedCompanyId) { setUninvoicedTasks([]); setUninvoicedRevisions([]); setPriceList([]); setItems([newItem()]); return; }
|
||||
setLoadingTasks(true);
|
||||
Promise.all([
|
||||
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
|
||||
@@ -37,26 +40,68 @@ export default function CreateInvoice() {
|
||||
]).then(async ([{ data: projects }, { data: prices }]) => {
|
||||
setPriceList(prices || []);
|
||||
if (projects && projects.length > 0) {
|
||||
const { data: tasks } = await supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name)')
|
||||
.in('project_id', projects.map(p => p.id))
|
||||
.eq('invoiced', false);
|
||||
setUninvoicedTasks(tasks || []);
|
||||
const projectIds = projects.map(p => p.id);
|
||||
const [{ data: tasks }, { data: revisions }] = await Promise.all([
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false),
|
||||
supabase
|
||||
.from('submissions')
|
||||
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
|
||||
.eq('type', 'revision')
|
||||
.or('revision_type.eq.client_revision,revision_type.is.null')
|
||||
.eq('invoiced', false)
|
||||
.in('task_id',
|
||||
(await supabase.from('tasks').select('id').in('project_id', projectIds)).data?.map(t => t.id) || []
|
||||
),
|
||||
]);
|
||||
const tasksWithService = (tasks || []).map(t => {
|
||||
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
||||
return { ...t, service_type: initial?.service_type || t.title };
|
||||
});
|
||||
setUninvoicedTasks(tasksWithService);
|
||||
setUninvoicedRevisions(revisions || []);
|
||||
} else {
|
||||
setUninvoicedTasks([]);
|
||||
setUninvoicedRevisions([]);
|
||||
}
|
||||
setLoadingTasks(false);
|
||||
});
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const addTaskAsItem = (task) => {
|
||||
const price = priceList.find(p => p.service_type === task.title);
|
||||
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||||
const description = task.service_type && task.service_type !== task.title
|
||||
? `${task.service_type} — ${task.title}`
|
||||
: task.title;
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(task.title, price?.price || '', 1, task.id)];
|
||||
return [newItem(description, price?.price || '', 1, task.id)];
|
||||
}
|
||||
return [...prev, newItem(task.title, price?.price || '', 1, task.id)];
|
||||
return [...prev, newItem(description, price?.price || '', 1, task.id)];
|
||||
});
|
||||
};
|
||||
|
||||
const getRevisionServiceType = (revision) => {
|
||||
const initial = (revision.task?.submissions || []).find(s => s.type === 'initial') || (revision.task?.submissions || [])[0];
|
||||
return initial?.service_type || revision.service_type || revision.task?.title || 'Revision';
|
||||
};
|
||||
|
||||
const addRevisionAsItem = (revision) => {
|
||||
const versionLabel = 'v' + String((revision.version_number || 1) - 1).padStart(2, '0');
|
||||
const serviceLabel = getRevisionServiceType(revision);
|
||||
const taskTitle = revision.task?.title;
|
||||
const description = serviceLabel && taskTitle && serviceLabel !== taskTitle
|
||||
? `${serviceLabel} — ${taskTitle} — Revision ${versionLabel}`
|
||||
: `${serviceLabel || taskTitle || 'Revision'} — Revision ${versionLabel}`;
|
||||
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||||
}
|
||||
return [...prev, newItem(description, price?.price || '', 1, revision.task_id, revision.id)];
|
||||
});
|
||||
};
|
||||
|
||||
@@ -66,6 +111,27 @@ export default function CreateInvoice() {
|
||||
|
||||
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) => {
|
||||
@@ -96,6 +162,7 @@ export default function CreateInvoice() {
|
||||
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,
|
||||
@@ -103,11 +170,18 @@ export default function CreateInvoice() {
|
||||
);
|
||||
}
|
||||
|
||||
const taskIds = validItems.filter(i => i.task_id).map(i => i.task_id);
|
||||
// Mark tasks as invoiced (only items without a submission_id are base task items)
|
||||
const taskIds = validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) {
|
||||
await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
||||
}
|
||||
|
||||
// Mark client revisions as invoiced
|
||||
const submissionIds = validItems.filter(i => i.submission_id).map(i => i.submission_id);
|
||||
if (submissionIds.length > 0) {
|
||||
await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
|
||||
}
|
||||
|
||||
navigate(`/invoices/${invoice.id}`, { state: { invoice } });
|
||||
};
|
||||
|
||||
@@ -123,54 +197,109 @@ export default function CreateInvoice() {
|
||||
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
|
||||
<button className="btn btn-primary" onClick={() => handleSave('sent')} disabled={saving}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleSave('sent')} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Finalise & Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ gap: 24, marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<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 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 style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600 }}>{selectedCompany.name}</div>
|
||||
</div>
|
||||
{selectedCompany && (
|
||||
<div style={{ padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)', fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600 }}>{selectedCompany.name}</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: 6, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{task.service_type && task.service_type !== task.title ? `${task.service_type} — ${task.title}` : task.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{task.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</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>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Uninvoiced Requests</div>
|
||||
{!selectedCompanyId ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their uninvoiced requests.</p>
|
||||
) : loadingTasks ? (
|
||||
{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>
|
||||
) : uninvoicedTasks.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No uninvoiced requests found.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{uninvoicedTasks.map(task => {
|
||||
const price = priceList.find(p => p.service_type === task.title);
|
||||
const alreadyAdded = items.some(i => i.task_id === task.id);
|
||||
{uninvoicedRevisions.map(rev => {
|
||||
const versionLabel = 'v' + String((rev.version_number || 1) - 1).padStart(2, '0');
|
||||
const revServiceType = getRevisionServiceType(rev);
|
||||
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
|
||||
const alreadyAdded = items.some(i => i.submission_id === rev.id);
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{task.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{task.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{revServiceType && rev.task?.title && revServiceType !== rev.task?.title
|
||||
? `${revServiceType} — ${rev.task.title} — Revision ${versionLabel}`
|
||||
: `${revServiceType || rev.task?.title || 'Revision'} — Revision ${versionLabel}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{rev.task?.project?.name} · {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</div>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
||||
onClick={() => !alreadyAdded && addTaskAsItem(task)}
|
||||
onClick={() => !alreadyAdded && addRevisionAsItem(rev)}
|
||||
disabled={alreadyAdded}
|
||||
>
|
||||
{alreadyAdded ? 'Added' : '+ Add'}
|
||||
@@ -181,20 +310,47 @@ export default function CreateInvoice() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
<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: 6, 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: '1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||
{['Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
|
||||
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 1 ? 'right' : 'left' }}>{h}</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: 600, 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 => (
|
||||
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}>
|
||||
{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' }} />
|
||||
|
||||
Reference in New Issue
Block a user