diff --git a/supabase/migrations/20260807000600_invoicing.sql b/supabase/migrations/20260807000600_invoicing.sql new file mode 100644 index 0000000..f7c61a5 --- /dev/null +++ b/supabase/migrations/20260807000600_invoicing.sql @@ -0,0 +1,753 @@ +-- Weekly invoicing and bulk generation — spec sections 9 and 10. +-- +-- Section 9 specifies a twelve-step calculation and requires that "every +-- generated invoice must contain an itemized explanation of the calculation". +-- That is honoured two ways: each line carries a `detail` JSONB recording the +-- rate, basis and quantity that produced it, and the invoice keeps a +-- `calculation_log` of the steps in order. Neither is derived at render time, +-- so an invoice reprinted a year later still explains itself even if the rate +-- cards have since changed. +-- +-- Section 10 requires a preview and an explicit confirmation before bulk +-- issuance. Batches therefore move draft → previewed → issued, and +-- generate_invoice_batch() refuses to run on a batch that was never previewed. + +-- ============================================================================ +-- 1. CREDITS +-- ============================================================================ +-- Step 9 of the engine applies "credits or adjustments". A credit sits here +-- unapplied until an invoice consumes it. + +CREATE TABLE IF NOT EXISTS public.student_credits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), + amount_applied_cents INTEGER NOT NULL DEFAULT 0 CHECK (amount_applied_cents >= 0), + reason TEXT NOT NULL, + issued_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_on DATE, + is_void BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT sc_not_over_applied CHECK (amount_applied_cents <= amount_cents) +); + +GRANT SELECT, INSERT, UPDATE ON public.student_credits TO authenticated; +GRANT ALL ON public.student_credits TO service_role; +ALTER TABLE public.student_credits ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS scr_student_idx ON public.student_credits (student_id); +CREATE INDEX IF NOT EXISTS scr_open_idx ON public.student_credits (student_id) + WHERE NOT is_void AND amount_applied_cents < amount_cents; + +-- ============================================================================ +-- 2. INVOICES +-- ============================================================================ + +CREATE SEQUENCE IF NOT EXISTS public.invoice_number_seq START 1000; + +CREATE TABLE IF NOT EXISTS public.invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_number TEXT NOT NULL UNIQUE, + + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE RESTRICT, + household_id UUID REFERENCES public.households(id) ON DELETE SET NULL, + campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, + batch_id UUID, + + billing_period_start DATE NOT NULL, + billing_period_end DATE NOT NULL, + invoice_date DATE NOT NULL DEFAULT CURRENT_DATE, + due_date DATE NOT NULL DEFAULT CURRENT_DATE, + + -- Classification captured as of issuance, not looked up later. + tuition_tier_id UUID REFERENCES public.tuition_tiers(id) ON DELETE SET NULL, + attendance_basis TEXT, + scheduled_days NUMERIC(6,2), + + subtotal_cents INTEGER NOT NULL DEFAULT 0, + scholarship_cents INTEGER NOT NULL DEFAULT 0, + credits_cents INTEGER NOT NULL DEFAULT 0, + penalties_cents INTEGER NOT NULL DEFAULT 0, + discounts_cents INTEGER NOT NULL DEFAULT 0, + total_cents INTEGER NOT NULL DEFAULT 0, + amount_paid_cents INTEGER NOT NULL DEFAULT 0, + balance_due_cents INTEGER GENERATED ALWAYS AS (total_cents - amount_paid_cents) STORED, + + status TEXT NOT NULL DEFAULT 'draft', + payment_plan_id UUID REFERENCES public.payment_plans(id) ON DELETE SET NULL, + payment_type TEXT, + + requires_signature BOOLEAN NOT NULL DEFAULT FALSE, + signed_at TIMESTAMPTZ, + signed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + signature_data TEXT, + + -- Ordered record of how the total was reached — the itemized explanation. + calculation_log JSONB NOT NULL DEFAULT '[]'::jsonb, + notes TEXT, + + issued_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + issued_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT inv_status_valid CHECK (status IN + ('draft','pending_review','issued','partially_paid','paid','void','written_off')), + CONSTRAINT inv_period_ordered CHECK (billing_period_start <= billing_period_end), + CONSTRAINT inv_paid_nonneg CHECK (amount_paid_cents >= 0) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.invoices TO authenticated; +GRANT ALL ON public.invoices TO service_role; +ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS inv_student_idx ON public.invoices (student_id, billing_period_start DESC); +CREATE INDEX IF NOT EXISTS inv_household_idx ON public.invoices (household_id); +CREATE INDEX IF NOT EXISTS inv_campus_idx ON public.invoices (campus_id); +CREATE INDEX IF NOT EXISTS inv_batch_idx ON public.invoices (batch_id); +CREATE INDEX IF NOT EXISTS inv_plan_idx ON public.invoices (payment_plan_id); +CREATE INDEX IF NOT EXISTS inv_tier_idx ON public.invoices (tuition_tier_id); +-- Receivables screen: everything not yet settled. +CREATE INDEX IF NOT EXISTS inv_outstanding_idx ON public.invoices (due_date) + WHERE status IN ('issued','partially_paid'); +-- One invoice per student per billing period, barring voids. +CREATE UNIQUE INDEX IF NOT EXISTS inv_unique_period_idx + ON public.invoices (student_id, billing_period_start, billing_period_end) + WHERE status <> 'void'; + +DROP TRIGGER IF EXISTS trg_inv_upd ON public.invoices; +CREATE TRIGGER trg_inv_upd BEFORE UPDATE ON public.invoices + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +CREATE TABLE IF NOT EXISTS public.invoice_line_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id UUID NOT NULL REFERENCES public.invoices(id) ON DELETE CASCADE, + line_kind TEXT NOT NULL, + description TEXT NOT NULL, + campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, + + quantity NUMERIC(10,4) NOT NULL DEFAULT 1, + unit_amount_cents INTEGER NOT NULL DEFAULT 0, + amount_cents INTEGER NOT NULL, + + tuition_rate_id UUID REFERENCES public.tuition_rates(id) ON DELETE SET NULL, + adjustment_rule_id UUID REFERENCES public.tuition_adjustment_rules(id) ON DELETE SET NULL, + scholarship_id UUID REFERENCES public.scholarships(id) ON DELETE SET NULL, + credit_id UUID REFERENCES public.student_credits(id) ON DELETE SET NULL, + + -- Why this line exists and how it was computed. + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + sort_order INTEGER NOT NULL DEFAULT 100, + + CONSTRAINT ili_kind_valid CHECK (line_kind IN + ('tuition','support_charge','early_dropoff','late_pickup','penalty', + 'discount','scholarship','credit','adjustment','other')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.invoice_line_items TO authenticated; +GRANT ALL ON public.invoice_line_items TO service_role; +ALTER TABLE public.invoice_line_items ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS ili_invoice_idx ON public.invoice_line_items (invoice_id, sort_order); +CREATE INDEX IF NOT EXISTS ili_campus_idx ON public.invoice_line_items (campus_id); +CREATE INDEX IF NOT EXISTS ili_rate_idx ON public.invoice_line_items (tuition_rate_id); +CREATE INDEX IF NOT EXISTS ili_rule_idx ON public.invoice_line_items (adjustment_rule_id); +CREATE INDEX IF NOT EXISTS ili_sch_idx ON public.invoice_line_items (scholarship_id); +CREATE INDEX IF NOT EXISTS ili_credit_idx ON public.invoice_line_items (credit_id); + +CREATE TABLE IF NOT EXISTS public.invoice_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id UUID NOT NULL REFERENCES public.invoices(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + title TEXT, + uploaded_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.invoice_documents TO authenticated; +GRANT ALL ON public.invoice_documents TO service_role; +ALTER TABLE public.invoice_documents ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS invdoc_invoice_idx ON public.invoice_documents (invoice_id); + +-- ============================================================================ +-- 3. BATCHES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.invoice_batches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT, + -- The section 10 selection criteria, stored verbatim so a batch can be + -- re-previewed or explained after the fact. + criteria JSONB NOT NULL DEFAULT '{}'::jsonb, + billing_period_start DATE NOT NULL, + billing_period_end DATE NOT NULL, + + status TEXT NOT NULL DEFAULT 'draft', + student_count INTEGER NOT NULL DEFAULT 0, + total_cents INTEGER NOT NULL DEFAULT 0, + + previewed_at TIMESTAMPTZ, + previewed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + confirmed_at TIMESTAMPTZ, + confirmed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT ib_status_valid CHECK (status IN ('draft','previewed','issued','cancelled')), + CONSTRAINT ib_period_ordered CHECK (billing_period_start <= billing_period_end) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.invoice_batches TO authenticated; +GRANT ALL ON public.invoice_batches TO service_role; +ALTER TABLE public.invoice_batches ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS ib_status_idx ON public.invoice_batches (status, created_at DESC); +CREATE INDEX IF NOT EXISTS ib_created_by_idx ON public.invoice_batches (created_by); + +DO $$ BEGIN + ALTER TABLE public.invoices + ADD CONSTRAINT invoices_batch_fk FOREIGN KEY (batch_id) + REFERENCES public.invoice_batches(id) ON DELETE SET NULL; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- ============================================================================ +-- 4. THE CALCULATION +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.next_invoice_number() +RETURNS TEXT LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + prefix TEXT; +BEGIN + SELECT invoice_number_prefix INTO prefix FROM public.billing_settings WHERE id; + RETURN COALESCE(prefix, 'INV') || '-' || to_char(nextval('public.invoice_number_seq'), 'FM000000'); +END; +$$; + +-- Steps 1–9 of the section 9 engine. Returns the lines an invoice would carry +-- for this student and period, without writing anything — which is what makes +-- the section 10 preview possible. +CREATE OR REPLACE FUNCTION public.compute_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 plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ +DECLARE + stu public.students%ROWTYPE; + ovr public.student_tuition_assignments%ROWTYPE; + rate public.tuition_rates%ROWTYPE; + sched RECORD; + rule RECORD; + sch RECORD; + cred RECORD; + settings public.billing_settings%ROWTYPE; + period_weeks NUMERIC; + weekly_basis INTEGER := 0; -- weekly tuition, for 'additional_week' rules + running_total INTEGER := 0; + qty NUMERIC; + amt INTEGER; + remaining INTEGER; +BEGIN + SELECT * INTO stu FROM public.students WHERE id = _student; + IF NOT FOUND THEN RETURN; END IF; + + SELECT * INTO settings FROM public.billing_settings WHERE id; + period_weeks := ROUND(((_to - _from + 1)::numeric / 7.0), 4); + + -- Step 1: a per-student override short-circuits the rate cards entirely. + SELECT * INTO ovr FROM public.resolve_tuition_override(_student, _from); + + IF ovr.id IS NOT NULL THEN + qty := CASE ovr.frequency + WHEN 'per_week' THEN period_weeks + WHEN 'per_day' THEN COALESCE((SELECT SUM(d.total_days) + FROM public.student_scheduled_days(_student,_from,_to) d), 0) + WHEN 'per_month' THEN ROUND(((_to - _from + 1)::numeric / 30.0), 4) + ELSE 1 + END; + amt := ROUND(qty * ovr.amount_cents); + running_total := running_total + amt; + IF ovr.frequency = 'per_week' THEN weekly_basis := ovr.amount_cents; END IF; + + RETURN QUERY SELECT + 'tuition'::TEXT, + format('Individual tuition assignment (%s)', ovr.frequency), + ovr.campus_id, qty, ovr.amount_cents, amt, + NULL::UUID, NULL::UUID, NULL::UUID, NULL::UUID, + jsonb_build_object( + 'step', 1, 'source', 'student_tuition_assignment', + 'assignment_id', ovr.id, 'reason', ovr.reason, + 'frequency', ovr.frequency, 'period_weeks', period_weeks), + 10; + ELSE + -- Steps 2–4: one tuition line per campus the student is scheduled at, so a + -- student split across campuses in one week is billed correctly at each. + FOR sched IN SELECT * FROM public.student_scheduled_days(_student, _from, _to) LOOP + SELECT * INTO rate FROM public.resolve_tuition_rate( + _student, sched.campus_id, _from, sched.days_per_week); + + IF rate.id IS NULL THEN + -- Legacy fallback so a student with no rate card still bills something + -- explicable rather than silently producing a zero invoice. + IF COALESCE(stu.daily_tuition_cents, 0) > 0 THEN + qty := sched.total_days; + amt := ROUND(qty * stu.daily_tuition_cents); + running_total := running_total + amt; + RETURN QUERY SELECT + 'tuition'::TEXT, + 'Tuition (legacy per-day rate — no matching rate card)', + sched.campus_id, qty, stu.daily_tuition_cents, amt, + NULL::UUID, NULL::UUID, NULL::UUID, NULL::UUID, + jsonb_build_object('step', 2, 'source', 'students.daily_tuition_cents', + 'days_per_week', sched.days_per_week, + 'weeks', sched.weeks, 'warning', 'no tuition_rate matched'), + 20; + END IF; + ELSE + -- Step 3: part-time bills per scheduled day, full-time per week. + IF rate.rate_basis = 'per_day' THEN + qty := sched.total_days; + ELSIF rate.rate_basis = 'per_week' THEN + qty := sched.weeks; + weekly_basis := GREATEST(weekly_basis, rate.amount_cents); + ELSIF rate.rate_basis = 'per_month' THEN + qty := ROUND(((_to - _from + 1)::numeric / 30.0), 4); + ELSE + qty := 1; + END IF; + + amt := ROUND(qty * rate.amount_cents); + running_total := running_total + amt; + + RETURN QUERY SELECT + 'tuition'::TEXT, + format('Tuition — %s (%s)', + COALESCE((SELECT name FROM public.campuses WHERE id = sched.campus_id), 'Campus'), + rate.rate_basis), + sched.campus_id, qty, rate.amount_cents, amt, + rate.id, NULL::UUID, NULL::UUID, NULL::UUID, + jsonb_build_object('step', 3, 'source', 'tuition_rate', 'rate_id', rate.id, + 'rate_basis', rate.rate_basis, + 'attendance_basis', stu.attendance_basis, + 'days_per_week', sched.days_per_week, + 'weeks', sched.weeks, 'scheduled_days', sched.total_days), + 20; + END IF; + END LOOP; + END IF; + + -- Step 6: support-student charges. + FOR rule IN + SELECT r.* FROM public.tuition_adjustment_rules r + WHERE r.is_active AND r.rule_type = 'support_charge' + AND r.effective_start <= _to + AND (r.effective_end IS NULL OR r.effective_end >= _from) + AND (r.requires_support_student IS NULL OR r.requires_support_student = stu.is_support_student) + AND (r.tier_id IS NULL OR r.tier_id = stu.tuition_tier_id) + AND (r.campus_id IS NULL OR r.campus_id IN (SELECT public.student_campus_ids(_student, _from, _to))) + AND (r.requires_tag_slug IS NULL OR public.student_has_tag(_student, r.requires_tag_slug)) + ORDER BY r.priority DESC + LOOP + CONTINUE WHEN NOT stu.is_support_student AND COALESCE(rule.requires_support_student, FALSE); + + IF rule.amount_basis = 'additional_week' THEN + -- Bill one further week at the resolved weekly rate, unless the rule + -- states its own amount. + amt := CASE WHEN COALESCE(rule.amount_cents, 0) > 0 THEN rule.amount_cents ELSE weekly_basis END; + qty := 1; + ELSIF rule.amount_basis = 'percent' THEN + amt := ROUND(running_total * rule.percent / 100.0); + qty := 1; + ELSIF rule.amount_basis = 'per_week' THEN + qty := period_weeks; amt := ROUND(qty * rule.amount_cents); + ELSIF rule.amount_basis = 'per_day' THEN + qty := COALESCE((SELECT SUM(d.total_days) + FROM public.student_scheduled_days(_student,_from,_to) d), 0); + amt := ROUND(qty * rule.amount_cents); + ELSE + qty := 1; amt := rule.amount_cents; + END IF; + + CONTINUE WHEN COALESCE(amt, 0) = 0; + running_total := running_total + amt; + + RETURN QUERY SELECT + 'support_charge'::TEXT, rule.name, rule.campus_id, qty, + CASE WHEN qty = 0 THEN 0 ELSE ROUND(amt / qty) END::INTEGER, amt, + NULL::UUID, rule.id, NULL::UUID, NULL::UUID, + jsonb_build_object('step', 6, 'source', 'tuition_adjustment_rule', + 'rule_id', rule.id, 'amount_basis', rule.amount_basis, + 'weekly_basis_cents', weekly_basis), + 30; + END LOOP; + + -- Step 8: early drop-off and late pick-up. + FOR sched IN + SELECT s.campus_id, s.early_dropoff, s.late_pickup, s.scheduled_days_per_week + FROM public.student_campus_schedules s + WHERE s.student_id = _student + AND s.effective_start <= _to + AND (s.effective_end IS NULL OR s.effective_end >= _from) + LOOP + IF sched.early_dropoff AND COALESCE(settings.early_dropoff_fee_cents, 0) > 0 THEN + qty := ROUND(period_weeks * sched.scheduled_days_per_week, 4); + amt := ROUND(qty * settings.early_dropoff_fee_cents); + running_total := running_total + amt; + RETURN QUERY SELECT + 'early_dropoff'::TEXT, 'Early drop-off', sched.campus_id, qty, + settings.early_dropoff_fee_cents, amt, + NULL::UUID, NULL::UUID, NULL::UUID, NULL::UUID, + jsonb_build_object('step', 8, 'source', 'billing_settings.early_dropoff_fee_cents'), 40; + END IF; + + IF sched.late_pickup AND COALESCE(settings.late_pickup_fee_cents, 0) > 0 THEN + qty := ROUND(period_weeks * sched.scheduled_days_per_week, 4); + amt := ROUND(qty * settings.late_pickup_fee_cents); + running_total := running_total + amt; + RETURN QUERY SELECT + 'late_pickup'::TEXT, 'Late pick-up', sched.campus_id, qty, + settings.late_pickup_fee_cents, amt, + NULL::UUID, NULL::UUID, NULL::UUID, NULL::UUID, + jsonb_build_object('step', 8, 'source', 'billing_settings.late_pickup_fee_cents'), 40; + END IF; + END LOOP; + + -- Step 5: scholarship funding, as a negative line. + FOR sch IN + SELECT * FROM public.scholarships + WHERE student_id = _student AND status = 'active' + AND funding_period_start <= _to + AND (funding_period_end IS NULL OR funding_period_end >= _from) + LOOP + qty := CASE sch.frequency + WHEN 'per_week' THEN period_weeks + WHEN 'per_day' THEN COALESCE((SELECT SUM(d.total_days) + FROM public.student_scheduled_days(_student,_from,_to) d), 0) + WHEN 'per_month' THEN ROUND(((_to - _from + 1)::numeric / 30.0), 4) + ELSE 1 + END; + amt := LEAST(ROUND(qty * sch.award_amount_cents), running_total); + CONTINUE WHEN amt <= 0; + running_total := running_total - amt; + + RETURN QUERY SELECT + 'scholarship'::TEXT, sch.program_name, NULL::UUID, qty, + sch.award_amount_cents, -amt, + NULL::UUID, NULL::UUID, sch.id, NULL::UUID, + jsonb_build_object('step', 5, 'source', 'scholarship', 'scholarship_id', sch.id, + 'frequency', sch.frequency, + 'capped_at_running_total', ROUND(qty * sch.award_amount_cents) > amt), + 50; + END LOOP; + + -- Step 9: outstanding credits, oldest first, never below zero. + FOR cred IN + SELECT * FROM public.student_credits + WHERE student_id = _student AND NOT is_void + AND amount_applied_cents < amount_cents + AND (expires_on IS NULL OR expires_on >= _from) + ORDER BY issued_at + LOOP + EXIT WHEN running_total <= 0; + remaining := cred.amount_cents - cred.amount_applied_cents; + amt := LEAST(remaining, running_total); + running_total := running_total - amt; + + RETURN QUERY SELECT + 'credit'::TEXT, COALESCE(cred.reason, 'Credit'), NULL::UUID, 1::NUMERIC, + amt, -amt, + NULL::UUID, NULL::UUID, NULL::UUID, cred.id, + jsonb_build_object('step', 9, 'source', 'student_credit', 'credit_id', cred.id, + 'credit_remaining_cents', remaining), 60; + END LOOP; +END; +$$; + +-- Steps 10–11: persist a computed invoice. Returns the new invoice id. +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_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; + + -- Consume the credit as it is applied. + 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; + + 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; +$$; + +-- ============================================================================ +-- 5. BULK GENERATION (section 10) +-- ============================================================================ + +-- Students matching the batch criteria. Supported keys: student_ids[], +-- campus_ids[], attendance_basis, tier_ids[], support_only, scholarship_only. +CREATE OR REPLACE FUNCTION public.eligible_students_for_batch(_criteria JSONB, _from DATE, _to DATE) +RETURNS TABLE (student_id UUID) +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT s.id + FROM public.students s + WHERE s.enrollment_status = 'enrolled' + AND (NOT (_criteria ? 'student_ids') + OR s.id = ANY (SELECT (jsonb_array_elements_text(_criteria->'student_ids'))::uuid)) + AND (NOT (_criteria ? 'campus_ids') + OR EXISTS (SELECT 1 FROM public.student_campus_ids(s.id, _from, _to) c + WHERE c = ANY (SELECT (jsonb_array_elements_text(_criteria->'campus_ids'))::uuid))) + AND (NOT (_criteria ? 'attendance_basis') + OR s.attendance_basis = _criteria->>'attendance_basis') + AND (NOT (_criteria ? 'tier_ids') + OR s.tuition_tier_id = ANY (SELECT (jsonb_array_elements_text(_criteria->'tier_ids'))::uuid)) + AND (NOT (_criteria ? 'support_only') OR (_criteria->>'support_only')::boolean IS NOT TRUE + OR s.is_support_student) + AND (NOT (_criteria ? 'scholarship_only') OR (_criteria->>'scholarship_only')::boolean IS NOT TRUE + OR EXISTS (SELECT 1 FROM public.scholarships sh + WHERE sh.student_id = s.id AND sh.status = 'active')) +$$; + +-- The preview the spec requires before anything is issued. Computes but does +-- not write, then records that the batch has been previewed. +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_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; +$$; + +-- Step 12: issuance, gated on the batch having been previewed and confirmed. +CREATE OR REPLACE FUNCTION public.generate_invoice_batch(_batch UUID) +RETURNS INTEGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + b public.invoice_batches%ROWTYPE; + r RECORD; + n INTEGER := 0; + sum_c BIGINT := 0; + iid UUID; +BEGIN + IF NOT (public.is_billing_admin() OR public.is_org_admin()) THEN + RAISE EXCEPTION 'insufficient privileges to issue invoice batches'; + END IF; + + SELECT * INTO b FROM public.invoice_batches WHERE id = _batch FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'batch % not found', _batch; END IF; + IF b.status = 'issued' THEN RAISE EXCEPTION 'batch % already issued', _batch; END IF; + -- The spec requires administrative confirmation before bulk issuance. + IF b.status <> 'previewed' THEN + RAISE EXCEPTION 'batch % must be previewed before it can be issued (status: %)', _batch, b.status; + END IF; + IF b.confirmed_at IS NULL THEN + RAISE EXCEPTION 'batch % has not been confirmed by an administrator', _batch; + END IF; + + FOR r IN SELECT e.student_id FROM public.eligible_students_for_batch( + b.criteria, b.billing_period_start, b.billing_period_end) e + LOOP + -- Skip anyone already invoiced for this period rather than failing the + -- whole batch on the unique index. + CONTINUE WHEN EXISTS ( + SELECT 1 FROM public.invoices i + WHERE i.student_id = r.student_id + AND i.billing_period_start = b.billing_period_start + AND i.billing_period_end = b.billing_period_end + AND i.status <> 'void'); + + iid := public.create_invoice(r.student_id, b.billing_period_start, + b.billing_period_end, _batch, 'issued'); + n := n + 1; + sum_c := sum_c + COALESCE((SELECT total_cents FROM public.invoices WHERE id = iid), 0); + END LOOP; + + UPDATE public.invoice_batches + SET status = 'issued', student_count = n, total_cents = LEAST(sum_c, 2147483647) + WHERE id = _batch; + + RETURN n; +END; +$$; + +-- ============================================================================ +-- 6. POLICIES +-- ============================================================================ + +DROP POLICY IF EXISTS "credits read" ON public.student_credits; +CREATE POLICY "credits read" ON public.student_credits FOR SELECT TO authenticated + USING (public.is_billing_admin() OR public.is_auditor() OR public.is_parent_of(student_id)); +DROP POLICY IF EXISTS "credits manage" ON public.student_credits; +CREATE POLICY "credits manage" ON public.student_credits FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +-- Guardians see invoices for their own children. Campus staff see invoices for +-- students at their campus; teachers do not, per the section 3 restriction on +-- teacher access to billing. +DROP POLICY IF EXISTS "invoices read" ON public.invoices; +CREATE POLICY "invoices read" ON public.invoices FOR SELECT TO authenticated + USING ( + public.is_billing_admin() OR public.is_org_admin() OR public.is_auditor() + OR public.is_parent_of(student_id) + OR (household_id IS NOT NULL AND household_id IN (SELECT public.user_household_ids())) + OR (campus_id IS NOT NULL + AND public.current_user_has_any_role(ARRAY['campus_admin','management']::app_role[]) + AND campus_id IN (SELECT public.user_campus_ids())) + ); + +DROP POLICY IF EXISTS "invoices manage" ON public.invoices; +CREATE POLICY "invoices manage" ON public.invoices FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "invoice lines read" ON public.invoice_line_items; +CREATE POLICY "invoice lines read" ON public.invoice_line_items FOR SELECT TO authenticated + USING (EXISTS (SELECT 1 FROM public.invoices i WHERE i.id = invoice_id)); +DROP POLICY IF EXISTS "invoice lines manage" ON public.invoice_line_items; +CREATE POLICY "invoice lines manage" ON public.invoice_line_items FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "invoice docs read" ON public.invoice_documents; +CREATE POLICY "invoice docs read" ON public.invoice_documents FOR SELECT TO authenticated + USING (EXISTS (SELECT 1 FROM public.invoices i WHERE i.id = invoice_id)); +DROP POLICY IF EXISTS "invoice docs manage" ON public.invoice_documents; +CREATE POLICY "invoice docs manage" ON public.invoice_documents FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "batches read" ON public.invoice_batches; +CREATE POLICY "batches read" ON public.invoice_batches FOR SELECT TO authenticated + USING (public.is_billing_admin() OR public.is_org_admin() OR public.is_auditor()); +DROP POLICY IF EXISTS "batches manage" ON public.invoice_batches; +CREATE POLICY "batches manage" ON public.invoice_batches FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin());