484 lines
22 KiB
PL/PgSQL
484 lines
22 KiB
PL/PgSQL
-- 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());
|