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:
2026-07-26 09:45:30 -04:00
co-authored by Claude Opus 5
parent 13fca11c81
commit 85d87d1c62
3 changed files with 126 additions and 1 deletions
+13
View File
@@ -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
+47 -1
View File
@@ -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<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 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
<ViewRow label="Photo release" value={c.photo_release ? "Granted" : "Not granted"} />
{isAdmin && <ViewRow label="Internal notes" value={val("notes")} />}
</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">
<ViewRow label="Signed by" value={val("agreement_signed_by")} />
<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>
{isAdmin && A("notes", "Internal notes (staff only)")}
</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">
<div className="grid grid-cols-2 gap-3">{T("agreement_signed_by", "Signed by")}{D("agreement_signed_date", "Date")}</div>
</Section>