diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index fbcd819..42ee28d 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -480,6 +480,7 @@ export type Database = { ledger_entries: { Row: { amount_cents: number + attendance_id: string | null category: Database["public"]["Enums"]["ledger_category"] created_at: string created_by: string | null @@ -491,6 +492,7 @@ export type Database = { } Insert: { amount_cents: number + attendance_id?: string | null category?: Database["public"]["Enums"]["ledger_category"] created_at?: string created_by?: string | null @@ -502,6 +504,7 @@ export type Database = { } Update: { amount_cents?: number + attendance_id?: string | null category?: Database["public"]["Enums"]["ledger_category"] created_at?: string created_by?: string | null @@ -512,6 +515,13 @@ export type Database = { student_id?: string } Relationships: [ + { + foreignKeyName: "ledger_entries_attendance_id_fkey" + columns: ["attendance_id"] + isOneToOne: true + referencedRelation: "attendance" + referencedColumns: ["id"] + }, { foreignKeyName: "ledger_entries_student_id_fkey" columns: ["student_id"] @@ -1159,6 +1169,7 @@ export type Database = { class_id: string | null created_at: string custody_agreement: string | null + daily_tuition_cents: number | null disciplinary_history: string | null dismissal_methods: string[] dismissal_other: string | null @@ -1192,6 +1203,7 @@ export type Database = { class_id?: string | null created_at?: string custody_agreement?: string | null + daily_tuition_cents?: number | null disciplinary_history?: string | null dismissal_methods?: string[] dismissal_other?: string | null @@ -1225,6 +1237,7 @@ export type Database = { class_id?: string | null created_at?: string custody_agreement?: string | null + daily_tuition_cents?: number | null disciplinary_history?: string | null dismissal_methods?: string[] dismissal_other?: string | null diff --git a/src/routes/_authenticated/students.$id.tsx b/src/routes/_authenticated/students.$id.tsx index 3d56d4f..d6145ed 100644 --- a/src/routes/_authenticated/students.$id.tsx +++ b/src/routes/_authenticated/students.$id.tsx @@ -18,6 +18,7 @@ import { createIntakeToken } from "@/lib/intake.functions"; import { genTempPassword } from "@/lib/temp-password"; import { StudentGradeReport } from "@/components/gradebook"; import { StudentPlansTab } from "@/components/plans"; +import { money } from "@/lib/reports"; export const Route = createFileRoute("/_authenticated/students/$id")({ head: () => ({ meta: [{ title: "Student — School Portal" }] }), @@ -167,7 +168,16 @@ function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdi // background refetch can never clobber in-progress input. Functional updates // avoid stale-closure races. const [form, setForm] = useState>({}); - const startEdit = () => { setForm({ ...(s as Record) }); setEditing(true); }; + // daily_tuition_cents is stored in cents but edited in dollars, so it gets its + // own form key and is converted on save rather than going through the string + // field helpers below. + const startEdit = () => { + setForm({ + ...(s as Record), + tuition_rate_input: s?.daily_tuition_cents != null ? (s.daily_tuition_cents / 100).toFixed(2) : "", + }); + setEditing(true); + }; const c = (editing ? form : s) as Record | null; const upd = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v })); // Read date inputs straight from the DOM at save time (Safari doesn't reliably @@ -193,6 +203,13 @@ function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdi const el = dateRefs.current[k]; if (el) payload[k] = el.value || null; } + // Dollars -> cents. Blank clears the rate, which stops auto-charging. + const rate = String(c.tuition_rate_input ?? "").trim(); + const rateNum = Number(rate); + if (rate !== "" && (!Number.isFinite(rateNum) || rateNum < 0)) { + throw new Error("Daily tuition rate must be a positive amount"); + } + payload.daily_tuition_cents = rate === "" ? null : Math.round(rateNum * 100); const { error } = await supabase.from("students").update(payload as never).eq("id", studentId); if (error) throw error; }, @@ -235,6 +252,18 @@ function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdi {isAdmin && } + {isAdmin && ( +
+ +
+ )}
@@ -293,6 +322,23 @@ function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdi
upd("photo_release", v)} />
{isAdmin && A("notes", "Internal notes (staff only)")}
+ {isAdmin && ( +
+ + upd("tuition_rate_input", e.target.value)} + /> + +

+ When set, marking this student present or late adds a tuition charge for that day. Changing + a status to absent or excused removes the charge again. Existing attendance is not billed + retroactively. +

+
+ )}
{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}
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.