Files
info-share-spot/supabase/migrations/20260807000600_invoicing.sql
T
2026-08-07 04:48:20 +00:00

756 lines
33 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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.
-- 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());