Files
info-share-spot/supabase/migrations/20260807001700_penalties.sql
T
2026-08-07 13:45:29 +00:00

579 lines
24 KiB
PL/PgSQL

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