diff --git a/supabase/migrations/20260726120000_attendance_tuition_autocharge.sql b/supabase/migrations/20260726120000_attendance_tuition_autocharge.sql new file mode 100644 index 0000000..85413ed --- /dev/null +++ b/supabase/migrations/20260726120000_attendance_tuition_autocharge.sql @@ -0,0 +1,66 @@ +-- Auto-charge tuition when a student is marked present or late. +-- +-- Idempotency is the whole problem here. The attendance UI upserts on +-- (student_id, date) and teachers freely toggle a status — present, then absent, +-- then present again. A naive "insert a charge on attendance" trigger bills the +-- family once per click. So each auto-charge is bound to the attendance row that +-- caused it via ledger_entries.attendance_id (UNIQUE), which makes the write an +-- upsert and lets a status change back to absent *remove* the charge. +-- +-- Manual ledger entries keep attendance_id NULL. Postgres allows unlimited NULLs +-- in a unique index, so hand-entered charges are unaffected, and the UI can tell +-- auto from manual by whether attendance_id is set. + +-- Per-student daily rate. NULL (the default) means "never auto-charge", so +-- nothing starts billing until a rate is deliberately set on a student. +ALTER TABLE public.students + ADD COLUMN IF NOT EXISTS daily_tuition_cents INTEGER; + +ALTER TABLE public.ledger_entries + ADD COLUMN IF NOT EXISTS attendance_id UUID REFERENCES public.attendance(id) ON DELETE CASCADE; + +-- One auto-charge per attendance record, and deleting the attendance record +-- takes its charge with it (ON DELETE CASCADE above). +DO $$ BEGIN + ALTER TABLE public.ledger_entries ADD CONSTRAINT ledger_entries_attendance_id_key UNIQUE (attendance_id); +EXCEPTION WHEN duplicate_table OR duplicate_object THEN NULL; END $$; + +-- SECURITY DEFINER because the writer is a *teacher* marking attendance, and +-- ledger_entries is admin-write under RLS. The teacher never gains general +-- ledger access — only this function's fixed, attendance-derived write. +CREATE OR REPLACE FUNCTION public.sync_attendance_tuition() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + rate INTEGER; +BEGIN + SELECT daily_tuition_cents INTO rate FROM public.students WHERE id = NEW.student_id; + + -- 'present' and 'late' are billable: the student attended either way. + -- 'absent' and 'excused' are not. + IF NEW.status IN ('present', 'late') AND rate IS NOT NULL AND rate > 0 THEN + INSERT INTO public.ledger_entries + (student_id, date, kind, category, amount_cents, note, created_by, attendance_id) + VALUES + (NEW.student_id, NEW.date, 'charge', 'tuition', rate, + 'Auto-charged from attendance (' || NEW.status || ')', NEW.recorded_by, NEW.id) + ON CONFLICT (attendance_id) DO UPDATE + SET amount_cents = EXCLUDED.amount_cents, + date = EXCLUDED.date, + note = EXCLUDED.note; + ELSE + -- Status moved to absent/excused, or the rate was cleared: undo the charge. + DELETE FROM public.ledger_entries WHERE attendance_id = NEW.id; + END IF; + + RETURN NEW; +END $$; + +DROP TRIGGER IF EXISTS trg_attendance_tuition ON public.attendance; +CREATE TRIGGER trg_attendance_tuition +AFTER INSERT OR UPDATE ON public.attendance +FOR EACH ROW EXECUTE FUNCTION public.sync_attendance_tuition(); + +-- Deliberately NOT backfilled. Existing attendance records stay unbilled — +-- retroactively generating charges against families' balances should be an +-- explicit decision, not a side effect of deploying this migration. Re-saving an +-- attendance record re-fires the trigger and bills that day at the current rate.