-- 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.