Modified by www.SourceFiles.app
This commit is contained in:
@@ -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());
|
||||
Reference in New Issue
Block a user