-- 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());