-- 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; $$;