diff --git a/supabase/migrations/20260807001700_penalties.sql b/supabase/migrations/20260807001700_penalties.sql deleted file mode 100644 index fa59516..0000000 --- a/supabase/migrations/20260807001700_penalties.sql +++ /dev/null @@ -1,578 +0,0 @@ --- 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; -$$;