Files
info-share-spot/supabase/migrations/20260807001600_payments.sql
T
2026-08-07 13:29:31 +00:00

469 lines
19 KiB
PL/PgSQL

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