Terminal
This commit is contained in:
@@ -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;
|
|
||||||
Reference in New Issue
Block a user