Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 04:39:50 +00:00
parent 8f1a998d27
commit 04b9d1a475
@@ -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());