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