Modified by www.SourceFiles.app
This commit is contained in:
@@ -0,0 +1,578 @@
|
|||||||
|
-- Early drop-off and late pick-up fees — spec section 15.
|
||||||
|
--
|
||||||
|
-- The flat per-day fees in billing_settings were a placeholder. Section 15 needs
|
||||||
|
-- allowed times, grace periods, per-minute or flat methods, a fee cap, an
|
||||||
|
-- escalation for repeat occurrences, waiver authority with a reason, and parent
|
||||||
|
-- notification — none of which fit in a single cents column.
|
||||||
|
--
|
||||||
|
-- A fee has two lives: the *event* (this child was collected 22 minutes late on
|
||||||
|
-- Tuesday) and the *charge* it produces on the next invoice. Keeping them apart
|
||||||
|
-- is what makes waiving possible without erasing the fact that it happened.
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 1. RULES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.penalty_rules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
rule_type TEXT NOT NULL,
|
||||||
|
campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Earliest permitted arrival, or latest permitted collection. NULL falls back
|
||||||
|
-- to the campus's own early_dropoff_time / late_pickup_cutoff.
|
||||||
|
allowed_time TIME,
|
||||||
|
grace_period_minutes INTEGER NOT NULL DEFAULT 0 CHECK (grace_period_minutes >= 0),
|
||||||
|
|
||||||
|
method TEXT NOT NULL DEFAULT 'flat',
|
||||||
|
amount_cents INTEGER NOT NULL DEFAULT 0 CHECK (amount_cents >= 0),
|
||||||
|
-- For per_increment: charge amount_cents per this many minutes, part-blocks
|
||||||
|
-- rounded up.
|
||||||
|
increment_minutes INTEGER CHECK (increment_minutes IS NULL OR increment_minutes > 0),
|
||||||
|
max_fee_cents INTEGER CHECK (max_fee_cents IS NULL OR max_fee_cents >= 0),
|
||||||
|
|
||||||
|
-- Escalation for the "excessive" case: after N occurrences inside a window,
|
||||||
|
-- the higher amount applies.
|
||||||
|
threshold_count INTEGER CHECK (threshold_count IS NULL OR threshold_count > 0),
|
||||||
|
threshold_window_days INTEGER CHECK (threshold_window_days IS NULL OR threshold_window_days > 0),
|
||||||
|
escalated_amount_cents INTEGER CHECK (escalated_amount_cents IS NULL OR escalated_amount_cents >= 0),
|
||||||
|
|
||||||
|
-- Who may waive a fee raised under this rule.
|
||||||
|
waiver_roles app_role[] NOT NULL
|
||||||
|
DEFAULT ARRAY['admin','org_admin','super_admin','campus_admin']::app_role[],
|
||||||
|
requires_approval BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
notify_parent BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
effective_start DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
effective_end DATE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT pr_type_valid CHECK (rule_type IN
|
||||||
|
('early_dropoff','late_pickup','excessive_late_pickup','schedule_violation')),
|
||||||
|
CONSTRAINT pr_method_valid CHECK (method IN ('flat','per_minute','per_increment')),
|
||||||
|
CONSTRAINT pr_increment_present
|
||||||
|
CHECK (method <> 'per_increment' OR increment_minutes IS NOT NULL),
|
||||||
|
CONSTRAINT pr_dates_ordered CHECK (effective_end IS NULL OR effective_start <= effective_end)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.penalty_rules TO authenticated;
|
||||||
|
GRANT ALL ON public.penalty_rules TO service_role;
|
||||||
|
ALTER TABLE public.penalty_rules ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE INDEX IF NOT EXISTS pr_campus_idx ON public.penalty_rules (campus_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS pr_active_idx ON public.penalty_rules (rule_type, priority DESC)
|
||||||
|
WHERE is_active;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_pr_upd ON public.penalty_rules;
|
||||||
|
CREATE TRIGGER trg_pr_upd BEFORE UPDATE ON public.penalty_rules
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
-- No rules are seeded. A fee schedule invented here would look official and
|
||||||
|
-- start charging families real money.
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 2. EVENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.penalty_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||||
|
campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL,
|
||||||
|
rule_id UUID REFERENCES public.penalty_rules(id) ON DELETE SET NULL,
|
||||||
|
attendance_id UUID REFERENCES public.attendance(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
occurred_on DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
minutes_over INTEGER NOT NULL DEFAULT 0 CHECK (minutes_over >= 0),
|
||||||
|
|
||||||
|
-- What the rule produced, versus what is actually being charged. They differ
|
||||||
|
-- when a fee is capped, escalated, or manually overridden.
|
||||||
|
computed_fee_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
final_fee_cents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
override_reason TEXT,
|
||||||
|
|
||||||
|
is_waived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
waiver_reason TEXT,
|
||||||
|
waived_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
waived_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
approved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
parent_notified_at TIMESTAMPTZ,
|
||||||
|
notification_method TEXT,
|
||||||
|
|
||||||
|
-- Set once the fee has been carried onto an invoice, so it is billed once.
|
||||||
|
invoice_id UUID REFERENCES public.invoices(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
recorded_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT pe_type_valid CHECK (event_type IN
|
||||||
|
('early_dropoff','late_pickup','excessive_late_pickup','schedule_violation')),
|
||||||
|
-- A waiver without a reason is not a waiver, it is a deletion.
|
||||||
|
CONSTRAINT pe_waiver_reason CHECK (NOT is_waived OR COALESCE(TRIM(waiver_reason), '') <> '')
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON public.penalty_events TO authenticated;
|
||||||
|
GRANT ALL ON public.penalty_events TO service_role;
|
||||||
|
ALTER TABLE public.penalty_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE INDEX IF NOT EXISTS pe_student_idx ON public.penalty_events (student_id, occurred_on DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS pe_campus_idx ON public.penalty_events (campus_id, occurred_on);
|
||||||
|
CREATE INDEX IF NOT EXISTS pe_rule_idx ON public.penalty_events (rule_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS pe_attendance_idx ON public.penalty_events (attendance_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS pe_invoice_idx ON public.penalty_events (invoice_id);
|
||||||
|
-- The billing query: chargeable, not waived, not yet invoiced.
|
||||||
|
CREATE INDEX IF NOT EXISTS pe_billable_idx ON public.penalty_events (student_id, occurred_on)
|
||||||
|
WHERE invoice_id IS NULL AND NOT is_waived;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_pe_upd ON public.penalty_events;
|
||||||
|
CREATE TRIGGER trg_pe_upd BEFORE UPDATE ON public.penalty_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
-- A waiver must be made by someone the rule authorises.
|
||||||
|
CREATE OR REPLACE FUNCTION public.enforce_waiver_authority()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
DECLARE
|
||||||
|
allowed app_role[];
|
||||||
|
BEGIN
|
||||||
|
IF NEW.is_waived AND NOT COALESCE(OLD.is_waived, FALSE) THEN
|
||||||
|
SELECT waiver_roles INTO allowed FROM public.penalty_rules WHERE id = NEW.rule_id;
|
||||||
|
IF allowed IS NOT NULL
|
||||||
|
AND NOT public.is_org_admin()
|
||||||
|
AND NOT public.current_user_has_any_role(allowed) THEN
|
||||||
|
RAISE EXCEPTION 'your role may not waive fees under this rule';
|
||||||
|
END IF;
|
||||||
|
NEW.waived_by := COALESCE(NEW.waived_by, (SELECT auth.uid()));
|
||||||
|
NEW.waived_at := COALESCE(NEW.waived_at, now());
|
||||||
|
NEW.final_fee_cents := 0;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_pe_waiver ON public.penalty_events;
|
||||||
|
CREATE TRIGGER trg_pe_waiver BEFORE UPDATE ON public.penalty_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.enforce_waiver_authority();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 3. ASSESSMENT
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.resolve_penalty_rule(_campus UUID, _type TEXT, _on DATE)
|
||||||
|
RETURNS public.penalty_rules
|
||||||
|
LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT r.* FROM public.penalty_rules r
|
||||||
|
WHERE r.is_active AND r.rule_type = _type
|
||||||
|
AND r.effective_start <= _on
|
||||||
|
AND (r.effective_end IS NULL OR r.effective_end >= _on)
|
||||||
|
AND (r.campus_id IS NULL OR r.campus_id = _campus)
|
||||||
|
ORDER BY r.priority DESC, (r.campus_id IS NOT NULL) DESC
|
||||||
|
LIMIT 1
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.compute_penalty_fee(
|
||||||
|
_rule public.penalty_rules, _minutes_over INTEGER, _escalated BOOLEAN DEFAULT FALSE
|
||||||
|
)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql IMMUTABLE AS $$
|
||||||
|
DECLARE
|
||||||
|
base INTEGER;
|
||||||
|
fee INTEGER;
|
||||||
|
billable INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF _rule.id IS NULL THEN RETURN 0; END IF;
|
||||||
|
|
||||||
|
billable := GREATEST(_minutes_over - _rule.grace_period_minutes, 0);
|
||||||
|
IF billable = 0 THEN RETURN 0; END IF;
|
||||||
|
|
||||||
|
base := CASE WHEN _escalated AND _rule.escalated_amount_cents IS NOT NULL
|
||||||
|
THEN _rule.escalated_amount_cents ELSE _rule.amount_cents END;
|
||||||
|
|
||||||
|
fee := CASE _rule.method
|
||||||
|
WHEN 'flat' THEN base
|
||||||
|
WHEN 'per_minute' THEN base * billable
|
||||||
|
WHEN 'per_increment' THEN base * CEIL(billable::numeric / _rule.increment_minutes)
|
||||||
|
ELSE base
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF _rule.max_fee_cents IS NOT NULL THEN
|
||||||
|
fee := LEAST(fee, _rule.max_fee_cents);
|
||||||
|
END IF;
|
||||||
|
RETURN fee;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Raise events from an attendance row. Idempotent per (attendance, type), so
|
||||||
|
-- re-running after a correction does not double-charge.
|
||||||
|
CREATE OR REPLACE FUNCTION public.assess_attendance_penalties(_attendance UUID)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
DECLARE
|
||||||
|
att public.attendance%ROWTYPE;
|
||||||
|
cam public.campuses%ROWTYPE;
|
||||||
|
rule public.penalty_rules%ROWTYPE;
|
||||||
|
tz TEXT;
|
||||||
|
mins INTEGER;
|
||||||
|
fee INTEGER;
|
||||||
|
recent INTEGER;
|
||||||
|
escalate BOOLEAN;
|
||||||
|
created INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO att FROM public.attendance WHERE id = _attendance;
|
||||||
|
IF NOT FOUND THEN RETURN 0; END IF;
|
||||||
|
SELECT * INTO cam FROM public.campuses WHERE id = att.campus_id;
|
||||||
|
tz := COALESCE(cam.timezone, 'America/New_York');
|
||||||
|
|
||||||
|
-- Early drop-off
|
||||||
|
IF att.check_in_at IS NOT NULL THEN
|
||||||
|
SELECT * INTO rule FROM public.resolve_penalty_rule(att.campus_id, 'early_dropoff', att.date);
|
||||||
|
IF rule.id IS NOT NULL THEN
|
||||||
|
mins := GREATEST(
|
||||||
|
EXTRACT(EPOCH FROM (
|
||||||
|
COALESCE(rule.allowed_time, cam.early_dropoff_time, att.expected_arrival)
|
||||||
|
- (att.check_in_at AT TIME ZONE tz)::time
|
||||||
|
)) / 60, 0)::int;
|
||||||
|
fee := public.compute_penalty_fee(rule, mins);
|
||||||
|
IF fee > 0 THEN
|
||||||
|
INSERT INTO public.penalty_events
|
||||||
|
(student_id, campus_id, rule_id, attendance_id, event_type, occurred_on,
|
||||||
|
minutes_over, computed_fee_cents, final_fee_cents, recorded_by)
|
||||||
|
SELECT att.student_id, att.campus_id, rule.id, att.id, 'early_dropoff', att.date,
|
||||||
|
mins, fee, fee, (SELECT auth.uid())
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.penalty_events e
|
||||||
|
WHERE e.attendance_id = att.id AND e.event_type = 'early_dropoff');
|
||||||
|
created := created + 1;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Late pick-up, escalating when it keeps happening.
|
||||||
|
IF att.check_out_at IS NOT NULL THEN
|
||||||
|
SELECT * INTO rule FROM public.resolve_penalty_rule(att.campus_id, 'late_pickup', att.date);
|
||||||
|
IF rule.id IS NOT NULL THEN
|
||||||
|
mins := GREATEST(
|
||||||
|
EXTRACT(EPOCH FROM (
|
||||||
|
(att.check_out_at AT TIME ZONE tz)::time
|
||||||
|
- COALESCE(rule.allowed_time, cam.late_pickup_cutoff, att.expected_departure)
|
||||||
|
)) / 60, 0)::int;
|
||||||
|
|
||||||
|
escalate := FALSE;
|
||||||
|
IF rule.threshold_count IS NOT NULL AND rule.threshold_window_days IS NOT NULL THEN
|
||||||
|
SELECT COUNT(*) INTO recent FROM public.penalty_events e
|
||||||
|
WHERE e.student_id = att.student_id
|
||||||
|
AND e.event_type IN ('late_pickup','excessive_late_pickup')
|
||||||
|
AND NOT e.is_waived
|
||||||
|
AND e.occurred_on >= att.date - rule.threshold_window_days;
|
||||||
|
escalate := recent >= rule.threshold_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
fee := public.compute_penalty_fee(rule, mins, escalate);
|
||||||
|
IF fee > 0 THEN
|
||||||
|
INSERT INTO public.penalty_events
|
||||||
|
(student_id, campus_id, rule_id, attendance_id, event_type, occurred_on,
|
||||||
|
minutes_over, computed_fee_cents, final_fee_cents, recorded_by, notes)
|
||||||
|
SELECT att.student_id, att.campus_id, rule.id, att.id,
|
||||||
|
CASE WHEN escalate THEN 'excessive_late_pickup' ELSE 'late_pickup' END,
|
||||||
|
att.date, mins, fee, fee, (SELECT auth.uid()),
|
||||||
|
CASE WHEN escalate THEN 'Escalated: repeat occurrences in window' END
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.penalty_events e
|
||||||
|
WHERE e.attendance_id = att.id
|
||||||
|
AND e.event_type IN ('late_pickup','excessive_late_pickup'));
|
||||||
|
created := created + 1;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN created;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Opt-in automation. Left off so enabling fee assessment is a decision, not a
|
||||||
|
-- side effect of this migration.
|
||||||
|
ALTER TABLE public.billing_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS auto_assess_penalties BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.auto_assess_penalties_trigger()
|
||||||
|
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
DECLARE
|
||||||
|
enabled BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
SELECT auto_assess_penalties INTO enabled FROM public.billing_settings WHERE id;
|
||||||
|
IF COALESCE(enabled, FALSE) THEN
|
||||||
|
PERFORM public.assess_attendance_penalties(NEW.id);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_attendance_penalties ON public.attendance;
|
||||||
|
CREATE TRIGGER trg_attendance_penalties AFTER INSERT OR UPDATE OF check_in_at, check_out_at
|
||||||
|
ON public.attendance
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.auto_assess_penalties_trigger();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 4. BILLING THE FEES
|
||||||
|
-- ============================================================================
|
||||||
|
-- compute_invoice_lines covers tuition, support charges and funding.
|
||||||
|
-- Penalty events are separate because they are facts about days that already
|
||||||
|
-- happened rather than a calculation over the schedule. compute_all_invoice_lines
|
||||||
|
-- is the entry point the invoice engine now uses; call it, not the tuition
|
||||||
|
-- function alone, or fees will silently never be billed.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.compute_penalty_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 SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
CASE e.event_type WHEN 'early_dropoff' THEN 'early_dropoff' ELSE 'late_pickup' END,
|
||||||
|
CASE e.event_type
|
||||||
|
WHEN 'early_dropoff' THEN 'Early drop-off — ' || to_char(e.occurred_on, 'Mon DD')
|
||||||
|
WHEN 'excessive_late_pickup' THEN 'Late pick-up (repeat) — ' || to_char(e.occurred_on, 'Mon DD')
|
||||||
|
ELSE 'Late pick-up — ' || to_char(e.occurred_on, 'Mon DD')
|
||||||
|
END,
|
||||||
|
e.campus_id,
|
||||||
|
1::numeric,
|
||||||
|
e.final_fee_cents,
|
||||||
|
e.final_fee_cents,
|
||||||
|
NULL::uuid, NULL::uuid, NULL::uuid, NULL::uuid,
|
||||||
|
jsonb_build_object('step', 8, 'source', 'penalty_event', 'event_id', e.id,
|
||||||
|
'rule_id', e.rule_id, 'minutes_over', e.minutes_over,
|
||||||
|
'computed_fee_cents', e.computed_fee_cents),
|
||||||
|
45
|
||||||
|
FROM public.penalty_events e
|
||||||
|
WHERE e.student_id = _student
|
||||||
|
AND e.occurred_on BETWEEN _from AND _to
|
||||||
|
AND NOT e.is_waived
|
||||||
|
AND e.invoice_id IS NULL
|
||||||
|
AND e.final_fee_cents > 0
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.compute_all_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 SQL STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT * FROM public.compute_invoice_lines(_student, _from, _to)
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM public.compute_penalty_lines(_student, _from, _to)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Attach billed events to their invoice so they cannot be charged twice.
|
||||||
|
CREATE OR REPLACE FUNCTION public.mark_penalties_invoiced(_student UUID, _from DATE, _to DATE, _invoice UUID)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
DECLARE
|
||||||
|
n INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE public.penalty_events
|
||||||
|
SET invoice_id = _invoice
|
||||||
|
WHERE student_id = _student
|
||||||
|
AND occurred_on BETWEEN _from AND _to
|
||||||
|
AND NOT is_waived
|
||||||
|
AND invoice_id IS NULL
|
||||||
|
AND final_fee_cents > 0;
|
||||||
|
GET DIAGNOSTICS n = ROW_COUNT;
|
||||||
|
RETURN n;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5. POLICIES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "penalty rules read" ON public.penalty_rules;
|
||||||
|
CREATE POLICY "penalty rules read" ON public.penalty_rules FOR SELECT TO authenticated
|
||||||
|
USING (TRUE);
|
||||||
|
DROP POLICY IF EXISTS "penalty rules manage" ON public.penalty_rules;
|
||||||
|
CREATE POLICY "penalty rules manage" ON public.penalty_rules FOR ALL TO authenticated
|
||||||
|
USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin());
|
||||||
|
|
||||||
|
-- Parents can see a fee raised against their own child, which is what makes
|
||||||
|
-- the charge on the next invoice explicable.
|
||||||
|
DROP POLICY IF EXISTS "penalty events read" ON public.penalty_events;
|
||||||
|
CREATE POLICY "penalty events read" ON public.penalty_events FOR SELECT TO authenticated
|
||||||
|
USING (public.can_access_student(student_id) OR public.is_billing_admin() OR public.is_auditor());
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "penalty events insert" ON public.penalty_events;
|
||||||
|
CREATE POLICY "penalty events insert" ON public.penalty_events FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (public.can_manage_student(student_id) OR public.is_billing_admin());
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "penalty events update" ON public.penalty_events;
|
||||||
|
CREATE POLICY "penalty events update" ON public.penalty_events FOR UPDATE TO authenticated
|
||||||
|
USING (public.can_manage_student(student_id) OR public.is_billing_admin())
|
||||||
|
WITH CHECK (public.can_manage_student(student_id) OR public.is_billing_admin());
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 6. REWIRE THE INVOICE ENGINE
|
||||||
|
-- ============================================================================
|
||||||
|
-- Both entry points move from compute_invoice_lines to compute_all_invoice_lines
|
||||||
|
-- so the preview a parent is shown and the invoice they are sent agree. Bodies
|
||||||
|
-- are otherwise unchanged from 20260807000600.
|
||||||
|
|
||||||
|
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_all_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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- Claim the fee events this invoice just billed.
|
||||||
|
PERFORM public.mark_penalties_invoiced(_student, _from, _to, inv_id);
|
||||||
|
|
||||||
|
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;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
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_all_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;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user