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
+52 -17
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
const MAX_FILES = 20;
const MAX_SIZE_MB = 10;
@@ -11,24 +11,48 @@ const formatSize = (bytes) => {
export default function FileAttachment({ files, onChange }) {
const [errors, setErrors] = useState([]);
const [dragging, setDragging] = useState(false);
const dragCounter = useRef(0);
const handleChange = (e) => {
const incoming = Array.from(e.target.files);
const processFiles = (incoming) => {
const combined = [...files, ...incoming];
const errs = [];
if (combined.length > MAX_FILES) {
errs.push(`Maximum ${MAX_FILES} files allowed.`);
}
incoming.filter(f => f.size > MAX_SIZE_BYTES)
.forEach(f => errs.push(`"${f.name}" exceeds ${MAX_SIZE_MB} MB limit.`));
if (combined.length > MAX_FILES) errs.push(`Maximum ${MAX_FILES} files allowed.`);
incoming.filter(f => f.size > MAX_SIZE_BYTES).forEach(f => errs.push(`"${f.name}" exceeds ${MAX_SIZE_MB} MB limit.`));
if (errs.length > 0) { setErrors(errs); return; }
setErrors([]);
onChange(combined);
};
const handleChange = (e) => {
processFiles(Array.from(e.target.files));
e.target.value = '';
};
const handleDragEnter = (e) => {
e.preventDefault();
dragCounter.current++;
setDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current === 0) setDragging(false);
};
const handleDragOver = (e) => {
e.preventDefault();
};
const handleDrop = (e) => {
e.preventDefault();
dragCounter.current = 0;
setDragging(false);
const dropped = Array.from(e.dataTransfer.files);
if (dropped.length > 0) processFiles(dropped);
};
const remove = (index) => {
setErrors([]);
onChange(files.filter((_, i) => i !== index));
@@ -43,16 +67,27 @@ export default function FileAttachment({ files, onChange }) {
</span>
</label>
<div style={{
border: `2px dashed ${files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
background: 'var(--bg)', transition: 'all 0.15s',
}}>
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
transition: 'all 0.15s',
}}
>
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}>
<div style={{ fontSize: 22, marginBottom: 4 }}>📎</div>
<div style={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
{files.length > 0 ? `${files.length} file${files.length !== 1 ? 's' : ''} attached — click to add more` : 'Click to attach files'}
{dragging
? 'Drop files here'
: files.length > 0
? `${files.length} file${files.length !== 1 ? 's' : ''} attached — click or drag to add more`
: 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
</label>