-- 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());