diff --git a/supabase/migrations/20260807001600_payments.sql b/supabase/migrations/20260807001600_payments.sql deleted file mode 100644 index 7bbb5e2..0000000 --- a/supabase/migrations/20260807001600_payments.sql +++ /dev/null @@ -1,468 +0,0 @@ --- 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());