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:
Krao Hasanee
2026-04-14 12:16:22 -04:00
parent 906a0041a4
commit d6e49a4c67
39 changed files with 6618 additions and 300 deletions
+283 -56
View File
@@ -4,6 +4,7 @@ import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
export default function CompanyDetail() {
const { id } = useParams();
@@ -19,6 +20,15 @@ export default function CompanyDetail() {
const [tab, setTab] = useState('users');
const [savingPrice, setSavingPrice] = useState(null);
const [assigning, setAssigning] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [savingProject, setSavingProject] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
load();
@@ -42,6 +52,33 @@ export default function CompanyDetail() {
setLoading(false);
}
const handleCompanyNameSave = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
setCompany(c => ({ ...c, name: nameVal.trim() }));
setEditingName(false);
setSavingName(false);
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
setUsers(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
setEditingUserId(null);
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. This cannot be undone.`)) return;
setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errMsg = data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setUsers(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null);
};
const handleAssignUser = async (userId) => {
setAssigning(true);
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
@@ -64,29 +101,65 @@ export default function CompanyDetail() {
}
};
const getPrice = (serviceType) => prices.find(p => p.service_type === serviceType)?.price ?? '';
const handleDeleteCompany = async () => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
await cleanupTaskStorage(tasks.map(t => t.id));
await supabase.from('companies').delete().eq('id', id);
navigate('/companies');
};
const handlePriceChange = (serviceType, value) => {
const handleDeleteProject = async (project) => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
await cleanupTaskStorage(projectTaskIds);
await supabase.from('projects').delete().eq('id', project.id);
setProjects(prev => prev.filter(p => p.id !== project.id));
setTasks(prev => prev.filter(t => t.project_id !== project.id));
};
const handleCreateProject = async (e) => {
e.preventDefault();
if (!newProjectName.trim()) return;
setSavingProject(true);
const { data } = await supabase.from('projects').insert({
company_id: id,
name: newProjectName.trim(),
status: 'active',
}).select().single();
if (data) {
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
}
setSavingProject(false);
};
const getPrice = (serviceType, priceType) =>
prices.find(p => p.service_type === serviceType && p.price_type === priceType)?.price ?? '';
const handlePriceChange = (serviceType, priceType, value) => {
setPrices(prev => {
const existing = prev.find(p => p.service_type === serviceType);
if (existing) return prev.map(p => p.service_type === serviceType ? { ...p, price: value } : p);
return [...prev, { service_type: serviceType, price: value, company_id: id }];
const existing = prev.find(p => p.service_type === serviceType && p.price_type === priceType);
if (existing) return prev.map(p => p.service_type === serviceType && p.price_type === priceType ? { ...p, price: value } : p);
return [...prev, { service_type: serviceType, price_type: priceType, price: value, company_id: id }];
});
};
const handlePriceSave = async (serviceType) => {
setSavingPrice(serviceType);
const priceVal = getPrice(serviceType);
const existing = prices.find(p => p.service_type === serviceType && p.id);
if (existing) {
const { error: updateError } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
if (updateError) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
} else {
const { data, error: insertError } = await supabase.from('company_prices').insert({
company_id: id, service_type: serviceType, price: Number(priceVal),
}).select().single();
if (insertError) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
if (data) setPrices(prev => prev.map(p => p.service_type === serviceType ? data : p));
for (const priceType of ['new', 'revision']) {
const priceVal = getPrice(serviceType, priceType);
const existing = prices.find(p => p.service_type === serviceType && p.price_type === priceType && p.id);
if (existing) {
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
} else if (priceVal !== '') {
const { data, error } = await supabase.from('company_prices').insert({
company_id: id, service_type: serviceType, price_type: priceType, price: Number(priceVal),
}).select().single();
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
if (data) setPrices(prev => [...prev.filter(p => !(p.service_type === serviceType && p.price_type === priceType && !p.id)), data]);
}
}
setSavingPrice(null);
};
@@ -103,7 +176,25 @@ export default function CompanyDetail() {
<div className="page-header">
<div>
<div className="page-title">{company.name}</div>
{editingName ? (
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
value={nameVal}
onChange={e => setNameVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{company.name}</div>
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(company.name); setEditingName(true); }}>Edit</button>
</div>
)}
<div className="page-subtitle">
{company.phone && <>{company.phone}</>}
{company.phone && company.address && ' · '}
@@ -111,7 +202,11 @@ export default function CompanyDetail() {
{!company.phone && !company.address && 'No contact info'}
</div>
</div>
<span className="badge badge-client" style={{ fontSize: 13, padding: '6px 14px' }}>Company</span>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
onClick={handleDeleteCompany}
>Delete Company</button>
</div>
<div className="stats-grid" style={{ marginBottom: 28 }}>
@@ -139,7 +234,7 @@ export default function CompanyDetail() {
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{['users', 'pricing'].map(t => (
{['users', 'projects', 'pricing'].map(t => (
<button
key={t}
onClick={() => setTab(t)}
@@ -176,7 +271,7 @@ export default function CompanyDetail() {
padding: '12px 0',
borderBottom: i < users.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1 }}>
<div style={{
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -184,18 +279,48 @@ export default function CompanyDetail() {
}}>
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
</>
)}
</div>
</div>
<button
className="btn btn-outline btn-sm"
style={{ fontSize: 11, color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleRemoveUser(user.id)}
>
Remove
</button>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
className="btn btn-outline btn-sm"
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
>Edit</button>
<button
className="btn btn-outline btn-sm"
onClick={() => handleRemoveUser(user.id)}
>Unassign</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? '...' : 'Delete'}
</button>
</div>
)}
</div>
))}
</div>
@@ -211,17 +336,41 @@ export default function CompanyDetail() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{unassigned.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
</>
)}
</div>
<button
className="btn btn-primary btn-sm"
onClick={() => handleAssignUser(user.id)}
disabled={assigning}
>
Assign to {company.name}
</button>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
Assign to {company.name}
</button>
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
<button
className="btn btn-outline btn-sm"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
</div>
)}
</div>
))}
</div>
@@ -230,6 +379,76 @@ export default function CompanyDetail() {
</div>
)}
{/* Projects Tab */}
{tab === 'projects' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
{showNewProject ? 'Cancel' : '+ New Project'}
</button>
</div>
{showNewProject && (
<div className="card">
<div className="card-title">New Project</div>
<form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<div className="form-group" style={{ flex: 1, marginBottom: 0 }}>
<label>Project Name *</label>
<input
type="text"
placeholder="e.g. Brand Identity 2026"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
required
autoFocus
/>
</div>
<button type="submit" className="btn btn-primary" disabled={savingProject || !newProjectName.trim()}>
{savingProject ? 'Creating...' : 'Create'}
</button>
</form>
</div>
)}
{projects.length === 0 ? (
<div className="empty-state">
<h3>No projects yet</h3>
<p>Create a project to start adding jobs.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => {
const projectTasks = tasks.filter(t => t.project_id === project.id);
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length;
return (
<div key={project.id} style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<Link to={`/projects/${project.id}`} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
{active > 0 && <> · <span style={{ color: 'var(--accent)' }}>{active} active</span></>}
{done > 0 && <> · {done} done</>}
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
</div>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
</Link>
<button
type="button"
onClick={() => handleDeleteProject(project)}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
title="Delete project"
>✕</button>
</div>
);
})}
</div>
)}
</div>
)}
{/* Pricing Tab */}
{tab === 'pricing' && (
<div className="card">
@@ -237,27 +456,35 @@ export default function CompanyDetail() {
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
Set prices per service type for this company. These auto-fill when creating an invoice.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<div />
{['New', 'Revision'].map(label => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
))}
<div />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{serviceTypes.map(serviceType => (
<div key={serviceType} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 14, fontWeight: 500, flex: 1 }}>{serviceType}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={getPrice(serviceType)}
onChange={e => handlePriceChange(serviceType, e.target.value)}
style={{ margin: 0, width: 90 }}
/>
</div>
<div key={serviceType} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{serviceType}</div>
{['new', 'revision'].map(priceType => (
<div key={priceType} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={getPrice(serviceType, priceType)}
onChange={e => handlePriceChange(serviceType, priceType, e.target.value)}
style={{ margin: 0, width: '100%', textAlign: 'right' }}
/>
</div>
))}
<button
className="btn btn-outline btn-sm"
onClick={() => handlePriceSave(serviceType)}
disabled={savingPrice === serviceType}
style={{ flexShrink: 0 }}
>
{savingPrice === serviceType ? '...' : 'Save'}
</button>