-- The rest of the billing views had the same fault as v_billing_detail. -- -- 20260807002000 fixed v_billing_detail, but three sibling views still inner -- joined public.students purely to build a display name. A billing administrator -- can read every invoice, payment and ledger entry in the organisation and no -- student rows at all, so that join silently emptied each of them: -- -- v_outstanding_invoices 0 rows, while public.invoices showed 5 -- v_student_ledger 0 rows, while public.payments showed 1 -- -- Both now resolve the name through student_display_name(), which re-checks -- entitlement inside its SECURITY DEFINER body. Student row access is unchanged; -- only the name lookup moved. v_payment_detail is added for the same reason — -- the receivables screen was reaching for payments.students(...) directly and -- rendering "—" for every payer. -- ============================================================================ -- 1. Outstanding invoices -- ============================================================================ CREATE OR REPLACE VIEW public.v_outstanding_invoices 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, i.campus_id, c.name AS campus_name, i.billing_period_start, i.billing_period_end, i.invoice_date, i.due_date, i.total_cents, i.amount_paid_cents, i.balance_due_cents, i.status, CURRENT_DATE - i.due_date AS days_overdue FROM public.invoices i LEFT JOIN public.campuses c ON c.id = i.campus_id WHERE i.status = ANY (ARRAY['issued'::text, 'partially_paid'::text]) AND i.balance_due_cents > 0; GRANT SELECT ON public.v_outstanding_invoices TO authenticated; -- ============================================================================ -- 2. Payments, with the payer named -- ============================================================================ CREATE OR REPLACE VIEW public.v_payment_detail WITH (security_invoker = true) AS SELECT p.id AS payment_id, p.student_id, public.student_display_name(p.student_id) AS student_name, p.household_id, p.campus_id, c.name AS campus_name, p.kind, p.method, p.amount_cents, p.received_at, p.effective_date, p.reference_number, p.payer_name, p.third_party_name, p.scholarship_id, p.status, p.reverses_payment_id, p.void_reason, p.notes, p.received_by FROM public.payments p LEFT JOIN public.campuses c ON c.id = p.campus_id; GRANT SELECT ON public.v_payment_detail TO authenticated; -- ============================================================================ -- 3. Student ledger -- ============================================================================ -- Reproduced from the deployed definition with only the trailing students join -- removed, so the running-balance window and the four-way UNION behind it are -- untouched. CREATE OR REPLACE VIEW public.v_student_ledger WITH (security_invoker = true) AS WITH events AS ( SELECT i.student_id, i.campus_id, i.invoice_date AS txn_date, i.billing_period_start AS effective_date, i.created_at AS sort_ts, 'invoice'::text AS source, 'charge'::text AS direction, i.total_cents AS amount_cents, i.invoice_number AS reference, NULL::text AS method, i.issued_by AS entered_by, i.id AS ref_id FROM invoices i WHERE i.status <> 'void'::text UNION ALL SELECT p.student_id, p.campus_id, p.received_at::date AS received_at, p.effective_date, p.created_at, 'payment'::text, CASE WHEN p.kind = 'payment'::text THEN 'credit'::text ELSE 'charge'::text END AS "case", CASE WHEN p.kind = 'payment'::text THEN - p.amount_cents ELSE p.amount_cents END AS amount_cents, p.reference_number, p.method, p.received_by, p.id FROM payments p WHERE p.status <> 'voided'::text AND p.student_id IS NOT NULL UNION ALL SELECT c.student_id, NULL::uuid AS uuid, c.issued_at::date AS issued_at, c.issued_at::date AS issued_at, c.issued_at, 'credit'::text, 'credit'::text, - c.amount_cents, NULL::text, NULL::text, c.issued_by, c.id FROM student_credits c WHERE NOT c.is_void UNION ALL SELECT l.student_id, NULL::uuid AS uuid, l.date, l.date, l.created_at, 'ledger_entry'::text, CASE WHEN l.kind = 'charge'::ledger_kind THEN 'charge'::text ELSE 'credit'::text END AS "case", CASE WHEN l.kind = 'charge'::ledger_kind THEN l.amount_cents ELSE - l.amount_cents END AS "case", NULL::text, l.category::text AS category, l.created_by, l.id FROM ledger_entries l ) SELECT e.student_id, e.campus_id, e.txn_date, e.effective_date, e.sort_ts, e.source, e.direction, e.amount_cents, e.reference, e.method, e.entered_by, e.ref_id, public.student_display_name(e.student_id) AS student_name, sum(e.amount_cents) OVER (PARTITION BY e.student_id ORDER BY e.effective_date, e.sort_ts, e.ref_id ROWS UNBOUNDED PRECEDING) AS running_balance_cents FROM events e; GRANT SELECT ON public.v_student_ledger TO authenticated;