diff --git a/supabase/migrations/20260807000500_tuition_engine.sql b/supabase/migrations/20260807000500_tuition_engine.sql new file mode 100644 index 0000000..26d9d74 --- /dev/null +++ b/supabase/migrations/20260807000500_tuition_engine.sql @@ -0,0 +1,483 @@ +-- Tuition engine — spec section 8. +-- +-- Tuition must be assignable by campus, student, enrollment type, scheduled +-- days, week, tier, support status, scholarship status and date range. That is +-- too many axes for columns on students, so rates live in their own table and +-- are *matched* against a student for a billing period. +-- +-- Rate resolution, most specific wins: +-- 1. student_tuition_assignments — an explicit per-student override +-- 2. tuition_rates — tier + campus + basis + day-count + dates +-- 3. students.daily_tuition_cents — legacy fallback, see the note below +-- +-- IMPORTANT — double-billing. 20260726120000 added a trigger that posts a +-- ledger charge every time a student is marked present. Once invoices are +-- generated from this engine, that trigger would bill the same tuition twice. +-- It is therefore made switchable here and left ON, so behaviour is unchanged +-- until an administrator deliberately turns it off. Turn it off before issuing +-- the first real invoice: +-- UPDATE public.billing_settings SET legacy_attendance_autocharge = FALSE; + +-- ============================================================================ +-- 1. BILLING SETTINGS (singleton) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.billing_settings ( + id BOOLEAN PRIMARY KEY DEFAULT TRUE, + legacy_attendance_autocharge BOOLEAN NOT NULL DEFAULT TRUE, + invoice_number_prefix TEXT NOT NULL DEFAULT 'INV', + default_due_days INTEGER NOT NULL DEFAULT 7, + late_pickup_fee_cents INTEGER NOT NULL DEFAULT 0, + early_dropoff_fee_cents INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Enforces a single row: the only permitted primary key value is TRUE. + CONSTRAINT billing_settings_singleton CHECK (id) +); + +GRANT SELECT, INSERT, UPDATE ON public.billing_settings TO authenticated; +GRANT ALL ON public.billing_settings TO service_role; +ALTER TABLE public.billing_settings ENABLE ROW LEVEL SECURITY; + +INSERT INTO public.billing_settings (id) VALUES (TRUE) ON CONFLICT (id) DO NOTHING; + +DROP POLICY IF EXISTS "billing_settings read" ON public.billing_settings; +CREATE POLICY "billing_settings read" ON public.billing_settings FOR SELECT TO authenticated + USING (public.is_billing_admin() OR public.is_org_admin() OR public.is_auditor()); +DROP POLICY IF EXISTS "billing_settings manage" ON public.billing_settings; +CREATE POLICY "billing_settings manage" ON public.billing_settings FOR ALL TO authenticated + USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); + +-- Make the legacy autocharge respect the switch. Same logic as before, +-- with one guard added at the top. +CREATE OR REPLACE FUNCTION public.sync_attendance_tuition() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + rate INTEGER; + enabled BOOLEAN; +BEGIN + SELECT legacy_attendance_autocharge INTO enabled FROM public.billing_settings WHERE id; + IF NOT COALESCE(enabled, TRUE) THEN + RETURN NEW; + END IF; + + IF NEW.status IN ('present', 'late') THEN + SELECT daily_tuition_cents INTO rate FROM public.students WHERE id = NEW.student_id; + IF rate IS NOT NULL AND rate > 0 THEN + INSERT INTO public.ledger_entries (student_id, date, kind, category, amount_cents, note) + VALUES (NEW.student_id, NEW.date, 'charge', 'tuition', rate, 'Auto-charged from attendance') + ON CONFLICT DO NOTHING; + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- ============================================================================ +-- 2. TUITION TIERS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.tuition_tiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.tuition_tiers TO authenticated; +GRANT ALL ON public.tuition_tiers TO service_role; +ALTER TABLE public.tuition_tiers ENABLE ROW LEVEL SECURITY; + +INSERT INTO public.tuition_tiers (name, slug, description, sort_order) VALUES + ('Standard Full-Time', 'standard_full_time', 'Billed per week.', 10), + ('Standard Part-Time', 'standard_part_time', 'Billed per scheduled day.', 20), + ('Support Program', 'support_program', 'Support students; may carry an additional charge.', 30), + ('Scholarship', 'scholarship', 'Students funded wholly or partly by scholarship.', 40) +ON CONFLICT (slug) DO NOTHING; + +ALTER TABLE public.students + ADD COLUMN IF NOT EXISTS tuition_tier_id UUID REFERENCES public.tuition_tiers(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS students_tuition_tier_idx ON public.students (tuition_tier_id); + +-- ============================================================================ +-- 3. TUITION RATES +-- ============================================================================ +-- NULL means "any" on every matching column, so one row can cover all campuses +-- or all tiers. min_days/max_days express the "by number of scheduled days" +-- axis — e.g. a discounted per-day rate once a student is scheduled 4+ days. + +CREATE TABLE IF NOT EXISTS public.tuition_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tier_id UUID REFERENCES public.tuition_tiers(id) ON DELETE CASCADE, + campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, + program_id UUID REFERENCES public.campus_programs(id) ON DELETE SET NULL, + + rate_basis TEXT NOT NULL, + amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0), + + attendance_basis TEXT, + min_days SMALLINT, + max_days SMALLINT, + requires_support_student BOOLEAN, + requires_scholarship BOOLEAN, + + effective_start DATE NOT NULL DEFAULT CURRENT_DATE, + effective_end DATE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + -- Higher wins when several rates match. Lets a campus-specific rate beat a + -- global one without relying on column-precedence guesswork. + priority INTEGER NOT NULL DEFAULT 100, + + notes TEXT, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT tr_basis_valid CHECK (rate_basis IN ('per_day','per_week','per_month','flat')), + CONSTRAINT tr_attendance_valid + CHECK (attendance_basis IS NULL OR attendance_basis IN ('full_time','part_time')), + CONSTRAINT tr_days_ordered CHECK (min_days IS NULL OR max_days IS NULL OR min_days <= max_days), + CONSTRAINT tr_dates_ordered CHECK (effective_end IS NULL OR effective_start <= effective_end) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.tuition_rates TO authenticated; +GRANT ALL ON public.tuition_rates TO service_role; +ALTER TABLE public.tuition_rates ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS tr_tier_idx ON public.tuition_rates (tier_id); +CREATE INDEX IF NOT EXISTS tr_campus_idx ON public.tuition_rates (campus_id); +CREATE INDEX IF NOT EXISTS tr_program_idx ON public.tuition_rates (program_id); +CREATE INDEX IF NOT EXISTS tr_lookup_idx ON public.tuition_rates (effective_start, effective_end, priority DESC) + WHERE is_active; + +DROP TRIGGER IF EXISTS trg_tr_upd ON public.tuition_rates; +CREATE TRIGGER trg_tr_upd BEFORE UPDATE ON public.tuition_rates + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 4. ADJUSTMENT RULES +-- ============================================================================ +-- Support charges, drop-off/pick-up penalties and discounts. The spec's +-- Enterprise/Palm Bay support rule is seeded here as data, not code, because +-- the directive says the method may change. + +CREATE TABLE IF NOT EXISTS public.tuition_adjustment_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + rule_type TEXT NOT NULL, + + campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, + tier_id UUID REFERENCES public.tuition_tiers(id) ON DELETE CASCADE, + requires_support_student BOOLEAN, + requires_tag_slug TEXT, + + amount_basis TEXT NOT NULL, + amount_cents INTEGER, + percent NUMERIC(5,2), + + effective_start DATE NOT NULL DEFAULT CURRENT_DATE, + effective_end DATE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + priority INTEGER NOT NULL DEFAULT 100, + notes TEXT, + + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT tar_type_valid CHECK (rule_type IN + ('support_charge','early_dropoff','late_pickup','discount','surcharge')), + CONSTRAINT tar_basis_valid CHECK (amount_basis IN + ('flat','per_day','per_week','percent','additional_week')), + -- A percent rule needs a percent; every other basis needs an amount. + CONSTRAINT tar_amount_present CHECK ( + (amount_basis = 'percent' AND percent IS NOT NULL) + OR (amount_basis <> 'percent' AND amount_cents IS NOT NULL) + ), + CONSTRAINT tar_dates_ordered CHECK (effective_end IS NULL OR effective_start <= effective_end) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.tuition_adjustment_rules TO authenticated; +GRANT ALL ON public.tuition_adjustment_rules TO service_role; +ALTER TABLE public.tuition_adjustment_rules ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS tar_campus_idx ON public.tuition_adjustment_rules (campus_id); +CREATE INDEX IF NOT EXISTS tar_tier_idx ON public.tuition_adjustment_rules (tier_id); +CREATE INDEX IF NOT EXISTS tar_active_idx ON public.tuition_adjustment_rules (rule_type, priority DESC) + WHERE is_active; + +DROP TRIGGER IF EXISTS trg_tar_upd ON public.tuition_adjustment_rules; +CREATE TRIGGER trg_tar_upd BEFORE UPDATE ON public.tuition_adjustment_rules + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- The spec's initial support rule: Enterprise campus, support students, an +-- additional week of tuition. amount_cents is 0 because the school has not +-- stated the figure — an administrator sets it before the rule has any effect. +INSERT INTO public.tuition_adjustment_rules + (name, rule_type, campus_id, requires_support_student, amount_basis, amount_cents, notes) +SELECT + 'Enterprise support-student additional week', + 'support_charge', + c.id, + TRUE, + 'additional_week', + 0, + 'Spec section 8: Enterprise campus support students carry an additional week ' + 'of tuition. Set amount_cents, or leave 0 to bill one extra week at the ' + 'student''s resolved weekly rate.' +FROM public.campuses c +WHERE c.name = 'Enterprise' + AND NOT EXISTS ( + SELECT 1 FROM public.tuition_adjustment_rules r + WHERE r.name = 'Enterprise support-student additional week' + ); + +-- ============================================================================ +-- 5. INDIVIDUAL TUITION ASSIGNMENT +-- ============================================================================ +-- The per-student override, with the approval and provenance trail the spec +-- lists field by field. + +CREATE TABLE IF NOT EXISTS public.student_tuition_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, + + amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0), + frequency TEXT NOT NULL DEFAULT 'per_week', + + start_date DATE NOT NULL DEFAULT CURRENT_DATE, + end_date DATE, + + reason TEXT NOT NULL, + document_path TEXT, + + approved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + approved_at TIMESTAMPTZ, + + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + last_modified_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT sta_frequency_valid + CHECK (frequency IN ('per_day','per_week','per_month','flat')), + CONSTRAINT sta_dates_ordered CHECK (end_date IS NULL OR start_date <= end_date) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_tuition_assignments TO authenticated; +GRANT ALL ON public.student_tuition_assignments TO service_role; +ALTER TABLE public.student_tuition_assignments ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS stat_student_idx ON public.student_tuition_assignments (student_id, start_date DESC); +CREATE INDEX IF NOT EXISTS stat_campus_idx ON public.student_tuition_assignments (campus_id); + +DROP TRIGGER IF EXISTS trg_stat_upd ON public.student_tuition_assignments; +CREATE TRIGGER trg_stat_upd BEFORE UPDATE ON public.student_tuition_assignments + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 6. SCHOLARSHIPS (Step Up) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.scholarships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + program_name TEXT NOT NULL DEFAULT 'Step Up For Students', + external_reference TEXT, + + award_amount_cents INTEGER NOT NULL CHECK (award_amount_cents >= 0), + frequency TEXT NOT NULL DEFAULT 'per_week', + + funding_period_start DATE NOT NULL DEFAULT CURRENT_DATE, + funding_period_end DATE, + + status TEXT NOT NULL DEFAULT 'active', + document_path TEXT, + notes TEXT, + + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT sch_frequency_valid CHECK (frequency IN ('per_day','per_week','per_month','flat')), + CONSTRAINT sch_status_valid CHECK (status IN ('pending','active','suspended','ended')), + CONSTRAINT sch_dates_ordered + CHECK (funding_period_end IS NULL OR funding_period_start <= funding_period_end) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.scholarships TO authenticated; +GRANT ALL ON public.scholarships TO service_role; +ALTER TABLE public.scholarships ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS sch_student_idx ON public.scholarships (student_id); +CREATE INDEX IF NOT EXISTS sch_active_idx ON public.scholarships (student_id, funding_period_start) + WHERE status = 'active'; + +DROP TRIGGER IF EXISTS trg_sch_upd ON public.scholarships; +CREATE TRIGGER trg_sch_upd BEFORE UPDATE ON public.scholarships + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 7. PAYMENT PLANS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.payment_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + household_id UUID REFERENCES public.households(id) ON DELETE CASCADE, + student_id UUID REFERENCES public.students(id) ON DELETE CASCADE, + + name TEXT NOT NULL, + installment_amount_cents INTEGER NOT NULL CHECK (installment_amount_cents >= 0), + frequency TEXT NOT NULL DEFAULT 'weekly', + day_of_period SMALLINT, + + start_date DATE NOT NULL DEFAULT CURRENT_DATE, + end_date DATE, + status TEXT NOT NULL DEFAULT 'active', + notes TEXT, + + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT pp_frequency_valid CHECK (frequency IN ('weekly','biweekly','monthly')), + CONSTRAINT pp_status_valid CHECK (status IN ('active','paused','completed','cancelled')), + CONSTRAINT pp_dates_ordered CHECK (end_date IS NULL OR start_date <= end_date), + -- Must attach to something. + CONSTRAINT pp_scope_present CHECK (household_id IS NOT NULL OR student_id IS NOT NULL) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.payment_plans TO authenticated; +GRANT ALL ON public.payment_plans TO service_role; +ALTER TABLE public.payment_plans ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS pplan_household_idx ON public.payment_plans (household_id); +CREATE INDEX IF NOT EXISTS pplan_student_idx ON public.payment_plans (student_id); + +DROP TRIGGER IF EXISTS trg_pplan_upd ON public.payment_plans; +CREATE TRIGGER trg_pplan_upd BEFORE UPDATE ON public.payment_plans + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +ALTER TABLE public.students + ADD COLUMN IF NOT EXISTS payment_plan_id UUID REFERENCES public.payment_plans(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS students_payment_plan_idx ON public.students (payment_plan_id); + +-- ============================================================================ +-- 8. RATE RESOLUTION +-- ============================================================================ + +-- Scheduled weekday count for a student across a period, campus by campus. +-- The billing engine multiplies this by the resolved per-day rate. +CREATE OR REPLACE FUNCTION public.student_scheduled_days(_student UUID, _from DATE, _to DATE) +RETURNS TABLE (campus_id UUID, days_per_week SMALLINT, weeks NUMERIC, total_days NUMERIC) +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT + sc.campus_id, + sc.scheduled_days_per_week, + -- Fractional weeks, so a partial billing period bills proportionally. + ROUND(((LEAST(_to, COALESCE(sc.effective_end, _to)) + - GREATEST(_from, sc.effective_start) + 1)::numeric / 7.0), 4) AS weeks, + ROUND(((LEAST(_to, COALESCE(sc.effective_end, _to)) + - GREATEST(_from, sc.effective_start) + 1)::numeric / 7.0) + * sc.scheduled_days_per_week, 4) AS total_days + FROM public.student_campus_schedules sc + WHERE sc.student_id = _student + AND sc.effective_start <= _to + AND (sc.effective_end IS NULL OR sc.effective_end >= _from) + AND sc.scheduled_days_per_week > 0 +$$; + +-- Best-matching rate for a student at a campus on a date. NULL columns on the +-- rate mean "any", so specificity is expressed by priority, then by how +-- recently the rate took effect. +CREATE OR REPLACE FUNCTION public.resolve_tuition_rate( + _student UUID, _campus UUID, _on DATE, _days SMALLINT DEFAULT NULL +) +RETURNS public.tuition_rates +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT r.* + FROM public.tuition_rates r + JOIN public.students s ON s.id = _student + WHERE r.is_active + AND r.effective_start <= _on + AND (r.effective_end IS NULL OR r.effective_end >= _on) + AND (r.tier_id IS NULL OR r.tier_id = s.tuition_tier_id) + AND (r.campus_id IS NULL OR r.campus_id = _campus) + AND (r.attendance_basis IS NULL OR r.attendance_basis = s.attendance_basis) + AND (r.requires_support_student IS NULL OR r.requires_support_student = s.is_support_student) + AND (r.requires_scholarship IS NULL OR r.requires_scholarship = EXISTS ( + SELECT 1 FROM public.scholarships sh + WHERE sh.student_id = _student AND sh.status = 'active' + AND sh.funding_period_start <= _on + AND (sh.funding_period_end IS NULL OR sh.funding_period_end >= _on))) + AND (_days IS NULL OR r.min_days IS NULL OR _days >= r.min_days) + AND (_days IS NULL OR r.max_days IS NULL OR _days <= r.max_days) + ORDER BY r.priority DESC, r.effective_start DESC + LIMIT 1 +$$; + +-- Active per-student override for a date, if any. +CREATE OR REPLACE FUNCTION public.resolve_tuition_override(_student UUID, _on DATE) +RETURNS public.student_tuition_assignments +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT a.* FROM public.student_tuition_assignments a + WHERE a.student_id = _student + AND a.start_date <= _on + AND (a.end_date IS NULL OR a.end_date >= _on) + ORDER BY a.start_date DESC + LIMIT 1 +$$; + +-- ============================================================================ +-- 9. POLICIES +-- ============================================================================ +-- Rate cards are readable by staff who bill or administrate; per-student money +-- is additionally readable by that student's guardians. + +DROP POLICY IF EXISTS "tiers read" ON public.tuition_tiers; +CREATE POLICY "tiers read" ON public.tuition_tiers FOR SELECT TO authenticated USING (TRUE); +DROP POLICY IF EXISTS "tiers manage" ON public.tuition_tiers; +CREATE POLICY "tiers manage" ON public.tuition_tiers FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "rates read" ON public.tuition_rates; +CREATE POLICY "rates read" ON public.tuition_rates FOR SELECT TO authenticated + USING (public.is_billing_admin() OR public.is_management() OR public.is_auditor() + OR public.has_campus_access(campus_id)); +DROP POLICY IF EXISTS "rates manage" ON public.tuition_rates; +CREATE POLICY "rates manage" ON public.tuition_rates FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "adj rules read" ON public.tuition_adjustment_rules; +CREATE POLICY "adj rules read" ON public.tuition_adjustment_rules FOR SELECT TO authenticated + USING (public.is_billing_admin() OR public.is_management() OR public.is_auditor()); +DROP POLICY IF EXISTS "adj rules manage" ON public.tuition_adjustment_rules; +CREATE POLICY "adj rules manage" ON public.tuition_adjustment_rules FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "student tuition read" ON public.student_tuition_assignments; +CREATE POLICY "student tuition read" ON public.student_tuition_assignments FOR SELECT TO authenticated + USING (public.is_billing_admin() OR public.is_auditor() OR public.is_parent_of(student_id)); +DROP POLICY IF EXISTS "student tuition manage" ON public.student_tuition_assignments; +CREATE POLICY "student tuition manage" ON public.student_tuition_assignments FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "scholarships read" ON public.scholarships; +CREATE POLICY "scholarships read" ON public.scholarships FOR SELECT TO authenticated + USING (public.is_billing_admin() OR public.is_auditor() OR public.is_parent_of(student_id)); +DROP POLICY IF EXISTS "scholarships manage" ON public.scholarships; +CREATE POLICY "scholarships manage" ON public.scholarships FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "payment plans read" ON public.payment_plans; +CREATE POLICY "payment plans read" ON public.payment_plans FOR SELECT TO authenticated + USING ( + public.is_billing_admin() OR public.is_auditor() + OR (student_id IS NOT NULL AND public.is_parent_of(student_id)) + OR (household_id IS NOT NULL AND household_id IN (SELECT public.user_household_ids())) + ); +DROP POLICY IF EXISTS "payment plans manage" ON public.payment_plans; +CREATE POLICY "payment plans manage" ON public.payment_plans FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin());