diff --git a/supabase/migrations/20260807001600_payments.sql b/supabase/migrations/20260807001600_payments.sql new file mode 100644 index 0000000..7bbb5e2 --- /dev/null +++ b/supabase/migrations/20260807001600_payments.sql @@ -0,0 +1,468 @@ +-- Payments, allocation, refunds and reversals — spec sections 12 and 13. +-- +-- Section 40 forbids permanently deleting financial records, so nothing here +-- edits or removes money once posted. A correction is always a *new* row that +-- references the original: a reversal cancels a payment, a refund returns it. +-- The original stays exactly as it was recorded. +-- +-- Allocation is separated from the payment itself. One payment can settle +-- several invoices, and the spec requires both explicit allocation and +-- oldest-balance-first, so the split has to be its own table. + +-- ============================================================================ +-- 1. PAYMENTS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- A payment may be aimed at one student or at the household as a whole. + student_id UUID REFERENCES public.students(id) ON DELETE RESTRICT, + household_id UUID REFERENCES public.households(id) ON DELETE RESTRICT, + campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, + + kind TEXT NOT NULL DEFAULT 'payment', + method TEXT NOT NULL, + amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), + + -- When the money moved, versus the date it should count against. A cheque + -- received Monday for last week's invoice needs both. + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + effective_date DATE NOT NULL DEFAULT CURRENT_DATE, + + reference_number TEXT, + payer_name TEXT, + third_party_name TEXT, + scholarship_id UUID REFERENCES public.scholarships(id) ON DELETE SET NULL, + + status TEXT NOT NULL DEFAULT 'posted', + -- Set on a reversal or refund row, pointing at what it undoes. + reverses_payment_id UUID REFERENCES public.payments(id) ON DELETE RESTRICT, + void_reason TEXT, + + notes TEXT, + document_path TEXT, + + received_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 pay_kind_valid CHECK (kind IN ('payment','refund','reversal')), + CONSTRAINT pay_method_valid CHECK (method IN + ('cash','check','ach','card','scholarship','third_party','credit','other')), + CONSTRAINT pay_status_valid CHECK (status IN ('posted','voided','reversed')), + -- Must attach to somebody. + CONSTRAINT pay_subject_present CHECK (student_id IS NOT NULL OR household_id IS NOT NULL), + -- Refunds and reversals must say what they undo; ordinary payments must not. + CONSTRAINT pay_reversal_target CHECK ( + (kind = 'payment' AND reverses_payment_id IS NULL) + OR (kind IN ('refund','reversal') AND reverses_payment_id IS NOT NULL) + ) +); + +GRANT SELECT, INSERT, UPDATE ON public.payments TO authenticated; +GRANT ALL ON public.payments TO service_role; +ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS pay_student_idx ON public.payments (student_id, effective_date DESC); +CREATE INDEX IF NOT EXISTS pay_household_idx ON public.payments (household_id, effective_date DESC); +CREATE INDEX IF NOT EXISTS pay_campus_idx ON public.payments (campus_id, effective_date); +CREATE INDEX IF NOT EXISTS pay_scholarship_idx ON public.payments (scholarship_id); +CREATE INDEX IF NOT EXISTS pay_reverses_idx ON public.payments (reverses_payment_id); +CREATE INDEX IF NOT EXISTS pay_received_by_idx ON public.payments (received_by); +CREATE INDEX IF NOT EXISTS pay_posted_idx ON public.payments (effective_date) WHERE status = 'posted'; + +DROP TRIGGER IF EXISTS trg_pay_upd ON public.payments; +CREATE TRIGGER trg_pay_upd BEFORE UPDATE ON public.payments + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 2. ALLOCATION +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.payment_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES public.payments(id) ON DELETE CASCADE, + invoice_id UUID NOT NULL REFERENCES public.invoices(id) ON DELETE RESTRICT, + amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), + allocated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + allocated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (payment_id, invoice_id) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.payment_allocations TO authenticated; +GRANT ALL ON public.payment_allocations TO service_role; +ALTER TABLE public.payment_allocations ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS pa_payment_idx ON public.payment_allocations (payment_id); +CREATE INDEX IF NOT EXISTS pa_invoice_idx ON public.payment_allocations (invoice_id); + +-- How much of a payment is not yet applied to an invoice. +CREATE OR REPLACE FUNCTION public.payment_unallocated_cents(_payment UUID) +RETURNS INTEGER +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT p.amount_cents - COALESCE(( + SELECT SUM(a.amount_cents) FROM public.payment_allocations a WHERE a.payment_id = p.id + ), 0) + FROM public.payments p WHERE p.id = _payment +$$; + +-- A payment may never be allocated beyond its own value. +CREATE OR REPLACE FUNCTION public.check_allocation_fits() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + total INTEGER; + amt INTEGER; +BEGIN + SELECT amount_cents INTO amt FROM public.payments WHERE id = NEW.payment_id; + SELECT COALESCE(SUM(amount_cents), 0) INTO total + FROM public.payment_allocations + WHERE payment_id = NEW.payment_id AND id <> NEW.id; + + IF total + NEW.amount_cents > amt THEN + RAISE EXCEPTION 'allocation exceeds payment: % + % > %', total, NEW.amount_cents, amt; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_alloc_fits ON public.payment_allocations; +CREATE TRIGGER trg_alloc_fits BEFORE INSERT OR UPDATE ON public.payment_allocations + FOR EACH ROW EXECUTE FUNCTION public.check_allocation_fits(); + +-- Invoice paid totals are derived from live allocations, never incremented, +-- so a voided payment cannot leave an invoice looking settled. +CREATE OR REPLACE FUNCTION public.refresh_invoice_paid(_invoice UUID) +RETURNS VOID +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + paid INTEGER; + inv public.invoices%ROWTYPE; +BEGIN + SELECT COALESCE(SUM(a.amount_cents), 0) INTO paid + FROM public.payment_allocations a + JOIN public.payments p ON p.id = a.payment_id + WHERE a.invoice_id = _invoice AND p.status = 'posted' AND p.kind = 'payment'; + + SELECT * INTO inv FROM public.invoices WHERE id = _invoice; + IF NOT FOUND THEN RETURN; END IF; + + UPDATE public.invoices + SET amount_paid_cents = paid, + status = CASE + WHEN status IN ('void','written_off') THEN status + WHEN paid >= total_cents AND total_cents > 0 THEN 'paid' + WHEN paid > 0 THEN 'partially_paid' + WHEN status = 'paid' OR status = 'partially_paid' THEN 'issued' + ELSE status END + WHERE id = _invoice; +END; +$$; + +CREATE OR REPLACE FUNCTION public.sync_invoice_from_allocation() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM public.refresh_invoice_paid(OLD.invoice_id); + RETURN OLD; + END IF; + PERFORM public.refresh_invoice_paid(NEW.invoice_id); + IF TG_OP = 'UPDATE' AND OLD.invoice_id IS DISTINCT FROM NEW.invoice_id THEN + PERFORM public.refresh_invoice_paid(OLD.invoice_id); + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_alloc_sync ON public.payment_allocations; +CREATE TRIGGER trg_alloc_sync AFTER INSERT OR UPDATE OR DELETE ON public.payment_allocations + FOR EACH ROW EXECUTE FUNCTION public.sync_invoice_from_allocation(); + +-- Voiding or reversing a payment must ripple to every invoice it touched. +CREATE OR REPLACE FUNCTION public.sync_invoices_from_payment() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + r RECORD; +BEGIN + IF NEW.status IS DISTINCT FROM OLD.status THEN + FOR r IN SELECT DISTINCT invoice_id FROM public.payment_allocations WHERE payment_id = NEW.id + LOOP + PERFORM public.refresh_invoice_paid(r.invoice_id); + END LOOP; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_pay_status_sync ON public.payments; +CREATE TRIGGER trg_pay_status_sync AFTER UPDATE ON public.payments + FOR EACH ROW EXECUTE FUNCTION public.sync_invoices_from_payment(); + +-- ============================================================================ +-- 3. OPERATIONS +-- ============================================================================ + +-- Oldest balance first — the spec's default when no explicit split is given. +-- Any remainder is left unallocated and can be turned into a credit. +CREATE OR REPLACE FUNCTION public.allocate_payment_oldest_first(_payment UUID) +RETURNS INTEGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + pay public.payments%ROWTYPE; + remaining INTEGER; + inv RECORD; + take INTEGER; + applied INTEGER := 0; +BEGIN + IF NOT (public.is_billing_admin() OR public.is_org_admin()) THEN + RAISE EXCEPTION 'insufficient privileges to allocate payments'; + END IF; + + SELECT * INTO pay FROM public.payments WHERE id = _payment; + IF NOT FOUND THEN RAISE EXCEPTION 'payment % not found', _payment; END IF; + IF pay.status <> 'posted' THEN RAISE EXCEPTION 'payment % is %', _payment, pay.status; END IF; + + remaining := public.payment_unallocated_cents(_payment); + + FOR inv IN + SELECT i.id, i.balance_due_cents + FROM public.invoices i + WHERE i.status IN ('issued','partially_paid') + AND i.balance_due_cents > 0 + AND ( + (pay.student_id IS NOT NULL AND i.student_id = pay.student_id) + OR (pay.household_id IS NOT NULL AND i.household_id = pay.household_id) + ) + ORDER BY i.due_date, i.billing_period_start + LOOP + EXIT WHEN remaining <= 0; + take := LEAST(remaining, inv.balance_due_cents); + + INSERT INTO public.payment_allocations (payment_id, invoice_id, amount_cents, allocated_by) + VALUES (_payment, inv.id, take, (SELECT auth.uid())) + ON CONFLICT (payment_id, invoice_id) DO UPDATE + SET amount_cents = public.payment_allocations.amount_cents + EXCLUDED.amount_cents; + + remaining := remaining - take; + applied := applied + take; + END LOOP; + + RETURN applied; +END; +$$; + +-- Reversing a payment: the original is marked reversed and a mirror row is +-- written. Neither is deleted, satisfying section 40. +CREATE OR REPLACE FUNCTION public.reverse_payment(_payment UUID, _reason TEXT) +RETURNS UUID +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + pay public.payments%ROWTYPE; + new_id UUID; +BEGIN + IF NOT (public.is_billing_admin() OR public.is_org_admin()) THEN + RAISE EXCEPTION 'insufficient privileges to reverse payments'; + END IF; + IF COALESCE(TRIM(_reason), '') = '' THEN + RAISE EXCEPTION 'a reason is required to reverse a payment'; + END IF; + + SELECT * INTO pay FROM public.payments WHERE id = _payment FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'payment % not found', _payment; END IF; + IF pay.status <> 'posted' THEN RAISE EXCEPTION 'payment % is already %', _payment, pay.status; END IF; + + INSERT INTO public.payments ( + student_id, household_id, campus_id, kind, method, amount_cents, + effective_date, reference_number, reverses_payment_id, void_reason, + notes, received_by + ) VALUES ( + pay.student_id, pay.household_id, pay.campus_id, 'reversal', pay.method, pay.amount_cents, + CURRENT_DATE, pay.reference_number, pay.id, _reason, + 'Reversal of ' || COALESCE(pay.reference_number, pay.id::text), (SELECT auth.uid()) + ) RETURNING id INTO new_id; + + UPDATE public.payments SET status = 'reversed', void_reason = _reason WHERE id = _payment; + RETURN new_id; +END; +$$; + +-- A refund returns money that was legitimately taken; the original stays posted. +CREATE OR REPLACE FUNCTION public.refund_payment(_payment UUID, _amount_cents INTEGER, _reason TEXT) +RETURNS UUID +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + pay public.payments%ROWTYPE; + new_id UUID; +BEGIN + IF NOT (public.is_billing_admin() OR public.is_org_admin()) THEN + RAISE EXCEPTION 'insufficient privileges to refund payments'; + END IF; + + SELECT * INTO pay FROM public.payments WHERE id = _payment; + IF NOT FOUND THEN RAISE EXCEPTION 'payment % not found', _payment; END IF; + IF _amount_cents <= 0 OR _amount_cents > pay.amount_cents THEN + RAISE EXCEPTION 'refund of % is outside the original payment of %', + _amount_cents, pay.amount_cents; + END IF; + + INSERT INTO public.payments ( + student_id, household_id, campus_id, kind, method, amount_cents, + effective_date, reverses_payment_id, void_reason, notes, received_by + ) VALUES ( + pay.student_id, pay.household_id, pay.campus_id, 'refund', pay.method, _amount_cents, + CURRENT_DATE, pay.id, _reason, 'Refund', (SELECT auth.uid()) + ) RETURNING id INTO new_id; + + RETURN new_id; +END; +$$; + +-- ============================================================================ +-- 4. PAYMENT PLANS — section 13 +-- ============================================================================ + +ALTER TABLE public.payment_plans + ADD COLUMN IF NOT EXISTS campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS total_obligation_cents INTEGER, + ADD COLUMN IF NOT EXISTS down_payment_cents INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS installment_count INTEGER, + ADD COLUMN IF NOT EXISTS autopay_authorized BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS payment_method TEXT, + ADD COLUMN IF NOT EXISTS grace_period_days INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS late_fee_cents INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS missed_payment_action TEXT, + ADD COLUMN IF NOT EXISTS scholarship_contribution_cents INTEGER NOT NULL DEFAULT 0; + +DO $$ BEGIN + ALTER TABLE public.payment_plans ADD CONSTRAINT pplan_missed_action_valid + CHECK (missed_payment_action IS NULL OR missed_payment_action IN + ('notify','late_fee','suspend_plan','suspend_enrollment','none')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE INDEX IF NOT EXISTS pplan_campus_idx ON public.payment_plans (campus_id); + +-- "The system must calculate the family's required amount after approved +-- scholarship funding or credits." +CREATE OR REPLACE FUNCTION public.family_responsibility_cents(_plan UUID) +RETURNS INTEGER +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ +DECLARE + p public.payment_plans%ROWTYPE; + credits INTEGER := 0; +BEGIN + SELECT * INTO p FROM public.payment_plans WHERE id = _plan; + IF NOT FOUND THEN RETURN NULL; END IF; + + IF p.student_id IS NOT NULL THEN + SELECT COALESCE(SUM(amount_cents - amount_applied_cents), 0) INTO credits + FROM public.student_credits + WHERE student_id = p.student_id AND NOT is_void; + END IF; + + RETURN GREATEST( + COALESCE(p.total_obligation_cents, 0) + - p.down_payment_cents + - p.scholarship_contribution_cents + - credits, + 0); +END; +$$; + +-- ============================================================================ +-- 5. UNIFIED LEDGER — section 12 +-- ============================================================================ +-- Every money event for a student in one ordered stream with a running balance. +-- +-- Caveat worth knowing: while billing_settings.legacy_attendance_autocharge is +-- TRUE, per-day tuition charges land in ledger_entries *and* invoices cover the +-- same period, so both appear here. The `source` column makes that visible. +-- Turning the legacy switch off resolves it. + +CREATE OR REPLACE VIEW public.v_student_ledger +WITH (security_invoker = true) AS +WITH events AS ( + SELECT i.student_id, i.campus_id, + i.invoice_date AS txn_date, i.billing_period_start AS effective_date, + i.created_at AS sort_ts, + 'invoice'::text AS source, 'charge'::text AS direction, + i.total_cents AS amount_cents, + i.invoice_number AS reference, NULL::text AS method, + i.issued_by AS entered_by, i.id AS ref_id + FROM public.invoices i + WHERE i.status <> 'void' + + UNION ALL + -- Reversed payments stay in the ledger. The original and its reversal are + -- both real events and net to zero; hiding the original would leave an + -- unmatched reversal inflating the balance, and would lose the audit trail + -- that section 40 requires. + SELECT p.student_id, p.campus_id, + p.received_at::date, p.effective_date, + p.created_at, + 'payment', CASE WHEN p.kind = 'payment' THEN 'credit' ELSE 'charge' END, + CASE WHEN p.kind = 'payment' THEN -p.amount_cents ELSE p.amount_cents END, + p.reference_number, p.method, p.received_by, p.id + FROM public.payments p + WHERE p.status <> 'voided' AND p.student_id IS NOT NULL + + UNION ALL + SELECT c.student_id, NULL::uuid, + c.issued_at::date, c.issued_at::date, + c.issued_at, + 'credit', 'credit', -c.amount_cents, + NULL, NULL, c.issued_by, c.id + FROM public.student_credits c + WHERE NOT c.is_void + + UNION ALL + SELECT l.student_id, NULL::uuid, + l.date, l.date, + l.created_at, + 'ledger_entry', CASE WHEN l.kind = 'charge' THEN 'charge' ELSE 'credit' END, + CASE WHEN l.kind = 'charge' THEN l.amount_cents ELSE -l.amount_cents END, + NULL, l.category::text, l.created_by, l.id + FROM public.ledger_entries l +) +SELECT + e.*, + s.first_name || ' ' || s.last_name AS student_name, + -- Ordered by when the event was actually recorded, not by primary key: a + -- UUID tiebreak would make the running balance non-deterministic. + SUM(e.amount_cents) OVER ( + PARTITION BY e.student_id + ORDER BY e.effective_date, e.sort_ts, e.ref_id + ROWS UNBOUNDED PRECEDING + ) AS running_balance_cents +FROM events e +JOIN public.students s ON s.id = e.student_id; + +GRANT SELECT ON public.v_student_ledger TO authenticated; + +-- ============================================================================ +-- 6. POLICIES +-- ============================================================================ +-- Parents may read their own ledger and never write it, per section 12. + +DROP POLICY IF EXISTS "payments read" ON public.payments; +CREATE POLICY "payments read" ON public.payments FOR SELECT TO authenticated + USING ( + public.is_billing_admin() OR public.is_auditor() + OR (student_id IS NOT NULL AND public.is_parent_of(student_id)) + OR (household_id IS NOT NULL AND household_id IN (SELECT public.user_household_ids())) + ); + +DROP POLICY IF EXISTS "payments insert" ON public.payments; +CREATE POLICY "payments insert" ON public.payments FOR INSERT TO authenticated + WITH CHECK (public.is_billing_admin()); + +-- Update only, never delete: money is corrected by a new row, not by removal. +DROP POLICY IF EXISTS "payments update" ON public.payments; +CREATE POLICY "payments update" ON public.payments FOR UPDATE TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin()); + +DROP POLICY IF EXISTS "allocations read" ON public.payment_allocations; +CREATE POLICY "allocations read" ON public.payment_allocations FOR SELECT TO authenticated + USING (EXISTS (SELECT 1 FROM public.payments p WHERE p.id = payment_id)); + +DROP POLICY IF EXISTS "allocations manage" ON public.payment_allocations; +CREATE POLICY "allocations manage" ON public.payment_allocations FOR ALL TO authenticated + USING (public.is_billing_admin()) WITH CHECK (public.is_billing_admin());