Terminal
This commit is contained in:
@@ -1,578 +0,0 @@
|
|||||||
-- 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