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
+308 -15
View File
@@ -9,6 +9,10 @@ create table public.companies (
name text not null,
phone text default '',
address text default '',
contact_name text,
contact_email text,
contact_phone text,
client_logo_url text,
created_at timestamptz default now() not null
);
alter table public.companies enable row level security;
@@ -18,7 +22,7 @@ create table public.profiles (
id uuid references auth.users on delete cascade primary key,
name text not null default '',
email text default '',
role text not null check (role in ('team', 'client')) default 'client',
role text not null check (role in ('team', 'client', 'external')) default 'client',
company_id uuid references public.companies(id) on delete set null,
created_at timestamptz default now() not null
);
@@ -56,6 +60,8 @@ create table public.submissions (
task_id uuid references public.tasks(id) on delete cascade not null,
version_number integer not null,
type text not null check (type in ('initial', 'revision', 'amendment')) default 'initial',
revision_type text check (revision_type in ('fourge_error', 'client_revision')),
invoiced boolean not null default false,
service_type text default '',
deadline date,
description text default '',
@@ -95,13 +101,24 @@ create table public.delivery_files (
);
alter table public.delivery_files enable row level security;
-- Company Prices (per service type, per company)
-- Project Members (external users assigned to specific projects)
create table public.project_members (
id uuid default gen_random_uuid() primary key,
project_id uuid references public.projects(id) on delete cascade not null,
profile_id uuid references public.profiles(id) on delete cascade not null,
created_at timestamptz default now() not null,
unique(project_id, profile_id)
);
alter table public.project_members enable row level security;
-- Company Prices (per service type, per company, per price type)
create table public.company_prices (
id uuid default gen_random_uuid() primary key,
company_id uuid references public.companies(id) on delete cascade not null,
service_type text not null,
price numeric(10,2) default 0,
unique(company_id, service_type)
price_type text not null default 'new' check (price_type in ('new', 'revision')),
unique(company_id, service_type, price_type)
);
alter table public.company_prices enable row level security;
@@ -115,6 +132,7 @@ create table public.invoices (
due_date date,
total numeric(10,2) default 0,
notes text default '',
stripe_fee numeric(10,2),
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz default now() not null
);
@@ -125,6 +143,7 @@ create table public.invoice_items (
id uuid default gen_random_uuid() primary key,
invoice_id uuid references public.invoices(id) on delete cascade not null,
task_id uuid references public.tasks(id) on delete set null,
submission_id uuid references public.submissions(id) on delete set null,
description text not null,
quantity numeric(10,2) default 1,
unit_price numeric(10,2) default 0,
@@ -132,6 +151,63 @@ create table public.invoice_items (
);
alter table public.invoice_items enable row level security;
-- Brand Books (sign survey / brand book records, team only)
create table public.brand_books (
id uuid default gen_random_uuid() primary key,
client_id uuid references public.companies(id) on delete set null,
client_name text not null default '',
project_name text default '',
site_address text default '',
book_date date,
prepared_by text default '',
revision text default '',
template text default 'fourge',
site_map_path text,
inventory_map_path text,
signs jsonb default '[]',
survey_photo_paths text[] default '{}',
updated_at timestamptz default now() not null,
-- Cover page fields
project_logo_path text,
creation_date date,
revision_date date,
customer_name text,
customer_address text,
client_logo_url text,
client_contact_name text,
client_contact_email text,
client_contact_phone text,
approved_date date,
approval_notes text
);
alter table public.brand_books enable row level security;
-- Server Status Overrides (team-managed usage inputs for metrics without live APIs)
create table public.server_status_overrides (
id boolean primary key default true,
supabase_egress_bytes bigint,
vercel_fast_data_transfer_bytes bigint,
vercel_edge_requests bigint,
vercel_function_invocations bigint,
vercel_active_cpu_hours numeric(10,4),
updated_at timestamptz not null default now()
);
alter table public.server_status_overrides enable row level security;
create table public.fourge_passwords (
id uuid default gen_random_uuid() primary key,
service_name text not null,
service_url text default '',
username text not null default '',
encrypted_password text not null,
password_iv text not null,
notes text default '',
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.fourge_passwords enable row level security;
-- ============================================================
-- Helpers
-- ============================================================
@@ -141,11 +217,48 @@ returns text as $$
select role from public.profiles where id = auth.uid();
$$ language sql security definer stable;
create or replace function public.is_external()
returns boolean as $$
select get_my_role() = 'external';
$$ language sql security definer stable;
create or replace function public.get_my_company_id()
returns uuid as $$
select company_id from public.profiles where id = auth.uid();
$$ language sql security definer stable;
create or replace function public.get_database_size_bytes()
returns bigint as $$
select pg_database_size(current_database());
$$ language sql security definer stable;
-- Prevents clients and externals from modifying protected task fields.
-- Fires before UPDATE so WITH CHECK sees the already-corrected new row.
create or replace function public.guard_task_update()
returns trigger as $$
declare
caller_role text;
begin
select role into caller_role from public.profiles where id = auth.uid();
if caller_role = 'client' then
new.project_id := old.project_id;
new.assigned_to := old.assigned_to;
new.assigned_name := old.assigned_name;
new.invoiced := old.invoiced;
elsif caller_role = 'external' then
new.project_id := old.project_id;
new.invoiced := old.invoiced;
end if;
return new;
end;
$$ language plpgsql security definer;
create trigger guard_task_update
before update on public.tasks
for each row execute function public.guard_task_update();
-- ============================================================
-- RLS Policies
-- ============================================================
@@ -153,17 +266,36 @@ $$ language sql security definer stable;
-- Companies
create policy "Team all companies" on public.companies for all using (get_my_role() = 'team');
create policy "Client reads own company" on public.companies for select using (id = get_my_company_id());
create policy "Client updates own company" on public.companies
for update using (id = get_my_company_id()) with check (id = get_my_company_id());
-- Project Members
create policy "Team all project_members" on public.project_members for all using (get_my_role() = 'team');
create policy "External reads own memberships" on public.project_members for select using (profile_id = auth.uid());
-- Profiles
create policy "Own profile select" on public.profiles for select using (id = auth.uid());
create policy "Team reads all profiles" on public.profiles for select using (get_my_role() = 'team');
create policy "Own profile update" on public.profiles for update using (id = auth.uid());
create policy "Own profile update" on public.profiles
for update
using (id = auth.uid())
with check (
id = auth.uid()
and role = (select role from public.profiles where id = auth.uid())
and company_id is not distinct from (select company_id from public.profiles where id = auth.uid())
);
create policy "Team update profiles" on public.profiles for update using (get_my_role() = 'team');
-- Projects
create policy "Team all projects" on public.projects for all using (get_my_role() = 'team');
create policy "Client reads company projects" on public.projects for select using (company_id = get_my_company_id());
create policy "Client inserts company projects" on public.projects for insert with check (company_id = get_my_company_id());
create policy "Client updates own company projects" on public.projects
for update using (company_id = get_my_company_id()) with check (company_id = get_my_company_id());
create policy "External reads assigned projects" on public.projects for select using (
get_my_role() = 'external' and
id in (select project_id from public.project_members where profile_id = auth.uid())
);
-- Tasks
create policy "Team all tasks" on public.tasks for all using (get_my_role() = 'team');
@@ -173,9 +305,30 @@ create policy "Client reads company tasks" on public.tasks for select using (
create policy "Client insert task" on public.tasks for insert with check (
project_id in (select id from public.projects where company_id = get_my_company_id())
);
create policy "Client updates company tasks" on public.tasks for update using (
project_id in (select id from public.projects where company_id = get_my_company_id())
create policy "Client updates company tasks" on public.tasks
for update
using (
get_my_role() = 'client'
and project_id in (select id from public.projects where company_id = get_my_company_id())
)
with check (
get_my_role() = 'client'
and project_id in (select id from public.projects where company_id = get_my_company_id())
);
create policy "External reads assigned tasks" on public.tasks for select using (
get_my_role() = 'external' and
project_id in (select project_id from public.project_members where profile_id = auth.uid())
);
create policy "External updates assigned tasks" on public.tasks
for update
using (
get_my_role() = 'external'
and project_id in (select project_id from public.project_members where profile_id = auth.uid())
)
with check (
get_my_role() = 'external'
and project_id in (select project_id from public.project_members where profile_id = auth.uid())
);
-- Submissions
create policy "Team all submissions" on public.submissions for all using (get_my_role() = 'team');
@@ -186,7 +339,32 @@ create policy "Client reads company submissions" on public.submissions for selec
where p.company_id = get_my_company_id()
)
);
create policy "Client inserts submissions" on public.submissions for insert with check (submitted_by = auth.uid());
create policy "Client inserts submissions" on public.submissions for insert with check (
get_my_role() = 'client'
and submitted_by = auth.uid()
and task_id in (
select t.id from public.tasks t
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
)
);
create policy "External reads assigned submissions" on public.submissions for select using (
get_my_role() = 'external' and
task_id in (
select t.id from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
create policy "External inserts submissions" on public.submissions for insert with check (
get_my_role() = 'external'
and submitted_by = auth.uid()
and task_id in (
select t.id from public.tasks t
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- Submission Files
create policy "Team all submission_files" on public.submission_files for all using (get_my_role() = 'team');
@@ -198,7 +376,35 @@ create policy "Client reads company submission_files" on public.submission_files
where p.company_id = get_my_company_id()
)
);
create policy "Client inserts submission_files" on public.submission_files for insert with check (true);
create policy "Client inserts submission_files" on public.submission_files for insert with check (
get_my_role() = 'client'
and submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.projects p on p.id = t.project_id
where p.company_id = get_my_company_id()
and s.submitted_by = auth.uid()
)
);
create policy "External reads assigned submission_files" on public.submission_files for select using (
get_my_role() = 'external' and
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
create policy "External inserts submission_files" on public.submission_files for insert with check (
get_my_role() = 'external'
and submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
and s.submitted_by = auth.uid()
)
);
-- Deliveries
create policy "Team all deliveries" on public.deliveries for all using (get_my_role() = 'team');
@@ -210,6 +416,15 @@ create policy "Client reads company deliveries" on public.deliveries for select
where p.company_id = get_my_company_id()
)
);
create policy "External reads assigned deliveries" on public.deliveries for select using (
get_my_role() = 'external' and
submission_id in (
select s.id from public.submissions s
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- Delivery Files
create policy "Team all delivery_files" on public.delivery_files for all using (get_my_role() = 'team');
@@ -222,6 +437,16 @@ create policy "Client reads company delivery_files" on public.delivery_files for
where p.company_id = get_my_company_id()
)
);
create policy "External reads assigned delivery_files" on public.delivery_files for select using (
get_my_role() = 'external' and
delivery_id in (
select d.id from public.deliveries d
join public.submissions s on s.id = d.submission_id
join public.tasks t on t.id = s.task_id
join public.project_members pm on pm.project_id = t.project_id
where pm.profile_id = auth.uid()
)
);
-- Company Prices
create policy "Team all company_prices" on public.company_prices for all using (get_my_role() = 'team');
@@ -237,21 +462,89 @@ create policy "Client reads company invoice_items" on public.invoice_items for s
invoice_id in (select id from public.invoices where company_id = get_my_company_id())
);
-- Brand Books
create policy "Team all brand_books" on public.brand_books for all using (get_my_role() = 'team');
create policy "Team all server_status_overrides" on public.server_status_overrides for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
create policy "Team all fourge_passwords" on public.fourge_passwords for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
-- ============================================================
-- Storage Buckets
-- ============================================================
insert into storage.buckets (id, name, public) values ('submissions', 'submissions', false);
insert into storage.buckets (id, name, public) values ('deliveries', 'deliveries', false);
insert into storage.buckets (id, name, public) values ('company-logos', 'company-logos', true);
insert into storage.buckets (id, name, public) values ('fourge-files', 'fourge-files', false);
create policy "Auth users upload to submissions" on storage.objects
for insert to authenticated with check (bucket_id = 'submissions');
create policy "Auth users read submissions" on storage.objects
for select to authenticated using (bucket_id = 'submissions');
-- Company Logos (public bucket — team manages, public can read for embedded URLs in PDFs)
create policy "Team manages company logos" on storage.objects
for all to authenticated
using (bucket_id = 'company-logos' and get_my_role() = 'team')
with check (bucket_id = 'company-logos' and get_my_role() = 'team');
create policy "Public can read company logos" on storage.objects
for select using (bucket_id = 'company-logos');
create policy "Team upload deliveries" on storage.objects
-- Submissions: SELECT
create policy "Team reads submissions storage" on storage.objects
for select to authenticated using (bucket_id = 'submissions' and get_my_role() = 'team');
create policy "Client reads submissions storage" on storage.objects
for select to authenticated using (
bucket_id = 'submissions' and get_my_role() = 'client'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.projects p on p.id = t.project_id where p.company_id = get_my_company_id())
);
create policy "External reads submissions storage" on storage.objects
for select to authenticated using (
bucket_id = 'submissions' and get_my_role() = 'external'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.project_members pm on pm.project_id = t.project_id where pm.profile_id = auth.uid())
);
-- Submissions: INSERT
create policy "Team inserts submissions storage" on storage.objects
for insert to authenticated with check (bucket_id = 'submissions' and get_my_role() = 'team');
create policy "Client inserts submissions storage" on storage.objects
for insert to authenticated with check (
bucket_id = 'submissions' and get_my_role() = 'client'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.projects p on p.id = t.project_id where p.company_id = get_my_company_id())
);
create policy "External inserts submissions storage" on storage.objects
for insert to authenticated with check (
bucket_id = 'submissions' and get_my_role() = 'external'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.project_members pm on pm.project_id = t.project_id where pm.profile_id = auth.uid())
);
-- Submissions: DELETE (team only)
create policy "Team deletes submissions storage" on storage.objects
for delete to authenticated using (bucket_id = 'submissions' and get_my_role() = 'team');
-- Deliveries: SELECT
create policy "Team reads deliveries storage" on storage.objects
for select to authenticated using (bucket_id = 'deliveries' and get_my_role() = 'team');
create policy "Client reads deliveries storage" on storage.objects
for select to authenticated using (
bucket_id = 'deliveries' and get_my_role() = 'client'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.projects p on p.id = t.project_id where p.company_id = get_my_company_id())
);
create policy "External reads deliveries storage" on storage.objects
for select to authenticated using (
bucket_id = 'deliveries' and get_my_role() = 'external'
and split_part(name, '/', 1) in (select t.id::text from public.tasks t join public.project_members pm on pm.project_id = t.project_id where pm.profile_id = auth.uid())
);
-- Deliveries: INSERT + DELETE (team only)
create policy "Team inserts deliveries storage" on storage.objects
for insert to authenticated with check (bucket_id = 'deliveries' and get_my_role() = 'team');
create policy "Auth users read deliveries" on storage.objects
for select to authenticated using (bucket_id = 'deliveries');
create policy "Team deletes deliveries storage" on storage.objects
for delete to authenticated using (bucket_id = 'deliveries' and get_my_role() = 'team');
-- Fourge Files: internal team-only company documents
create policy "Team reads fourge files storage" on storage.objects
for select to authenticated using (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team inserts fourge files storage" on storage.objects
for insert to authenticated with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team updates fourge files storage" on storage.objects
for update to authenticated using (bucket_id = 'fourge-files' and get_my_role() = 'team')
with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
create policy "Team deletes fourge files storage" on storage.objects
for delete to authenticated using (bucket_id = 'fourge-files' and get_my_role() = 'team');
-- ============================================================
-- Trigger: auto-create profile on signup