Charge tuition automatically from attendance
Adds a per-student daily rate (students.daily_tuition_cents) and a trigger that writes a tuition charge when a student is marked present or late. Idempotency is the crux. The attendance UI upserts on (student_id, date) and teachers toggle a status freely — present, absent, 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 turns the write into an upsert, lets a change back to absent delete the charge, and cascades the charge away if the attendance record is deleted. Manual entries keep attendance_id NULL — Postgres allows unlimited NULLs in a unique index — so hand-entered charges are untouched and the UI can tell auto from manual. A NULL rate (the default) means never auto-charge, so nothing begins billing until a rate is deliberately set on a student. Existing attendance is not backfilled: retroactively generating charges against families' balances should be an explicit decision, not a side effect of deploying a migration. The trigger is SECURITY DEFINER because the writer is a teacher marking attendance while ledger_entries is admin-write under RLS; teachers gain no general ledger access, only this fixed attendance-derived write. Verified against the live schema across all eight paths: charge on present, reversal on absent, no duplicate on re-mark, late billable, excused free, no rate means no charge, rate change on re-save, and cascade on attendance delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -480,6 +480,7 @@ export type Database = {
|
|||||||
ledger_entries: {
|
ledger_entries: {
|
||||||
Row: {
|
Row: {
|
||||||
amount_cents: number
|
amount_cents: number
|
||||||
|
attendance_id: string | null
|
||||||
category: Database["public"]["Enums"]["ledger_category"]
|
category: Database["public"]["Enums"]["ledger_category"]
|
||||||
created_at: string
|
created_at: string
|
||||||
created_by: string | null
|
created_by: string | null
|
||||||
@@ -491,6 +492,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
amount_cents: number
|
amount_cents: number
|
||||||
|
attendance_id?: string | null
|
||||||
category?: Database["public"]["Enums"]["ledger_category"]
|
category?: Database["public"]["Enums"]["ledger_category"]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
created_by?: string | null
|
created_by?: string | null
|
||||||
@@ -502,6 +504,7 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
amount_cents?: number
|
amount_cents?: number
|
||||||
|
attendance_id?: string | null
|
||||||
category?: Database["public"]["Enums"]["ledger_category"]
|
category?: Database["public"]["Enums"]["ledger_category"]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
created_by?: string | null
|
created_by?: string | null
|
||||||
@@ -512,6 +515,13 @@ export type Database = {
|
|||||||
student_id?: string
|
student_id?: string
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "ledger_entries_attendance_id_fkey"
|
||||||
|
columns: ["attendance_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "attendance"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
foreignKeyName: "ledger_entries_student_id_fkey"
|
foreignKeyName: "ledger_entries_student_id_fkey"
|
||||||
columns: ["student_id"]
|
columns: ["student_id"]
|
||||||
@@ -1159,6 +1169,7 @@ export type Database = {
|
|||||||
class_id: string | null
|
class_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
custody_agreement: string | null
|
custody_agreement: string | null
|
||||||
|
daily_tuition_cents: number | null
|
||||||
disciplinary_history: string | null
|
disciplinary_history: string | null
|
||||||
dismissal_methods: string[]
|
dismissal_methods: string[]
|
||||||
dismissal_other: string | null
|
dismissal_other: string | null
|
||||||
@@ -1192,6 +1203,7 @@ export type Database = {
|
|||||||
class_id?: string | null
|
class_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
custody_agreement?: string | null
|
custody_agreement?: string | null
|
||||||
|
daily_tuition_cents?: number | null
|
||||||
disciplinary_history?: string | null
|
disciplinary_history?: string | null
|
||||||
dismissal_methods?: string[]
|
dismissal_methods?: string[]
|
||||||
dismissal_other?: string | null
|
dismissal_other?: string | null
|
||||||
@@ -1225,6 +1237,7 @@ export type Database = {
|
|||||||
class_id?: string | null
|
class_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
custody_agreement?: string | null
|
custody_agreement?: string | null
|
||||||
|
daily_tuition_cents?: number | null
|
||||||
disciplinary_history?: string | null
|
disciplinary_history?: string | null
|
||||||
dismissal_methods?: string[]
|
dismissal_methods?: string[]
|
||||||
dismissal_other?: string | null
|
dismissal_other?: string | null
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { createIntakeToken } from "@/lib/intake.functions";
|
|||||||
import { genTempPassword } from "@/lib/temp-password";
|
import { genTempPassword } from "@/lib/temp-password";
|
||||||
import { StudentGradeReport } from "@/components/gradebook";
|
import { StudentGradeReport } from "@/components/gradebook";
|
||||||
import { StudentPlansTab } from "@/components/plans";
|
import { StudentPlansTab } from "@/components/plans";
|
||||||
|
import { money } from "@/lib/reports";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/students/$id")({
|
export const Route = createFileRoute("/_authenticated/students/$id")({
|
||||||
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
|
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
|
// background refetch can never clobber in-progress input. Functional updates
|
||||||
// avoid stale-closure races.
|
// avoid stale-closure races.
|
||||||
const [form, setForm] = useState<Record<string, unknown>>({});
|
const [form, setForm] = useState<Record<string, unknown>>({});
|
||||||
const startEdit = () => { setForm({ ...(s as Record<string, unknown>) }); 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<string, unknown>),
|
||||||
|
tuition_rate_input: s?.daily_tuition_cents != null ? (s.daily_tuition_cents / 100).toFixed(2) : "",
|
||||||
|
});
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
const c = (editing ? form : s) as Record<string, unknown> | null;
|
const c = (editing ? form : s) as Record<string, unknown> | null;
|
||||||
const upd = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
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
|
// 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];
|
const el = dateRefs.current[k];
|
||||||
if (el) payload[k] = el.value || null;
|
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);
|
const { error } = await supabase.from("students").update(payload as never).eq("id", studentId);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
},
|
},
|
||||||
@@ -235,6 +252,18 @@ function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdi
|
|||||||
<ViewRow label="Photo release" value={c.photo_release ? "Granted" : "Not granted"} />
|
<ViewRow label="Photo release" value={c.photo_release ? "Granted" : "Not granted"} />
|
||||||
{isAdmin && <ViewRow label="Internal notes" value={val("notes")} />}
|
{isAdmin && <ViewRow label="Internal notes" value={val("notes")} />}
|
||||||
</Section>
|
</Section>
|
||||||
|
{isAdmin && (
|
||||||
|
<Section title="Tuition">
|
||||||
|
<ViewRow
|
||||||
|
label="Daily rate"
|
||||||
|
value={
|
||||||
|
c.daily_tuition_cents != null
|
||||||
|
? `${money(c.daily_tuition_cents as number)} — charged automatically when marked present or late`
|
||||||
|
: "Not set — attendance does not create charges"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
<Section title="Agreement">
|
<Section title="Agreement">
|
||||||
<ViewRow label="Signed by" value={val("agreement_signed_by")} />
|
<ViewRow label="Signed by" value={val("agreement_signed_by")} />
|
||||||
<ViewRow label="Date" value={val("agreement_signed_date")} />
|
<ViewRow label="Date" value={val("agreement_signed_date")} />
|
||||||
@@ -293,6 +322,23 @@ function ProfileTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdi
|
|||||||
<div className="flex items-center justify-between max-w-sm"><Label className="text-xs">Photo release granted</Label><Switch checked={!!c.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
|
<div className="flex items-center justify-between max-w-sm"><Label className="text-xs">Photo release granted</Label><Switch checked={!!c.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
|
||||||
{isAdmin && A("notes", "Internal notes (staff only)")}
|
{isAdmin && A("notes", "Internal notes (staff only)")}
|
||||||
</Section>
|
</Section>
|
||||||
|
{isAdmin && (
|
||||||
|
<Section title="Tuition">
|
||||||
|
<Field label="Daily rate in dollars — leave blank for no automatic charging">
|
||||||
|
<Input
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="e.g. 45.00"
|
||||||
|
value={(c.tuition_rate_input as string) ?? ""}
|
||||||
|
onChange={(e) => upd("tuition_rate_input", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
<Section title="Agreement">
|
<Section title="Agreement">
|
||||||
<div className="grid grid-cols-2 gap-3">{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}</div>
|
<div className="grid grid-cols-2 gap-3">{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user