diff --git a/supabase/migrations/20260807001700_penalties.sql b/supabase/migrations/20260807001700_penalties.sql new file mode 100644 index 0000000..fa59516 --- /dev/null +++ b/supabase/migrations/20260807001700_penalties.sql @@ -0,0 +1,578 @@ +-- Early drop-off and late pick-up fees — spec section 15. +-- +-- The flat per-day fees in billing_settings were a placeholder. Section 15 needs +-- allowed times, grace periods, per-minute or flat methods, a fee cap, an +-- escalation for repeat occurrences, waiver authority with a reason, and parent +-- notification — none of which fit in a single cents column. +-- +-- A fee has two lives: the *event* (this child was collected 22 minutes late on +-- Tuesday) and the *charge* it produces on the next invoice. Keeping them apart +-- is what makes waiving possible without erasing the fact that it happened. + +-- ============================================================================ +-- 1. RULES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.penalty_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, + + -- Earliest permitted arrival, or latest permitted collection. NULL falls back + -- to the campus's own early_dropoff_time / late_pickup_cutoff. + allowed_time TIME, + grace_period_minutes INTEGER NOT NULL DEFAULT 0 CHECK (grace_period_minutes >= 0), + + method TEXT NOT NULL DEFAULT 'flat', + amount_cents INTEGER NOT NULL DEFAULT 0 CHECK (amount_cents >= 0), + -- For per_increment: charge amount_cents per this many minutes, part-blocks + -- rounded up. + increment_minutes INTEGER CHECK (increment_minutes IS NULL OR increment_minutes > 0), + max_fee_cents INTEGER CHECK (max_fee_cents IS NULL OR max_fee_cents >= 0), + + -- Escalation for the "excessive" case: after N occurrences inside a window, + -- the higher amount applies. + threshold_count INTEGER CHECK (threshold_count IS NULL OR threshold_count > 0), + threshold_window_days INTEGER CHECK (threshold_window_days IS NULL OR threshold_window_days > 0), + escalated_amount_cents INTEGER CHECK (escalated_amount_cents IS NULL OR escalated_amount_cents >= 0), + + -- Who may waive a fee raised under this rule. + waiver_roles app_role[] NOT NULL + DEFAULT ARRAY['admin','org_admin','super_admin','campus_admin']::app_role[], + requires_approval BOOLEAN NOT NULL DEFAULT FALSE, + notify_parent BOOLEAN NOT NULL DEFAULT TRUE, + + 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) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT pr_type_valid CHECK (rule_type IN + ('early_dropoff','late_pickup','excessive_late_pickup','schedule_violation')), + CONSTRAINT pr_method_valid CHECK (method IN ('flat','per_minute','per_increment')), + CONSTRAINT pr_increment_present + CHECK (method <> 'per_increment' OR increment_minutes IS NOT NULL), + CONSTRAINT pr_dates_ordered CHECK (effective_end IS NULL OR effective_start <= effective_end) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.penalty_rules TO authenticated; +GRANT ALL ON public.penalty_rules TO service_role; +ALTER TABLE public.penalty_rules ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS pr_campus_idx ON public.penalty_rules (campus_id); +CREATE INDEX IF NOT EXISTS pr_active_idx ON public.penalty_rules (rule_type, priority DESC) + WHERE is_active; + +DROP TRIGGER IF EXISTS trg_pr_upd ON public.penalty_rules; +CREATE TRIGGER trg_pr_upd BEFORE UPDATE ON public.penalty_rules + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- No rules are seeded. A fee schedule invented here would look official and +-- start charging families real money. + +-- ============================================================================ +-- 2. EVENTS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.penalty_events ( + 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, + rule_id UUID REFERENCES public.penalty_rules(id) ON DELETE SET NULL, + attendance_id UUID REFERENCES public.attendance(id) ON DELETE SET NULL, + + event_type TEXT NOT NULL, + occurred_on DATE NOT NULL DEFAULT CURRENT_DATE, + minutes_over INTEGER NOT NULL DEFAULT 0 CHECK (minutes_over >= 0), + + -- What the rule produced, versus what is actually being charged. They differ + -- when a fee is capped, escalated, or manually overridden. + computed_fee_cents INTEGER NOT NULL DEFAULT 0, + final_fee_cents INTEGER NOT NULL DEFAULT 0, + override_reason TEXT, + + is_waived BOOLEAN NOT NULL DEFAULT FALSE, + waiver_reason TEXT, + waived_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + waived_at TIMESTAMPTZ, + + approved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + approved_at TIMESTAMPTZ, + + parent_notified_at TIMESTAMPTZ, + notification_method TEXT, + + -- Set once the fee has been carried onto an invoice, so it is billed once. + invoice_id UUID REFERENCES public.invoices(id) ON DELETE SET NULL, + + recorded_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT pe_type_valid CHECK (event_type IN + ('early_dropoff','late_pickup','excessive_late_pickup','schedule_violation')), + -- A waiver without a reason is not a waiver, it is a deletion. + CONSTRAINT pe_waiver_reason CHECK (NOT is_waived OR COALESCE(TRIM(waiver_reason), '') <> '') +); + +GRANT SELECT, INSERT, UPDATE ON public.penalty_events TO authenticated; +GRANT ALL ON public.penalty_events TO service_role; +ALTER TABLE public.penalty_events ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS pe_student_idx ON public.penalty_events (student_id, occurred_on DESC); +CREATE INDEX IF NOT EXISTS pe_campus_idx ON public.penalty_events (campus_id, occurred_on); +CREATE INDEX IF NOT EXISTS pe_rule_idx ON public.penalty_events (rule_id); +CREATE INDEX IF NOT EXISTS pe_attendance_idx ON public.penalty_events (attendance_id); +CREATE INDEX IF NOT EXISTS pe_invoice_idx ON public.penalty_events (invoice_id); +-- The billing query: chargeable, not waived, not yet invoiced. +CREATE INDEX IF NOT EXISTS pe_billable_idx ON public.penalty_events (student_id, occurred_on) + WHERE invoice_id IS NULL AND NOT is_waived; + +DROP TRIGGER IF EXISTS trg_pe_upd ON public.penalty_events; +CREATE TRIGGER trg_pe_upd BEFORE UPDATE ON public.penalty_events + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- A waiver must be made by someone the rule authorises. +CREATE OR REPLACE FUNCTION public.enforce_waiver_authority() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + allowed app_role[]; +BEGIN + IF NEW.is_waived AND NOT COALESCE(OLD.is_waived, FALSE) THEN + SELECT waiver_roles INTO allowed FROM public.penalty_rules WHERE id = NEW.rule_id; + IF allowed IS NOT NULL + AND NOT public.is_org_admin() + AND NOT public.current_user_has_any_role(allowed) THEN + RAISE EXCEPTION 'your role may not waive fees under this rule'; + END IF; + NEW.waived_by := COALESCE(NEW.waived_by, (SELECT auth.uid())); + NEW.waived_at := COALESCE(NEW.waived_at, now()); + NEW.final_fee_cents := 0; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_pe_waiver ON public.penalty_events; +CREATE TRIGGER trg_pe_waiver BEFORE UPDATE ON public.penalty_events + FOR EACH ROW EXECUTE FUNCTION public.enforce_waiver_authority(); + +-- ============================================================================ +-- 3. ASSESSMENT +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.resolve_penalty_rule(_campus UUID, _type TEXT, _on DATE) +RETURNS public.penalty_rules +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT r.* FROM public.penalty_rules r + WHERE r.is_active AND r.rule_type = _type + AND r.effective_start <= _on + AND (r.effective_end IS NULL OR r.effective_end >= _on) + AND (r.campus_id IS NULL OR r.campus_id = _campus) + ORDER BY r.priority DESC, (r.campus_id IS NOT NULL) DESC + LIMIT 1 +$$; + +CREATE OR REPLACE FUNCTION public.compute_penalty_fee( + _rule public.penalty_rules, _minutes_over INTEGER, _escalated BOOLEAN DEFAULT FALSE +) +RETURNS INTEGER +LANGUAGE plpgsql IMMUTABLE AS $$ +DECLARE + base INTEGER; + fee INTEGER; + billable INTEGER; +BEGIN + IF _rule.id IS NULL THEN RETURN 0; END IF; + + billable := GREATEST(_minutes_over - _rule.grace_period_minutes, 0); + IF billable = 0 THEN RETURN 0; END IF; + + base := CASE WHEN _escalated AND _rule.escalated_amount_cents IS NOT NULL + THEN _rule.escalated_amount_cents ELSE _rule.amount_cents END; + + fee := CASE _rule.method + WHEN 'flat' THEN base + WHEN 'per_minute' THEN base * billable + WHEN 'per_increment' THEN base * CEIL(billable::numeric / _rule.increment_minutes) + ELSE base + END; + + IF _rule.max_fee_cents IS NOT NULL THEN + fee := LEAST(fee, _rule.max_fee_cents); + END IF; + RETURN fee; +END; +$$; + +-- Raise events from an attendance row. Idempotent per (attendance, type), so +-- re-running after a correction does not double-charge. +CREATE OR REPLACE FUNCTION public.assess_attendance_penalties(_attendance UUID) +RETURNS INTEGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + att public.attendance%ROWTYPE; + cam public.campuses%ROWTYPE; + rule public.penalty_rules%ROWTYPE; + tz TEXT; + mins INTEGER; + fee INTEGER; + recent INTEGER; + escalate BOOLEAN; + created INTEGER := 0; +BEGIN + SELECT * INTO att FROM public.attendance WHERE id = _attendance; + IF NOT FOUND THEN RETURN 0; END IF; + SELECT * INTO cam FROM public.campuses WHERE id = att.campus_id; + tz := COALESCE(cam.timezone, 'America/New_York'); + + -- Early drop-off + IF att.check_in_at IS NOT NULL THEN + SELECT * INTO rule FROM public.resolve_penalty_rule(att.campus_id, 'early_dropoff', att.date); + IF rule.id IS NOT NULL THEN + mins := GREATEST( + EXTRACT(EPOCH FROM ( + COALESCE(rule.allowed_time, cam.early_dropoff_time, att.expected_arrival) + - (att.check_in_at AT TIME ZONE tz)::time + )) / 60, 0)::int; + fee := public.compute_penalty_fee(rule, mins); + IF fee > 0 THEN + INSERT INTO public.penalty_events + (student_id, campus_id, rule_id, attendance_id, event_type, occurred_on, + minutes_over, computed_fee_cents, final_fee_cents, recorded_by) + SELECT att.student_id, att.campus_id, rule.id, att.id, 'early_dropoff', att.date, + mins, fee, fee, (SELECT auth.uid()) + WHERE NOT EXISTS ( + SELECT 1 FROM public.penalty_events e + WHERE e.attendance_id = att.id AND e.event_type = 'early_dropoff'); + created := created + 1; + END IF; + END IF; + END IF; + + -- Late pick-up, escalating when it keeps happening. + IF att.check_out_at IS NOT NULL THEN + SELECT * INTO rule FROM public.resolve_penalty_rule(att.campus_id, 'late_pickup', att.date); + IF rule.id IS NOT NULL THEN + mins := GREATEST( + EXTRACT(EPOCH FROM ( + (att.check_out_at AT TIME ZONE tz)::time + - COALESCE(rule.allowed_time, cam.late_pickup_cutoff, att.expected_departure) + )) / 60, 0)::int; + + escalate := FALSE; + IF rule.threshold_count IS NOT NULL AND rule.threshold_window_days IS NOT NULL THEN + SELECT COUNT(*) INTO recent FROM public.penalty_events e + WHERE e.student_id = att.student_id + AND e.event_type IN ('late_pickup','excessive_late_pickup') + AND NOT e.is_waived + AND e.occurred_on >= att.date - rule.threshold_window_days; + escalate := recent >= rule.threshold_count; + END IF; + + fee := public.compute_penalty_fee(rule, mins, escalate); + IF fee > 0 THEN + INSERT INTO public.penalty_events + (student_id, campus_id, rule_id, attendance_id, event_type, occurred_on, + minutes_over, computed_fee_cents, final_fee_cents, recorded_by, notes) + SELECT att.student_id, att.campus_id, rule.id, att.id, + CASE WHEN escalate THEN 'excessive_late_pickup' ELSE 'late_pickup' END, + att.date, mins, fee, fee, (SELECT auth.uid()), + CASE WHEN escalate THEN 'Escalated: repeat occurrences in window' END + WHERE NOT EXISTS ( + SELECT 1 FROM public.penalty_events e + WHERE e.attendance_id = att.id + AND e.event_type IN ('late_pickup','excessive_late_pickup')); + created := created + 1; + END IF; + END IF; + END IF; + + RETURN created; +END; +$$; + +-- Opt-in automation. Left off so enabling fee assessment is a decision, not a +-- side effect of this migration. +ALTER TABLE public.billing_settings + ADD COLUMN IF NOT EXISTS auto_assess_penalties BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE OR REPLACE FUNCTION public.auto_assess_penalties_trigger() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + enabled BOOLEAN; +BEGIN + SELECT auto_assess_penalties INTO enabled FROM public.billing_settings WHERE id; + IF COALESCE(enabled, FALSE) THEN + PERFORM public.assess_attendance_penalties(NEW.id); + END IF; + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_attendance_penalties ON public.attendance; +CREATE TRIGGER trg_attendance_penalties AFTER INSERT OR UPDATE OF check_in_at, check_out_at + ON public.attendance + FOR EACH ROW EXECUTE FUNCTION public.auto_assess_penalties_trigger(); + +-- ============================================================================ +-- 4. BILLING THE FEES +-- ============================================================================ +-- compute_invoice_lines covers tuition, support charges and funding. +-- Penalty events are separate because they are facts about days that already +-- happened rather than a calculation over the schedule. compute_all_invoice_lines +-- is the entry point the invoice engine now uses; call it, not the tuition +-- function alone, or fees will silently never be billed. + +CREATE OR REPLACE FUNCTION public.compute_penalty_lines(_student UUID, _from DATE, _to DATE) +RETURNS TABLE ( + line_kind TEXT, description TEXT, campus_id UUID, quantity NUMERIC, + unit_amount_cents INTEGER, amount_cents INTEGER, tuition_rate_id UUID, + adjustment_rule_id UUID, scholarship_id UUID, credit_id UUID, + detail JSONB, sort_order INTEGER +) +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT + CASE e.event_type WHEN 'early_dropoff' THEN 'early_dropoff' ELSE 'late_pickup' END, + CASE e.event_type + WHEN 'early_dropoff' THEN 'Early drop-off — ' || to_char(e.occurred_on, 'Mon DD') + WHEN 'excessive_late_pickup' THEN 'Late pick-up (repeat) — ' || to_char(e.occurred_on, 'Mon DD') + ELSE 'Late pick-up — ' || to_char(e.occurred_on, 'Mon DD') + END, + e.campus_id, + 1::numeric, + e.final_fee_cents, + e.final_fee_cents, + NULL::uuid, NULL::uuid, NULL::uuid, NULL::uuid, + jsonb_build_object('step', 8, 'source', 'penalty_event', 'event_id', e.id, + 'rule_id', e.rule_id, 'minutes_over', e.minutes_over, + 'computed_fee_cents', e.computed_fee_cents), + 45 + FROM public.penalty_events e + WHERE e.student_id = _student + AND e.occurred_on BETWEEN _from AND _to + AND NOT e.is_waived + AND e.invoice_id IS NULL + AND e.final_fee_cents > 0 +$$; + +CREATE OR REPLACE FUNCTION public.compute_all_invoice_lines(_student UUID, _from DATE, _to DATE) +RETURNS TABLE ( + line_kind TEXT, description TEXT, campus_id UUID, quantity NUMERIC, + unit_amount_cents INTEGER, amount_cents INTEGER, tuition_rate_id UUID, + adjustment_rule_id UUID, scholarship_id UUID, credit_id UUID, + detail JSONB, sort_order INTEGER +) +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT * FROM public.compute_invoice_lines(_student, _from, _to) + UNION ALL + SELECT * FROM public.compute_penalty_lines(_student, _from, _to) +$$; + +-- Attach billed events to their invoice so they cannot be charged twice. +CREATE OR REPLACE FUNCTION public.mark_penalties_invoiced(_student UUID, _from DATE, _to DATE, _invoice UUID) +RETURNS INTEGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + n INTEGER; +BEGIN + UPDATE public.penalty_events + SET invoice_id = _invoice + WHERE student_id = _student + AND occurred_on BETWEEN _from AND _to + AND NOT is_waived + AND invoice_id IS NULL + AND final_fee_cents > 0; + GET DIAGNOSTICS n = ROW_COUNT; + RETURN n; +END; +$$; + +-- ============================================================================ +-- 5. POLICIES +-- ============================================================================ + +DROP POLICY IF EXISTS "penalty rules read" ON public.penalty_rules; +CREATE POLICY "penalty rules read" ON public.penalty_rules FOR SELECT TO authenticated + USING (TRUE); +DROP POLICY IF EXISTS "penalty rules manage" ON public.penalty_rules; +CREATE POLICY "penalty rules manage" ON public.penalty_rules FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +-- Parents can see a fee raised against their own child, which is what makes +-- the charge on the next invoice explicable. +DROP POLICY IF EXISTS "penalty events read" ON public.penalty_events; +CREATE POLICY "penalty events read" ON public.penalty_events FOR SELECT TO authenticated + USING (public.can_access_student(student_id) OR public.is_billing_admin() OR public.is_auditor()); + +DROP POLICY IF EXISTS "penalty events insert" ON public.penalty_events; +CREATE POLICY "penalty events insert" ON public.penalty_events FOR INSERT TO authenticated + WITH CHECK (public.can_manage_student(student_id) OR public.is_billing_admin()); + +DROP POLICY IF EXISTS "penalty events update" ON public.penalty_events; +CREATE POLICY "penalty events update" ON public.penalty_events FOR UPDATE TO authenticated + USING (public.can_manage_student(student_id) OR public.is_billing_admin()) + WITH CHECK (public.can_manage_student(student_id) OR public.is_billing_admin()); + +-- ============================================================================ +-- 6. REWIRE THE INVOICE ENGINE +-- ============================================================================ +-- Both entry points move from compute_invoice_lines to compute_all_invoice_lines +-- so the preview a parent is shown and the invoice they are sent agree. Bodies +-- are otherwise unchanged from 20260807000600. + +CREATE OR REPLACE FUNCTION public.create_invoice( + _student UUID, _from DATE, _to DATE, _batch UUID DEFAULT NULL, _status TEXT DEFAULT 'draft' +) +RETURNS UUID +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + inv_id UUID; + stu public.students%ROWTYPE; + settings public.billing_settings%ROWTYPE; + hh UUID; + ln RECORD; + v_sub INTEGER := 0; + v_sch INTEGER := 0; + v_cred INTEGER := 0; + v_pen INTEGER := 0; + v_disc INTEGER := 0; + v_total INTEGER := 0; + v_days NUMERIC := 0; + v_log JSONB := '[]'::jsonb; +BEGIN + IF NOT (public.is_billing_admin() OR public.is_org_admin()) THEN + RAISE EXCEPTION 'insufficient privileges to create invoices'; + END IF; + + SELECT * INTO stu FROM public.students WHERE id = _student; + IF NOT FOUND THEN RAISE EXCEPTION 'student % not found', _student; END IF; + + SELECT * INTO settings FROM public.billing_settings WHERE id; + SELECT household_id INTO hh FROM public.household_students + WHERE student_id = _student ORDER BY is_primary_household DESC LIMIT 1; + + SELECT COALESCE(SUM(d.total_days), 0) INTO v_days + FROM public.student_scheduled_days(_student, _from, _to) d; + + INSERT INTO public.invoices ( + invoice_number, student_id, household_id, campus_id, batch_id, + billing_period_start, billing_period_end, invoice_date, due_date, + tuition_tier_id, attendance_basis, scheduled_days, + status, payment_plan_id, issued_by, issued_at + ) VALUES ( + public.next_invoice_number(), _student, hh, stu.primary_campus_id, _batch, + _from, _to, CURRENT_DATE, CURRENT_DATE + COALESCE(settings.default_due_days, 7), + stu.tuition_tier_id, stu.attendance_basis, v_days, + _status, stu.payment_plan_id, (SELECT auth.uid()), + CASE WHEN _status = 'issued' THEN now() ELSE NULL END + ) + RETURNING id INTO inv_id; + + FOR ln IN SELECT * FROM public.compute_all_invoice_lines(_student, _from, _to) LOOP + INSERT INTO public.invoice_line_items ( + invoice_id, line_kind, description, campus_id, quantity, + unit_amount_cents, amount_cents, tuition_rate_id, adjustment_rule_id, + scholarship_id, credit_id, detail, sort_order + ) VALUES ( + inv_id, ln.line_kind, ln.description, ln.campus_id, ln.quantity, + ln.unit_amount_cents, ln.amount_cents, ln.tuition_rate_id, ln.adjustment_rule_id, + ln.scholarship_id, ln.credit_id, ln.detail, ln.sort_order + ); + + v_total := v_total + ln.amount_cents; + v_log := v_log || jsonb_build_object( + 'line_kind', ln.line_kind, 'description', ln.description, + 'amount_cents', ln.amount_cents, 'detail', ln.detail); + + IF ln.line_kind = 'scholarship' THEN v_sch := v_sch + (-ln.amount_cents); + ELSIF ln.line_kind = 'credit' THEN v_cred := v_cred + (-ln.amount_cents); + ELSIF ln.line_kind = 'discount' THEN v_disc := v_disc + (-ln.amount_cents); + ELSIF ln.line_kind IN ('early_dropoff','late_pickup','penalty') + THEN v_pen := v_pen + ln.amount_cents; + v_sub := v_sub + ln.amount_cents; + ELSE v_sub := v_sub + ln.amount_cents; + END IF; + + IF ln.line_kind = 'credit' AND ln.credit_id IS NOT NULL THEN + UPDATE public.student_credits + SET amount_applied_cents = amount_applied_cents + (-ln.amount_cents) + WHERE id = ln.credit_id; + END IF; + END LOOP; + + -- Claim the fee events this invoice just billed. + PERFORM public.mark_penalties_invoiced(_student, _from, _to, inv_id); + + UPDATE public.invoices + SET subtotal_cents = v_sub, + scholarship_cents = v_sch, + credits_cents = v_cred, + penalties_cents = v_pen, + discounts_cents = v_disc, + total_cents = GREATEST(v_total, 0), + calculation_log = v_log + WHERE id = inv_id; + + RETURN inv_id; +END; +$$; + +CREATE OR REPLACE FUNCTION public.preview_invoice_batch(_batch UUID) +RETURNS TABLE ( + student_id UUID, student_name TEXT, campus_name TEXT, + scheduled_days NUMERIC, subtotal_cents BIGINT, total_cents BIGINT +) +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + b public.invoice_batches%ROWTYPE; +BEGIN + IF NOT (public.is_billing_admin() OR public.is_org_admin()) THEN + RAISE EXCEPTION 'insufficient privileges to preview invoice batches'; + END IF; + + SELECT * INTO b FROM public.invoice_batches WHERE id = _batch; + IF NOT FOUND THEN RAISE EXCEPTION 'batch % not found', _batch; END IF; + + RETURN QUERY + WITH elig AS ( + SELECT e.student_id FROM public.eligible_students_for_batch( + b.criteria, b.billing_period_start, b.billing_period_end) e + ), + computed AS ( + SELECT e.student_id, + COALESCE(SUM(l.amount_cents), 0)::BIGINT AS total, + COALESCE(SUM(l.amount_cents) FILTER ( + WHERE l.line_kind NOT IN ('scholarship','credit','discount')), 0)::BIGINT AS sub + FROM elig e + LEFT JOIN LATERAL public.compute_all_invoice_lines( + e.student_id, b.billing_period_start, b.billing_period_end) l ON TRUE + GROUP BY e.student_id + ) + SELECT c.student_id, + s.first_name || ' ' || s.last_name, + cam.name, + COALESCE((SELECT SUM(d.total_days) FROM public.student_scheduled_days( + c.student_id, b.billing_period_start, b.billing_period_end) d), 0), + c.sub, + GREATEST(c.total, 0) + FROM computed c + JOIN public.students s ON s.id = c.student_id + LEFT JOIN public.campuses cam ON cam.id = s.primary_campus_id + ORDER BY 2; + + UPDATE public.invoice_batches + SET status = CASE WHEN status = 'draft' THEN 'previewed' ELSE status END, + previewed_at = now(), + previewed_by = (SELECT auth.uid()) + WHERE id = _batch; +END; +$$;