From 7d86f559d3077f4b2ad060cb279958b420e96646 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 22 Aug 2026 23:10:59 +0000 Subject: [PATCH] Terminal --- .../migrations/20260807000600_invoicing.sql | 755 ------------------ 1 file changed, 755 deletions(-) delete mode 100644 supabase/migrations/20260807000600_invoicing.sql diff --git a/supabase/migrations/20260807000600_invoicing.sql b/supabase/migrations/20260807000600_invoicing.sql deleted file mode 100644 index 45af6e5..0000000 --- a/supabase/migrations/20260807000600_invoicing.sql +++ /dev/null @@ -1,755 +0,0 @@ --- 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 s2.* FROM public.scholarships s2 - WHERE s2.student_id = _student AND s2.status = 'active' - AND s2.funding_period_start <= _to - AND (s2.funding_period_end IS NULL OR s2.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. - -- Every column is alias-qualified: this function's RETURNS TABLE declares an - -- `amount_cents` output, which would otherwise shadow the table's column. - FOR cred IN - SELECT sc.* FROM public.student_credits sc - WHERE sc.student_id = _student AND NOT sc.is_void - AND sc.amount_applied_cents < sc.amount_cents - AND (sc.expires_on IS NULL OR sc.expires_on >= _from) - ORDER BY sc.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());