From e64789d70575e6d69ef0d9841a9dd955e888a175 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 20:46:45 +0000 Subject: [PATCH] Terminal --- .../20260807002000_reporting_view_fixes.sql | 145 ++++++++++++++---- 1 file changed, 113 insertions(+), 32 deletions(-) diff --git a/supabase/migrations/20260807002000_reporting_view_fixes.sql b/supabase/migrations/20260807002000_reporting_view_fixes.sql index 6211f86..d302e8a 100644 --- a/supabase/migrations/20260807002000_reporting_view_fixes.sql +++ b/supabase/migrations/20260807002000_reporting_view_fixes.sql @@ -1,20 +1,111 @@ --- Two honesty fixes to the section 14 reporting views. +-- Three fixes to the section 14 reporting views. -- --- Both are about a number that reads as a fact when it is really an absence. --- Neither changes what anyone is permitted to see. +-- 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. -- ============================================================================ --- 1. A household with nothing past due owes zero, not "unknown" +-- 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. + AND (public.can_access_student(_student) + OR public.is_billing_admin() + OR public.is_auditor()); +$$; + +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. v_campus_billing_summary --- already wraps the identical expression in COALESCE; this brings the household --- view into line so callers can add the two together without special-casing. +-- 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 is deliberately left NULL: there is no meaningful "zero --- days overdue" for an invoice that was never overdue, and 0 would read as --- "due today". +-- 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 @@ -33,20 +124,19 @@ GROUP BY d.household_id, d.household_name; GRANT SELECT ON public.v_household_receivables TO authenticated; -- ============================================================================ --- 2. Unfunded scholarship money: absent is not the same as zero +-- 3. Unfunded scholarship money: absent is not the same as zero -- ============================================================================ -- --- scholarship_pending_cents reads public.scholarships and public.payments, --- whose RLS admits only billing admins, auditors and a student's own parents. --- Under security_invoker a campus administrator's subquery therefore matched --- nothing and the tile rendered $0 — indistinguishable from "all funding --- received", which is the opposite of the truth and the more reassuring of the --- two readings. +-- 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. -- --- The column now returns NULL for anyone who cannot see scholarship data, so --- the dashboard can render "—" and say nothing rather than something false. --- The gate mirrors the scholarships SELECT policy exactly; widening who may see --- this number is a policy decision, not a reporting one, and is not made here. +-- 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 @@ -67,17 +157,8 @@ SELECT -- without that last arm, server-side reporting would see NULL too. WHEN public.is_billing_admin() OR public.is_auditor() - OR pg_has_role(current_user, 'service_role', 'MEMBER') THEN - COALESCE(( - SELECT SUM(sc.award_amount_cents) - FROM public.scholarships sc - JOIN public.students st ON st.id = sc.student_id - WHERE st.primary_campus_id = c.id - AND sc.status = 'active' - AND NOT EXISTS ( - SELECT 1 FROM public.payments p - WHERE p.scholarship_id = sc.id AND p.status = 'posted') - ), 0) + OR pg_has_role(current_user, 'service_role', 'MEMBER') + THEN public.campus_pending_scholarship_cents(c.id) ELSE NULL END AS scholarship_pending_cents FROM public.campuses c