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
@@ -0,0 +1,74 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Find all paid invoices that don't have a stripe_fee yet
const { data: invoices, error } = await supabase
.from('invoices')
.select('id, invoice_number, total')
.eq('status', 'paid')
.is('stripe_fee', null);
if (error) {
return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders });
}
if (!invoices?.length) {
return new Response(JSON.stringify({ message: 'No invoices missing fees', updated: 0 }), { headers: corsHeaders });
}
const results: { invoice_id: string; invoice_number: string; status: string; fee?: number }[] = [];
for (const invoice of invoices) {
try {
// Search Stripe checkout sessions by invoice_id metadata
const sessions = await stripe.checkout.sessions.search({
query: `metadata["invoice_id"]:"${invoice.id}"`,
limit: 1,
expand: ['data.payment_intent.latest_charge.balance_transaction'],
});
const session = sessions.data[0];
if (!session) {
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'no_session' });
continue;
}
const paymentIntent = session.payment_intent as Stripe.PaymentIntent | null;
const charge = paymentIntent?.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee == null) {
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'no_balance_tx' });
continue;
}
const stripe_fee = balanceTx.fee / 100;
await supabase.from('invoices').update({ stripe_fee }).eq('id', invoice.id);
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'updated', fee: stripe_fee });
} catch (err) {
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: `error: ${err.message}` });
}
}
const updated = results.filter(r => r.status === 'updated').length;
return new Response(
JSON.stringify({ updated, total: invoices.length, results }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
);
});
@@ -0,0 +1,56 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
serve(async (req) => {
const body = await req.text();
const sig = req.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(body, sig!, webhookSecret);
} catch (err) {
console.error('Webhook signature failed:', err.message);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const invoice_id = session.metadata?.invoice_id;
if (invoice_id) {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Retrieve the Stripe processing fee from the balance transaction
let stripe_fee: number | null = null;
try {
const paymentIntentId = session.payment_intent as string;
if (paymentIntentId) {
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge.balance_transaction'],
});
const charge = paymentIntent.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee != null) {
stripe_fee = balanceTx.fee / 100;
}
}
} catch (err) {
console.error('Failed to retrieve Stripe fee:', err.message);
}
const updateData: Record<string, unknown> = { status: 'paid' };
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
});
@@ -0,0 +1,2 @@
-- Add stripe_fee column to store the Stripe processing fee on paid invoices
alter table public.invoices add column if not exists stripe_fee numeric(10,2);
@@ -0,0 +1,13 @@
-- Add cover page fields to brand_books table
ALTER TABLE brand_books
ADD COLUMN IF NOT EXISTS creation_date date,
ADD COLUMN IF NOT EXISTS revision_date date,
ADD COLUMN IF NOT EXISTS customer_name text,
ADD COLUMN IF NOT EXISTS customer_address text,
ADD COLUMN IF NOT EXISTS project_logo_path text,
ADD COLUMN IF NOT EXISTS client_logo_url text,
ADD COLUMN IF NOT EXISTS client_contact_name text,
ADD COLUMN IF NOT EXISTS client_contact_email text,
ADD COLUMN IF NOT EXISTS client_contact_phone text,
ADD COLUMN IF NOT EXISTS approved_date date,
ADD COLUMN IF NOT EXISTS approval_notes text;
@@ -0,0 +1,4 @@
-- Add template and inventory map fields to brand_books table
ALTER TABLE brand_books
ADD COLUMN IF NOT EXISTS template text DEFAULT 'fourge',
ADD COLUMN IF NOT EXISTS inventory_map_path text;
@@ -0,0 +1,29 @@
-- Add brand book / cover page fields to companies
ALTER TABLE companies
ADD COLUMN IF NOT EXISTS address text,
ADD COLUMN IF NOT EXISTS contact_name text,
ADD COLUMN IF NOT EXISTS contact_email text,
ADD COLUMN IF NOT EXISTS contact_phone text,
ADD COLUMN IF NOT EXISTS client_logo_url text;
-- Create public bucket for company logos
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM storage.buckets WHERE id = 'company-logos') THEN
INSERT INTO storage.buckets (id, name, public) VALUES ('company-logos', 'company-logos', true);
END IF;
END $$;
-- Storage policies for company-logos
DROP POLICY IF EXISTS "Authenticated users can manage company logos" ON storage.objects;
DROP POLICY IF EXISTS "Public can read company logos" ON storage.objects;
CREATE POLICY "Authenticated users can manage company logos"
ON storage.objects FOR ALL
TO authenticated
USING (bucket_id = 'company-logos')
WITH CHECK (bucket_id = 'company-logos');
CREATE POLICY "Public can read company logos"
ON storage.objects FOR SELECT
USING (bucket_id = 'company-logos');
+123
View File
@@ -0,0 +1,123 @@
-- ============================================================
-- Migration: Add external role and project_members table
-- Run this in Supabase → SQL Editor → Run
-- ============================================================
-- 1. Update profiles.role check constraint to include 'external'
do $$
declare
cname text;
begin
select constraint_name into cname
from information_schema.table_constraints
where table_schema = 'public'
and table_name = 'profiles'
and constraint_type = 'CHECK'
and constraint_name ilike '%role%';
if cname is not null then
execute 'alter table public.profiles drop constraint ' || quote_ident(cname);
end if;
end;
$$;
alter table public.profiles
add constraint profiles_role_check check (role in ('team', 'client', 'external'));
-- 2. project_members table
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;
-- 3. Helper function
create or replace function public.is_external()
returns boolean as $$
select get_my_role() = 'external';
$$ language sql security definer stable;
-- 4. RLS: 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());
-- 5. RLS: projects (external reads assigned only)
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())
);
-- 6. RLS: tasks (external reads + updates assigned projects)
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())
);
-- 7. RLS: submissions
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()
);
-- 8. RLS: submission_files
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');
-- 9. RLS: deliveries (read only)
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()
)
);
-- 10. RLS: delivery_files (read only)
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()
)
);
-- 11. RLS: profiles (external reads own profile only — already covered by existing policy)
-- "Own profile select" policy already handles this with: id = auth.uid()
-- No additional policy needed.
+17
View File
@@ -0,0 +1,17 @@
-- ============================================================
-- Migration: Add price_type to company_prices (new vs revision)
-- Run in Supabase → SQL Editor → Run
-- ============================================================
-- Add price_type column (existing rows default to 'new')
alter table public.company_prices
add column price_type text not null default 'new'
check (price_type in ('new', 'revision'));
-- Drop old unique constraint and add new one that includes price_type
alter table public.company_prices
drop constraint company_prices_company_id_service_type_key;
alter table public.company_prices
add constraint company_prices_company_id_service_type_price_type_key
unique (company_id, service_type, price_type);
@@ -0,0 +1,15 @@
-- ============================================================
-- Migration: Add revision billing tracking
-- Run in Supabase → SQL Editor → Run
-- ============================================================
-- Add revision_type and invoiced to submissions
alter table public.submissions
add column revision_type text check (revision_type in ('fourge_error', 'client_revision'));
alter table public.submissions
add column invoiced boolean not null default false;
-- Add submission_id to invoice_items (links a line item to a specific revision)
alter table public.invoice_items
add column submission_id uuid references public.submissions(id) on delete set null;
+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