diff --git a/supabase/migrations/20260807002000_reporting_view_fixes.sql b/supabase/migrations/20260807002000_reporting_view_fixes.sql deleted file mode 100644 index 404f784..0000000 --- a/supabase/migrations/20260807002000_reporting_view_fixes.sql +++ /dev/null @@ -1,190 +0,0 @@ --- Three fixes to the section 14 reporting views. --- --- All three are the same class of fault: a number or a row that reads as a --- fact when it is really an absence. None of them widens what anyone may see. - --- ============================================================================ --- 0. Recognising a caller that bypasses RLS --- ============================================================================ --- --- True for the service role used by server-side code, and for a direct --- superuser psql session (where `role` is unset). --- --- This MUST read the `role` GUC rather than current_user. Inside a --- SECURITY DEFINER function current_user is the function's owner, so --- pg_has_role(current_user, 'service_role', ...) is true for every caller and --- silently turns an entitlement check into a no-op — which is exactly the trap --- the checks below would otherwise fall into. -CREATE OR REPLACE FUNCTION public.is_service_context() -RETURNS BOOLEAN -LANGUAGE sql STABLE AS $$ - SELECT COALESCE(current_setting('role', true), 'none') IN ('service_role', 'none'); -$$; - -GRANT EXECUTE ON FUNCTION public.is_service_context() TO authenticated; - --- ============================================================================ --- 1. A billing administrator could not read their own receivables report --- ============================================================================ --- --- v_billing_detail joined public.students purely to build a display name, but --- can_access_student() admits org admins, auditors, parents, a student's own --- teacher and campus staff — and NOT billing admins, even though the invoices --- policy grants them every invoice in the organisation. The inner join then --- dropped every row, so the receivables report came back empty for exactly the --- role that exists to run it, with no error to explain why. --- --- The tempting fix is to add is_billing_admin() to can_access_student(). That --- would also hand finance staff allergies, chronic_conditions, special_needs, --- disciplinary_history, custody_agreement and primary_physician, which they --- have no business reading. So the name is resolved on its own instead, and --- student row access is left exactly as it was. -CREATE OR REPLACE FUNCTION public.student_display_name(_student UUID) -RETURNS TEXT -LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT s.first_name || ' ' || s.last_name - FROM public.students s - WHERE s.id = _student - -- SECURITY DEFINER bypasses the students policy, so the entitlement is - -- re-checked here. Without this an authenticated caller could resolve any - -- student's name by guessing uuids. The service_role arm keeps server-side - -- rendering working, since it holds no app role and would otherwise get NULL. - AND (public.can_access_student(_student) - OR public.is_billing_admin() - OR public.is_auditor() - OR public.is_service_context()); -$$; - -GRANT EXECUTE ON FUNCTION public.student_display_name(UUID) TO authenticated; - --- Unfunded scholarship money per campus. Pulled out of the summary view for the --- same reason: it joined students to reach primary_campus_id and so returned 0 --- for a billing admin. Callers are gated in the view below, not here. -CREATE OR REPLACE FUNCTION public.campus_pending_scholarship_cents(_campus UUID) -RETURNS BIGINT -LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT COALESCE(SUM(sc.award_amount_cents), 0)::BIGINT - FROM public.scholarships sc - JOIN public.students st ON st.id = sc.student_id - WHERE st.primary_campus_id = _campus - AND sc.status = 'active' - AND NOT EXISTS ( - SELECT 1 FROM public.payments p - WHERE p.scholarship_id = sc.id AND p.status = 'posted'); -$$; - -GRANT EXECUTE ON FUNCTION public.campus_pending_scholarship_cents(UUID) TO authenticated; - --- Rebuilt without the students join. Row visibility still comes entirely from --- the invoices policy via security_invoker — only the name lookup changed. -CREATE OR REPLACE VIEW public.v_billing_detail -WITH (security_invoker = true) AS -SELECT - i.id AS invoice_id, - i.invoice_number, - i.student_id, - public.student_display_name(i.student_id) AS student_name, - i.household_id, - h.name AS household_name, - i.campus_id, - c.name AS campus_name, - i.billing_period_start, - i.billing_period_end, - to_char(i.billing_period_start, 'IYYY-"W"IW') AS billing_week, - i.invoice_date, - i.due_date, - i.tuition_tier_id, - t.name AS tuition_tier, - i.attendance_basis, - i.status, - i.total_cents, - i.amount_paid_cents, - i.balance_due_cents, - i.scholarship_cents, - i.penalties_cents, - i.payment_plan_id, - (i.payment_plan_id IS NOT NULL) AS on_payment_plan, - (i.scholarship_cents > 0) AS has_scholarship, - (i.status IN ('issued','partially_paid') AND i.balance_due_cents > 0 - AND i.due_date < CURRENT_DATE) AS is_past_due, - GREATEST(CURRENT_DATE - i.due_date, 0) AS days_overdue -FROM public.invoices i -LEFT JOIN public.households h ON h.id = i.household_id -LEFT JOIN public.campuses c ON c.id = i.campus_id -LEFT JOIN public.tuition_tiers t ON t.id = i.tuition_tier_id -WHERE i.status <> 'void'; - -GRANT SELECT ON public.v_billing_detail TO authenticated; - --- ============================================================================ --- 2. A household with nothing past due owes zero, not "unknown" --- ============================================================================ --- --- SUM(...) FILTER over no matching rows returns NULL, so past_due_cents came --- back NULL for any household that is simply up to date, while the campus view --- already wrapped the identical expression in COALESCE. The two can now be --- added together without special-casing. --- --- worst_days_overdue stays NULL deliberately: there is no meaningful "zero days --- overdue" for an invoice that was never overdue, and 0 would read as "due --- today". -CREATE OR REPLACE VIEW public.v_household_receivables -WITH (security_invoker = true) AS -SELECT - d.household_id, - d.household_name, - COUNT(DISTINCT d.student_id) AS students, - SUM(d.total_cents) AS invoiced_cents, - SUM(d.amount_paid_cents) AS paid_cents, - SUM(d.balance_due_cents) AS balance_cents, - COALESCE(SUM(d.balance_due_cents) FILTER (WHERE d.is_past_due), 0) AS past_due_cents, - MAX(d.days_overdue) FILTER (WHERE d.is_past_due) AS worst_days_overdue -FROM public.v_billing_detail d -WHERE d.household_id IS NOT NULL -GROUP BY d.household_id, d.household_name; - -GRANT SELECT ON public.v_household_receivables TO authenticated; - --- ============================================================================ --- 3. Unfunded scholarship money: absent is not the same as zero --- ============================================================================ --- --- This column reads public.scholarships and public.payments, whose policy --- admits only billing admins, auditors and a student's own parents. A campus --- administrator's subquery therefore matched nothing and the tile rendered $0 — --- indistinguishable from "all funding received", which is both false and the --- more reassuring of the two readings. --- --- It now returns NULL for anyone who cannot see scholarship data, so the --- dashboard can render "—" and say nothing rather than something wrong. --- Widening who may see this number is a policy decision, not a reporting one, --- and is not made here. -CREATE OR REPLACE VIEW public.v_campus_billing_summary -WITH (security_invoker = true) AS -SELECT - c.id AS campus_id, - c.name AS campus_name, - COUNT(d.invoice_id) AS invoice_count, - COALESCE(SUM(d.total_cents), 0) AS total_invoiced_cents, - COALESCE(SUM(d.amount_paid_cents), 0) AS total_collected_cents, - COALESCE(SUM(d.balance_due_cents), 0) AS total_unpaid_cents, - COALESCE(SUM(d.balance_due_cents) FILTER (WHERE d.is_past_due), 0) AS past_due_cents, - COALESCE(SUM(d.scholarship_cents), 0) AS scholarship_applied_cents, - COALESCE(SUM(d.balance_due_cents) FILTER (WHERE d.on_payment_plan), 0) - AS payment_plan_balance_cents, - COUNT(DISTINCT d.student_id) FILTER (WHERE d.is_past_due) AS delinquent_accounts, - CASE - -- Everyone who can genuinely read public.scholarships: billing admins and - -- auditors by policy, plus service_role by RLS bypass — without that last - -- arm, server-side reporting would silently see NULL too. - WHEN public.is_billing_admin() - OR public.is_auditor() - OR public.is_service_context() - THEN public.campus_pending_scholarship_cents(c.id) - ELSE NULL - END AS scholarship_pending_cents -FROM public.campuses c -LEFT JOIN public.v_billing_detail d ON d.campus_id = c.id -GROUP BY c.id, c.name; - -GRANT SELECT ON public.v_campus_billing_summary TO authenticated;