diff --git a/src/components/invoices/generate-invoice-dialog.tsx b/src/components/invoices/generate-invoice-dialog.tsx
index 0fa0f5e..e4ad656 100644
--- a/src/components/invoices/generate-invoice-dialog.tsx
+++ b/src/components/invoices/generate-invoice-dialog.tsx
@@ -41,6 +41,7 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
const [taxPct, setTaxPct] = useState("0");
const [dueDays, setDueDays] = useState("30");
const [notes, setNotes] = useState("");
+ const [isRetainer, setIsRetainer] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -104,9 +105,53 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
const submit = async () => {
if (!user?.id) return;
- if (selectedIds.length === 0) return toast.error("Select at least one case");
+ if (selectedIds.length === 0 && !isRetainer) return toast.error("Select at least one case");
setSaving(true);
try {
+ if (isRetainer) {
+ // Retainer invoices have no time/expenses — create a single line item for the deposit.
+ const yr = new Date().getFullYear();
+ const num = `INV-${yr}-${Math.floor(1000 + Math.random() * 9000)}`;
+ const due = new Date();
+ due.setDate(due.getDate() + (Number(dueDays) || 30));
+ const amt = Math.max(0, Number((document.getElementById("retainer-amount") as HTMLInputElement)?.value) || 0);
+ if (amt <= 0) {
+ setSaving(false);
+ return toast.error("Enter a retainer amount");
+ }
+ const tax = +(amt * (Number(taxPct) || 0) / 100).toFixed(2);
+ const { data: inv, error: ie } = await supabase
+ .from("invoices")
+ .insert({
+ invoice_number: num,
+ client_id: clientId,
+ status: "draft",
+ issue_date: new Date().toISOString().slice(0, 10),
+ due_date: due.toISOString().slice(0, 10),
+ subtotal: amt,
+ tax,
+ total: +(amt + tax).toFixed(2),
+ notes: notes || "Retainer deposit",
+ is_retainer: true,
+ created_by: user.id,
+ })
+ .select("id, invoice_number")
+ .single();
+ if (ie || !inv) throw ie ?? new Error("Failed to create");
+ await supabase.from("invoice_line_items").insert({
+ invoice_id: inv.id,
+ kind: "manual",
+ description: "Retainer deposit into trust account",
+ quantity: 1,
+ rate: amt,
+ amount: amt,
+ sort_order: 0,
+ });
+ toast.success(`Retainer invoice ${inv.invoice_number} created`);
+ onOpenChange(false);
+ navigate({ to: "/invoices/$invoiceId", params: { invoiceId: inv.id } });
+ return;
+ }
const { invoiceId, invoiceNumber } = await generateInvoiceForClient({
clientId,
caseIds: selectedIds,
@@ -132,39 +177,27 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
Generate invoice — {clientName}
- {loading ? (
-
Loading unbilled work…
- ) : cases.length === 0 ? (
-
- No unbilled time or expenses across this client's cases.
-
- ) : (
-
-
-
-
- {cases.map((c) => (
-
- ))}
+
+
+ {isRetainer ? (
+
+
+
+
setTaxPct(e.target.value)} />
@@ -173,27 +206,73 @@ export function GenerateInvoiceDialog({ open, onOpenChange, clientId, clientName
setDueDays(e.target.value)} />
-
-
-
Estimated total
-
{formatCurrency(total)}
- {tax > 0 &&
{formatCurrency(subtotal)} + {formatCurrency(tax)} tax
}
+
+ ) : loading ? (
+
Loading unbilled work…
+ ) : cases.length === 0 ? (
+
+ No unbilled time or expenses across this client's cases.
+
+ ) : (
+ <>
+
+
+
+ {cases.map((c) => (
+
+ ))}
-
-
-
-
+
+
+
+ setTaxPct(e.target.value)} />
+
+
+
+ setDueDays(e.target.value)} />
+
+
+
+
Estimated total
+
{formatCurrency(total)}
+ {tax > 0 &&
{formatCurrency(subtotal)} + {formatCurrency(tax)} tax
}
+
+
+
+ >
+ )}
+
+
+
+
- )}
+
-
diff --git a/src/components/trust/trust-account-panel.tsx b/src/components/trust/trust-account-panel.tsx
new file mode 100644
index 0000000..a46df08
--- /dev/null
+++ b/src/components/trust/trust-account-panel.tsx
@@ -0,0 +1,421 @@
+import { useEffect, useState, useCallback } from "react";
+import { Link } from "@tanstack/react-router";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/lib/auth";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Wallet, Plus, ArrowDownToLine, ArrowUpFromLine, Trash2, Loader2 } from "lucide-react";
+import { formatCurrency, formatDate } from "@/lib/format";
+import { toast } from "sonner";
+
+interface Entry {
+ id: string;
+ entry_date: string;
+ entry_type: "deposit" | "withdrawal";
+ amount: number;
+ note: string | null;
+ source_invoice_id: string | null;
+ applied_invoice_id: string | null;
+ created_by: string | null;
+}
+
+export function TrustAccountPanel({ clientId }: { clientId: string }) {
+ const { user, isAdmin } = useAuth();
+ const [entries, setEntries] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [adjustOpen, setAdjustOpen] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ const { data } = await supabase
+ .from("trust_ledger_entries")
+ .select("*")
+ .eq("client_id", clientId)
+ .order("entry_date", { ascending: false })
+ .order("created_at", { ascending: false });
+ setEntries((data ?? []) as Entry[]);
+ setLoading(false);
+ }, [clientId]);
+
+ useEffect(() => {
+ load();
+ const ch = supabase
+ .channel(`trust-${clientId}`)
+ .on(
+ "postgres_changes",
+ { event: "*", schema: "public", table: "trust_ledger_entries", filter: `client_id=eq.${clientId}` },
+ () => load(),
+ )
+ .subscribe();
+ return () => {
+ supabase.removeChannel(ch);
+ };
+ }, [clientId, load]);
+
+ const balance = entries.reduce(
+ (sum, e) => sum + (e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount)),
+ 0,
+ );
+
+ const removeEntry = async (id: string) => {
+ if (!confirm("Remove this trust ledger entry? This will not reverse the related invoice payment.")) return;
+ const { error } = await supabase.from("trust_ledger_entries").delete().eq("id", id);
+ if (error) return toast.error(error.message);
+ toast.success("Entry removed");
+ load();
+ };
+
+ return (
+
+
+
+
+
+
Trust account
+
+
+
setAdjustOpen("deposit")}>
+ Deposit
+
+
setAdjustOpen("withdrawal")}>
+ Withdraw
+
+
+
+
+
+ Available balance
+
+ {formatCurrency(balance)}
+
+
+
+
+
Ledger
+ {loading ? (
+
Loading…
+ ) : entries.length === 0 ? (
+
+ No activity yet. Mark an invoice as a retainer and record payment to fund this account.
+
+ ) : (
+
+ {entries.map((e) => {
+ const isDeposit = e.entry_type === "deposit";
+ const link = isDeposit ? e.source_invoice_id : e.applied_invoice_id;
+ const canDelete = isAdmin || e.created_by === user?.id;
+ return (
+
+
+
+ {isDeposit ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isDeposit ? "Deposit" : "Withdrawal"}
+ {e.note ? · {e.note} : null}
+
+
+ {formatDate(e.entry_date)}
+ {link && (
+ <>
+ {" · "}
+
+ {isDeposit ? "from invoice" : "to invoice"}
+
+ >
+ )}
+
+
+
+
+
+ {isDeposit ? "+" : "−"}
+ {formatCurrency(e.amount)}
+
+ {canDelete && (
+ removeEntry(e.id)}
+ >
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ !v && setAdjustOpen(null)}
+ clientId={clientId}
+ userId={user?.id ?? null}
+ balance={balance}
+ onSaved={load}
+ />
+
+ );
+}
+
+function ManualAdjustDialog({
+ open,
+ type,
+ onOpenChange,
+ clientId,
+ userId,
+ balance,
+ onSaved,
+}: {
+ open: boolean;
+ type: "deposit" | "withdrawal";
+ onOpenChange: (b: boolean) => void;
+ clientId: string;
+ userId: string | null;
+ balance: number;
+ onSaved: () => void;
+}) {
+ const [amount, setAmount] = useState("");
+ const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
+ const [note, setNote] = useState("");
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ setAmount("");
+ setDate(new Date().toISOString().slice(0, 10));
+ setNote("");
+ }
+ }, [open]);
+
+ const save = async () => {
+ const amt = Number(amount);
+ if (!amt || amt <= 0) return toast.error("Enter a valid amount");
+ if (type === "withdrawal" && amt > balance) {
+ if (!confirm(`This will overdraw the trust by ${formatCurrency(amt - balance)}. Continue?`)) return;
+ }
+ setSaving(true);
+ const { error } = await supabase.from("trust_ledger_entries").insert({
+ client_id: clientId,
+ entry_date: date,
+ entry_type: type,
+ amount: amt,
+ note: note || (type === "deposit" ? "Manual deposit" : "Manual withdrawal"),
+ created_by: userId,
+ });
+ setSaving(false);
+ if (error) return toast.error(error.message);
+ toast.success(type === "deposit" ? "Deposit recorded" : "Withdrawal recorded");
+ onOpenChange(false);
+ onSaved();
+ };
+
+ return (
+
+ );
+}
+
+/** Dialog used on an invoice detail page to apply trust funds toward its balance. */
+export function ApplyTrustDialog({
+ open,
+ onOpenChange,
+ clientId,
+ invoiceId,
+ invoiceNumber,
+ balanceDue,
+ userId,
+ onApplied,
+}: {
+ open: boolean;
+ onOpenChange: (b: boolean) => void;
+ clientId: string;
+ invoiceId: string;
+ invoiceNumber: string;
+ balanceDue: number;
+ userId: string | null;
+ onApplied: () => void;
+}) {
+ const [trustBalance, setTrustBalance] = useState(null);
+ const [amount, setAmount] = useState("");
+ const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ (async () => {
+ const { data } = await supabase
+ .from("trust_ledger_entries")
+ .select("entry_type, amount")
+ .eq("client_id", clientId);
+ const bal = (data ?? []).reduce(
+ (s: number, e: any) => s + (e.entry_type === "deposit" ? Number(e.amount) : -Number(e.amount)),
+ 0,
+ );
+ setTrustBalance(bal);
+ const suggest = Math.min(bal, balanceDue);
+ setAmount(suggest > 0 ? suggest.toFixed(2) : "");
+ setDate(new Date().toISOString().slice(0, 10));
+ })();
+ }, [open, clientId, balanceDue]);
+
+ const apply = async () => {
+ const amt = Number(amount);
+ if (!amt || amt <= 0) return toast.error("Enter a valid amount");
+ if (amt > balanceDue) return toast.error("Amount exceeds invoice balance due");
+ if (trustBalance != null && amt > trustBalance) {
+ if (!confirm(`This will overdraw the trust by ${formatCurrency(amt - trustBalance)}. Continue?`)) return;
+ }
+ setSaving(true);
+ // 1) Record the invoice payment (the existing trigger keeps invoice totals in sync).
+ const { data: pay, error: payErr } = await supabase
+ .from("invoice_payments")
+ .insert({
+ invoice_id: invoiceId,
+ amount: amt,
+ paid_on: date,
+ method: "trust",
+ reference: `Trust transfer · ${invoiceNumber}`,
+ created_by: userId,
+ })
+ .select("id")
+ .single();
+ if (payErr || !pay) {
+ setSaving(false);
+ return toast.error(payErr?.message ?? "Could not record payment");
+ }
+ // 2) Add the matching trust ledger withdrawal pointing at this invoice.
+ const { error: ledErr } = await supabase.from("trust_ledger_entries").insert({
+ client_id: clientId,
+ entry_date: date,
+ entry_type: "withdrawal",
+ amount: amt,
+ note: `Applied to ${invoiceNumber}`,
+ applied_invoice_id: invoiceId,
+ applied_payment_id: pay.id,
+ created_by: userId,
+ });
+ setSaving(false);
+ if (ledErr) return toast.error(ledErr.message);
+ toast.success("Trust funds applied");
+ onOpenChange(false);
+ onApplied();
+ };
+
+ return (
+
+ );
+}
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index 1d0485f..c990442 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -1561,6 +1561,7 @@ export type Database = {
due_date: string | null
id: string
invoice_number: string
+ is_retainer: boolean
issue_date: string
notes: string | null
paid_at: string | null
@@ -1579,6 +1580,7 @@ export type Database = {
due_date?: string | null
id?: string
invoice_number: string
+ is_retainer?: boolean
issue_date?: string
notes?: string | null
paid_at?: string | null
@@ -1597,6 +1599,7 @@ export type Database = {
due_date?: string | null
id?: string
invoice_number?: string
+ is_retainer?: boolean
issue_date?: string
notes?: string | null
paid_at?: string | null
@@ -2287,6 +2290,90 @@ export type Database = {
},
]
}
+ trust_ledger_entries: {
+ Row: {
+ amount: number
+ applied_invoice_id: string | null
+ applied_payment_id: string | null
+ client_id: string
+ created_at: string
+ created_by: string | null
+ entry_date: string
+ entry_type: string
+ id: string
+ note: string | null
+ source_invoice_id: string | null
+ source_payment_id: string | null
+ updated_at: string
+ }
+ Insert: {
+ amount: number
+ applied_invoice_id?: string | null
+ applied_payment_id?: string | null
+ client_id: string
+ created_at?: string
+ created_by?: string | null
+ entry_date?: string
+ entry_type: string
+ id?: string
+ note?: string | null
+ source_invoice_id?: string | null
+ source_payment_id?: string | null
+ updated_at?: string
+ }
+ Update: {
+ amount?: number
+ applied_invoice_id?: string | null
+ applied_payment_id?: string | null
+ client_id?: string
+ created_at?: string
+ created_by?: string | null
+ entry_date?: string
+ entry_type?: string
+ id?: string
+ note?: string | null
+ source_invoice_id?: string | null
+ source_payment_id?: string | null
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "trust_ledger_entries_applied_invoice_id_fkey"
+ columns: ["applied_invoice_id"]
+ isOneToOne: false
+ referencedRelation: "invoices"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "trust_ledger_entries_applied_payment_id_fkey"
+ columns: ["applied_payment_id"]
+ isOneToOne: false
+ referencedRelation: "invoice_payments"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "trust_ledger_entries_client_id_fkey"
+ columns: ["client_id"]
+ isOneToOne: false
+ referencedRelation: "clients"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "trust_ledger_entries_source_invoice_id_fkey"
+ columns: ["source_invoice_id"]
+ isOneToOne: false
+ referencedRelation: "invoices"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "trust_ledger_entries_source_payment_id_fkey"
+ columns: ["source_payment_id"]
+ isOneToOne: false
+ referencedRelation: "invoice_payments"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
user_roles: {
Row: {
created_at: string
diff --git a/src/routes/clients.$clientId.tsx b/src/routes/clients.$clientId.tsx
index ae6435c..8e6a592 100644
--- a/src/routes/clients.$clientId.tsx
+++ b/src/routes/clients.$clientId.tsx
@@ -15,6 +15,7 @@ import { formatCurrency, formatDate, formatDateTime, statusBadgeClass } from "@/
import { toast } from "sonner";
import { downloadStatusReport } from "@/lib/status-pdf";
import { GenerateInvoiceDialog } from "@/components/invoices/generate-invoice-dialog";
+import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
export const Route = createFileRoute("/clients/$clientId")({
component: () => (
@@ -203,6 +204,8 @@ function ClientDetail() {
)}
+
+
diff --git a/src/routes/invoices.$invoiceId.tsx b/src/routes/invoices.$invoiceId.tsx
index 72ead90..1471ea7 100644
--- a/src/routes/invoices.$invoiceId.tsx
+++ b/src/routes/invoices.$invoiceId.tsx
@@ -12,10 +12,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
-import { ArrowLeft, FileDown, Plus, Trash2, Loader2, Send, Ban, CheckCircle2, DollarSign } from "lucide-react";
+import { ArrowLeft, FileDown, Plus, Trash2, Loader2, Send, Ban, CheckCircle2, DollarSign, Wallet } from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { downloadInvoicePdf, type InvoicePdfInput } from "@/lib/invoice-pdf";
import { recalcInvoiceTotals } from "@/lib/invoice-generation";
+import { ApplyTrustDialog } from "@/components/trust/trust-account-panel";
import { toast } from "sonner";
export const Route = createFileRoute("/invoices/$invoiceId")({
@@ -37,6 +38,7 @@ function InvoiceDetail() {
const [loading, setLoading] = useState(true);
const [savingItem, setSavingItem] = useState(false);
const [payOpen, setPayOpen] = useState(false);
+ const [trustOpen, setTrustOpen] = useState(false);
const load = async () => {
setLoading(true);
@@ -269,6 +271,11 @@ function InvoiceDetail() {
Invoice
{invoice.status}
+ {invoice.is_retainer && (
+
+ Retainer
+
+ )}
{invoice.invoice_number}
@@ -290,6 +297,11 @@ function InvoiceDetail() {
{canEdit && invoice.status !== "void" && invoice.status !== "paid" && (
setStatus("void")}> Void
)}
+ {canEdit && balance > 0 && !invoice.is_retainer && (
+ setTrustOpen(true)}>
+ Apply trust funds
+
+ )}
{canEdit && (
setPayOpen(true)}> Record payment
)}
@@ -476,6 +488,19 @@ function InvoiceDetail() {
userId={user?.id ?? ""}
onSaved={load}
/>
+
+ {invoice.client?.id && (
+
+ )}
);
}
diff --git a/supabase/migrations/20260418010645_2ca5538f-221c-439d-8f12-54b0ce082083.sql b/supabase/migrations/20260418010645_2ca5538f-221c-439d-8f12-54b0ce082083.sql
new file mode 100644
index 0000000..eee168b
--- /dev/null
+++ b/supabase/migrations/20260418010645_2ca5538f-221c-439d-8f12-54b0ce082083.sql
@@ -0,0 +1,64 @@
+
+ALTER TABLE public.invoices ADD COLUMN IF NOT EXISTS is_retainer boolean NOT NULL DEFAULT false;
+
+CREATE TABLE IF NOT EXISTS public.trust_ledger_entries (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ client_id uuid NOT NULL REFERENCES public.clients(id) ON DELETE CASCADE,
+ entry_date date NOT NULL DEFAULT CURRENT_DATE,
+ entry_type text NOT NULL CHECK (entry_type IN ('deposit', 'withdrawal')),
+ amount numeric NOT NULL CHECK (amount >= 0),
+ note text,
+ source_invoice_id uuid REFERENCES public.invoices(id) ON DELETE SET NULL,
+ applied_invoice_id uuid REFERENCES public.invoices(id) ON DELETE SET NULL,
+ source_payment_id uuid REFERENCES public.invoice_payments(id) ON DELETE SET NULL,
+ applied_payment_id uuid REFERENCES public.invoice_payments(id) ON DELETE SET NULL,
+ created_by uuid,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS trust_ledger_client_idx ON public.trust_ledger_entries(client_id, entry_date DESC);
+
+ALTER TABLE public.trust_ledger_entries ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY trust_ledger_select ON public.trust_ledger_entries
+ FOR SELECT TO authenticated USING (true);
+
+CREATE POLICY trust_ledger_insert ON public.trust_ledger_entries
+ FOR INSERT TO authenticated WITH CHECK (auth.uid() IS NOT NULL);
+
+CREATE POLICY trust_ledger_delete ON public.trust_ledger_entries
+ FOR DELETE TO authenticated USING (public.is_admin(auth.uid()) OR created_by = auth.uid());
+
+CREATE TRIGGER trust_ledger_set_updated_at
+ BEFORE UPDATE ON public.trust_ledger_entries
+ FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
+
+CREATE OR REPLACE FUNCTION public.tg_trust_deposit_from_payment()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_client_id uuid;
+ v_is_retainer boolean;
+BEGIN
+ SELECT i.client_id, i.is_retainer INTO v_client_id, v_is_retainer
+ FROM public.invoices i WHERE i.id = NEW.invoice_id;
+
+ IF v_is_retainer IS TRUE AND v_client_id IS NOT NULL AND NEW.amount > 0 THEN
+ INSERT INTO public.trust_ledger_entries
+ (client_id, entry_date, entry_type, amount, note, source_invoice_id, source_payment_id, created_by)
+ VALUES
+ (v_client_id, NEW.paid_on, 'deposit', NEW.amount,
+ 'Retainer payment', NEW.invoice_id, NEW.id, NEW.created_by);
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trust_deposit_from_payment ON public.invoice_payments;
+CREATE TRIGGER trust_deposit_from_payment
+ AFTER INSERT ON public.invoice_payments
+ FOR EACH ROW EXECUTE FUNCTION public.tg_trust_deposit_from_payment();