-- 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());