Added payment plans tab

X-Lovable-Edit-ID: edt-8aed5fd4-e42b-4c8c-9e22-f4547b59fd11
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-26 08:10:24 +00:00
co-authored by renee-png
4 changed files with 620 additions and 4 deletions
+499
View File
@@ -0,0 +1,499 @@
import { useCallback, useEffect, useMemo, useState } from "react";
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 { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CalendarClock, CreditCard, ExternalLink, Loader2, Plus, Trash2, CheckCircle2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { formatCurrency, formatDate } from "@/lib/format";
import { createPaymentRequest, refreshPaymentStatus } from "@/lib/payments.functions";
type Frequency = "weekly" | "biweekly" | "monthly";
interface Plan {
id: string;
case_id: string | null;
collection_id: string | null;
name: string | null;
status: string;
total_amount: number;
down_payment: number;
installment_count: number;
installment_amount: number;
frequency: Frequency;
start_date: string;
notes: string | null;
}
interface Installment {
id: string;
plan_id: string;
sort_order: number;
due_date: string;
amount: number;
paid: boolean;
paid_on: string | null;
paid_amount: number | null;
paid_method: string | null;
paid_reference: string | null;
payment_request_id: string | null;
notes: string | null;
}
interface PaymentRequestRow {
id: string;
status: string;
stripe_checkout_url: string | null;
paid_at: string | null;
}
function addPeriod(dateISO: string, frequency: Frequency, idx: number): string {
const d = new Date(dateISO + "T00:00:00");
if (frequency === "weekly") d.setDate(d.getDate() + 7 * idx);
else if (frequency === "biweekly") d.setDate(d.getDate() + 14 * idx);
else d.setMonth(d.getMonth() + idx);
return d.toISOString().slice(0, 10);
}
export function CasePaymentPlansTab({ caseRecord }: { caseRecord: any }) {
const caseId: string = caseRecord.id;
const { user } = useAuth();
const [plans, setPlans] = useState<Plan[]>([]);
const [installments, setInstallments] = useState<Record<string, Installment[]>>({});
const [requests, setRequests] = useState<Record<string, PaymentRequestRow>>({});
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
// create form
const [name, setName] = useState("Payment Plan");
const [total, setTotal] = useState("");
const [count, setCount] = useState("3");
const [frequency, setFrequency] = useState<Frequency>("monthly");
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
const [downPayment, setDownPayment] = useState("0");
const [notes, setNotes] = useState("");
const load = useCallback(async () => {
setLoading(true);
const { data: pls, error } = await supabase
.from("payment_plans")
.select("*")
.eq("case_id", caseId)
.order("created_at", { ascending: false });
if (error) {
toast.error(error.message);
setLoading(false);
return;
}
setPlans((pls ?? []) as Plan[]);
const planIds = (pls ?? []).map((p) => p.id);
if (planIds.length) {
const { data: ins } = await supabase
.from("payment_plan_installments")
.select("*")
.in("plan_id", planIds)
.order("sort_order");
const grouped: Record<string, Installment[]> = {};
(ins ?? []).forEach((i: any) => {
(grouped[i.plan_id] ||= []).push(i as Installment);
});
setInstallments(grouped);
const reqIds = (ins ?? []).map((i: any) => i.payment_request_id).filter(Boolean);
if (reqIds.length) {
const { data: prs } = await supabase
.from("payment_requests")
.select("id, status, stripe_checkout_url, paid_at")
.in("id", reqIds);
const map: Record<string, PaymentRequestRow> = {};
(prs ?? []).forEach((r: any) => (map[r.id] = r));
setRequests(map);
} else {
setRequests({});
}
} else {
setInstallments({});
setRequests({});
}
setLoading(false);
}, [caseId]);
useEffect(() => { load(); }, [load]);
const previewAmount = useMemo(() => {
const t = parseFloat(total) || 0;
const dp = parseFloat(downPayment) || 0;
const c = Math.max(1, parseInt(count) || 1);
return Math.max(0, (t - dp) / c);
}, [total, downPayment, count]);
const resetCreate = () => {
setName("Payment Plan");
setTotal("");
setCount("3");
setFrequency("monthly");
setStartDate(new Date().toISOString().slice(0, 10));
setDownPayment("0");
setNotes("");
};
const handleCreate = async () => {
const t = parseFloat(total);
const c = parseInt(count);
const dp = parseFloat(downPayment) || 0;
if (!t || t <= 0) return toast.error("Enter a total amount");
if (!c || c < 1) return toast.error("At least 1 installment");
const installmentAmount = +((t - dp) / c).toFixed(2);
const { data: plan, error } = await supabase
.from("payment_plans")
.insert({
case_id: caseId,
name,
total_amount: t,
down_payment: dp,
installment_count: c,
installment_amount: installmentAmount,
frequency,
start_date: startDate,
notes: notes || null,
status: "active",
created_by: user?.id ?? null,
})
.select("*")
.single();
if (error || !plan) return toast.error(error?.message ?? "Failed to create plan");
const rows = Array.from({ length: c }).map((_, i) => ({
plan_id: plan.id,
sort_order: i + 1,
due_date: addPeriod(startDate, frequency, i),
amount: installmentAmount,
paid: false,
}));
const { error: insErr } = await supabase.from("payment_plan_installments").insert(rows);
if (insErr) return toast.error(insErr.message);
toast.success("Payment plan created");
setCreateOpen(false);
resetCreate();
load();
};
const updateInstallment = async (id: string, patch: Partial<Installment>) => {
const { error } = await supabase.from("payment_plan_installments").update(patch).eq("id", id);
if (error) toast.error(error.message);
else load();
};
const deletePlan = async (id: string) => {
if (!confirm("Delete this payment plan and all installments?")) return;
const { error } = await supabase.from("payment_plans").delete().eq("id", id);
if (error) toast.error(error.message);
else { toast.success("Plan deleted"); load(); }
};
const generatePayment = async (inst: Installment, plan: Plan) => {
setBusyId(inst.id);
try {
const res = await createPaymentRequest({
data: {
recipient_name: caseRecord.client?.name || caseRecord.title || "Payment",
description: `${plan.name || "Payment Plan"} – Installment ${inst.sort_order}`,
base_amount_cents: Math.round(Number(inst.amount) * 100),
case_id: caseId,
client_id: caseRecord.client_id ?? undefined,
},
});
await supabase
.from("payment_plan_installments")
.update({ payment_request_id: res.id })
.eq("id", inst.id);
toast.success("Payment link generated");
if (res.checkout_url) window.open(res.checkout_url, "_blank");
load();
} catch (e: any) {
toast.error(e?.message ?? "Failed to generate payment");
} finally {
setBusyId(null);
}
};
const refreshStatus = async (inst: Installment) => {
if (!inst.payment_request_id) return;
setBusyId(inst.id);
try {
const res = await refreshPaymentStatus({ data: { id: inst.payment_request_id } });
if (res.status === "paid" && !inst.paid) {
await supabase
.from("payment_plan_installments")
.update({
paid: true,
paid_on: new Date().toISOString().slice(0, 10),
paid_amount: inst.amount,
paid_method: "online",
})
.eq("id", inst.id);
toast.success("Marked as paid");
} else {
toast.message(`Status: ${res.status}`);
}
load();
} catch (e: any) {
toast.error(e?.message ?? "Failed to refresh");
} finally {
setBusyId(null);
}
};
const markPaidManual = async (inst: Installment) => {
const dateStr = prompt("Paid on (YYYY-MM-DD)?", new Date().toISOString().slice(0, 10));
if (!dateStr) return;
const method = prompt("Payment method (check, cash, ACH, etc.)?", "check") || "manual";
const reference = prompt("Reference / check #? (optional)", "") || null;
await updateInstallment(inst.id, {
paid: true,
paid_on: dateStr,
paid_amount: inst.amount,
paid_method: method,
paid_reference: reference,
});
};
const unmarkPaid = async (inst: Installment) => {
if (!confirm("Mark this installment as unpaid?")) return;
await updateInstallment(inst.id, {
paid: false,
paid_on: null,
paid_amount: null,
paid_method: null,
paid_reference: null,
});
};
if (loading) {
return (
<Card><CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin mb-2" /> Loading payment plans…
</CardContent></Card>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">Payment Plans</h3>
<p className="text-xs text-muted-foreground">Schedule installments and collect online or manually.</p>
</div>
<Button onClick={() => setCreateOpen(true)} size="sm"><Plus className="h-4 w-4 mr-1" />New plan</Button>
</div>
{plans.length === 0 ? (
<Card><CardContent className="py-10 text-center text-sm text-muted-foreground">
<CalendarClock className="mx-auto h-6 w-6 mb-2 opacity-60" />
No payment plans yet for this case.
</CardContent></Card>
) : plans.map((plan) => {
const ins = installments[plan.id] ?? [];
const paidTotal = ins.filter((i) => i.paid).reduce((s, i) => s + Number(i.paid_amount ?? i.amount), 0);
return (
<Card key={plan.id}>
<CardContent className="p-4 space-y-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="font-semibold">{plan.name || "Payment Plan"}</div>
<div className="text-xs text-muted-foreground">
Total {formatCurrency(plan.total_amount)} • {plan.installment_count} {plan.frequency} payments • starts {formatDate(plan.start_date)}
</div>
{plan.down_payment > 0 && (
<div className="text-xs text-muted-foreground">Down payment: {formatCurrency(plan.down_payment)}</div>
)}
<div className="text-xs mt-1">
Paid: <span className="font-medium">{formatCurrency(paidTotal)}</span> / {formatCurrency(plan.total_amount - plan.down_payment)}
</div>
</div>
<div className="flex gap-2">
<Badge variant="outline">{plan.status}</Badge>
<Button size="sm" variant="ghost" onClick={() => deletePlan(plan.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Due date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Paid on / method</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ins.map((i) => {
const req = i.payment_request_id ? requests[i.payment_request_id] : null;
return (
<TableRow key={i.id}>
<TableCell>{i.sort_order}</TableCell>
<TableCell>
<Input
type="date"
value={i.due_date}
disabled={i.paid}
onChange={(e) => updateInstallment(i.id, { due_date: e.target.value })}
className="h-8 w-[140px]"
/>
</TableCell>
<TableCell>
<Input
type="number"
step="0.01"
value={i.amount}
disabled={i.paid}
onChange={(e) => updateInstallment(i.id, { amount: parseFloat(e.target.value) || 0 })}
className="h-8 w-[110px]"
/>
</TableCell>
<TableCell>
{i.paid ? (
<Badge className="bg-primary text-primary-foreground hover:bg-primary">Paid</Badge>
) : req ? (
<Badge variant="outline">{req.status}</Badge>
) : (
<Badge variant="secondary">Scheduled</Badge>
)}
</TableCell>
<TableCell className="text-xs">
{i.paid ? (
<span>{i.paid_on ? formatDate(i.paid_on) : ""}{i.paid_method ? ` • ${i.paid_method}` : ""}{i.paid_reference ? ` (${i.paid_reference})` : ""}</span>
) : "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{!i.paid && !req && (
<Button size="sm" variant="outline" disabled={busyId === i.id} onClick={() => generatePayment(i, plan)}>
{busyId === i.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CreditCard className="h-3.5 w-3.5" />}
<span className="ml-1">Generate</span>
</Button>
)}
{!i.paid && req && (
<>
{req.stripe_checkout_url && (
<Button size="sm" variant="ghost" asChild>
<a href={req.stripe_checkout_url} target="_blank" rel="noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
)}
<Button size="sm" variant="ghost" disabled={busyId === i.id} onClick={() => refreshStatus(i)}>
{busyId === i.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
</>
)}
{!i.paid ? (
<Button size="sm" variant="outline" onClick={() => markPaidManual(i)}>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />Mark paid
</Button>
) : (
<Button size="sm" variant="ghost" onClick={() => unmarkPaid(i)}>Unpay</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
})}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Payment Plan</DialogTitle>
<DialogDescription>Total amount divided into installments. You can edit each installment afterwards.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Total amount</Label>
<Input type="number" step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} placeholder="0.00" />
</div>
<div>
<Label>Down payment</Label>
<Input type="number" step="0.01" value={downPayment} onChange={(e) => setDownPayment(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label># of installments</Label>
<Input type="number" min={1} value={count} onChange={(e) => setCount(e.target.value)} />
</div>
<div>
<Label>Frequency</Label>
<Select value={frequency} onValueChange={(v) => setFrequency(v as Frequency)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="biweekly">Biweekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Start date</Label>
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
</div>
<div className="text-xs text-muted-foreground">
Each installment will be approximately <strong>{formatCurrency(previewAmount)}</strong>.
</div>
<div>
<Label>Notes</Label>
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate}>Create plan</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+29 -3
View File
@@ -2538,7 +2538,10 @@ export type Database = {
notes: string | null
paid: boolean
paid_amount: number | null
paid_method: string | null
paid_on: string | null
paid_reference: string | null
payment_request_id: string | null
plan_id: string
sort_order: number
updated_at: string
@@ -2552,7 +2555,10 @@ export type Database = {
notes?: string | null
paid?: boolean
paid_amount?: number | null
paid_method?: string | null
paid_on?: string | null
paid_reference?: string | null
payment_request_id?: string | null
plan_id: string
sort_order?: number
updated_at?: string
@@ -2566,7 +2572,10 @@ export type Database = {
notes?: string | null
paid?: boolean
paid_amount?: number | null
paid_method?: string | null
paid_on?: string | null
paid_reference?: string | null
payment_request_id?: string | null
plan_id?: string
sort_order?: number
updated_at?: string
@@ -2579,6 +2588,13 @@ export type Database = {
referencedRelation: "collection_ledger_entries"
referencedColumns: ["id"]
},
{
foreignKeyName: "payment_plan_installments_payment_request_id_fkey"
columns: ["payment_request_id"]
isOneToOne: false
referencedRelation: "payment_requests"
referencedColumns: ["id"]
},
{
foreignKeyName: "payment_plan_installments_plan_id_fkey"
columns: ["plan_id"]
@@ -2590,7 +2606,8 @@ export type Database = {
}
payment_plans: {
Row: {
collection_id: string
case_id: string | null
collection_id: string | null
created_at: string
created_by: string | null
down_payment: number
@@ -2606,7 +2623,8 @@ export type Database = {
updated_at: string
}
Insert: {
collection_id: string
case_id?: string | null
collection_id?: string | null
created_at?: string
created_by?: string | null
down_payment?: number
@@ -2622,7 +2640,8 @@ export type Database = {
updated_at?: string
}
Update: {
collection_id?: string
case_id?: string | null
collection_id?: string | null
created_at?: string
created_by?: string | null
down_payment?: number
@@ -2638,6 +2657,13 @@ export type Database = {
updated_at?: string
}
Relationships: [
{
foreignKeyName: "payment_plans_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
{
foreignKeyName: "payment_plans_collection_id_fkey"
columns: ["collection_id"]
+4 -1
View File
@@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork, Wallet, CheckSquare, Mail } from "lucide-react";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone, Tag, Archive, ArchiveRestore, ArrowRightLeft, Search, GitFork, Wallet, CheckSquare, Mail, CalendarClock } from "lucide-react";
import { TrustAccountPanel } from "@/components/trust/trust-account-panel";
import { ConvertToCollectionsDialog } from "@/components/cases/convert-to-collections-dialog";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
@@ -26,6 +26,7 @@ import { CaseCollectionsTab } from "@/components/cases/collections-tab";
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
import { CaseTasksTab } from "@/components/cases/tasks-tab";
import { CaseMessagesTab } from "@/components/cases/messages-tab";
import { CasePaymentPlansTab } from "@/components/cases/payment-plans-tab";
import { setArchived } from "@/lib/archive";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
@@ -318,6 +319,7 @@ function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: a
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
<TabsTrigger value="plans"><CalendarClock className="h-3.5 w-3.5 mr-1.5" />Payment plans</TabsTrigger>
{data.client_id && (
<TabsTrigger value="trust"><Wallet className="h-3.5 w-3.5 mr-1.5" />Trust</TabsTrigger>
)}
@@ -336,6 +338,7 @@ function CaseTabs({ data, caseId, canManage, load, tab, onTabChange }: { data: a
<TabsContent value="time"><CaseTimeTab caseRecord={data} onInvoice={() => onTabChange("invoices")} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
<TabsContent value="plans"><CasePaymentPlansTab caseRecord={data} /></TabsContent>
{data.client_id && (
<TabsContent value="trust"><TrustAccountPanel clientId={data.client_id} caseId={caseId} /></TabsContent>
)}
@@ -0,0 +1,88 @@
-- Allow payment_plans to be attached to a case directly
ALTER TABLE public.payment_plans
ADD COLUMN IF NOT EXISTS case_id UUID REFERENCES public.cases(id) ON DELETE CASCADE;
ALTER TABLE public.payment_plans
ALTER COLUMN collection_id DROP NOT NULL;
ALTER TABLE public.payment_plans
DROP CONSTRAINT IF EXISTS pp_case_or_collection;
ALTER TABLE public.payment_plans
ADD CONSTRAINT pp_case_or_collection CHECK (case_id IS NOT NULL OR collection_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_payment_plans_case ON public.payment_plans(case_id);
-- Installment fields for online + manual payments
ALTER TABLE public.payment_plan_installments
ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES public.payment_requests(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS paid_method TEXT,
ADD COLUMN IF NOT EXISTS paid_reference TEXT;
-- Update RLS to also allow case-based access
DROP POLICY IF EXISTS pp_select ON public.payment_plans;
DROP POLICY IF EXISTS pp_insert ON public.payment_plans;
DROP POLICY IF EXISTS pp_update ON public.payment_plans;
DROP POLICY IF EXISTS pp_delete ON public.payment_plans;
CREATE POLICY pp_select ON public.payment_plans FOR SELECT TO authenticated USING (
(case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))
OR (collection_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())))
);
CREATE POLICY pp_insert ON public.payment_plans FOR INSERT TO authenticated WITH CHECK (
(case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))
OR (collection_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())))
);
CREATE POLICY pp_update ON public.payment_plans FOR UPDATE TO authenticated USING (
(case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))
OR (collection_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())))
);
CREATE POLICY pp_delete ON public.payment_plans FOR DELETE TO authenticated USING (
(case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid()))
OR (collection_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())))
);
DROP POLICY IF EXISTS ppi_select ON public.payment_plan_installments;
DROP POLICY IF EXISTS ppi_insert ON public.payment_plan_installments;
DROP POLICY IF EXISTS ppi_update ON public.payment_plan_installments;
DROP POLICY IF EXISTS ppi_delete ON public.payment_plan_installments;
CREATE POLICY ppi_select ON public.payment_plan_installments FOR SELECT TO authenticated USING (
EXISTS (
SELECT 1 FROM public.payment_plans p
LEFT JOIN public.collections c ON c.id = p.collection_id
WHERE p.id = plan_id AND (
(p.case_id IS NOT NULL AND public.can_access_case(p.case_id, auth.uid()))
OR (c.id IS NOT NULL AND public.can_access_case(c.case_id, auth.uid()))
)
)
);
CREATE POLICY ppi_insert ON public.payment_plan_installments FOR INSERT TO authenticated WITH CHECK (
EXISTS (
SELECT 1 FROM public.payment_plans p
LEFT JOIN public.collections c ON c.id = p.collection_id
WHERE p.id = plan_id AND (
(p.case_id IS NOT NULL AND public.can_access_case(p.case_id, auth.uid()))
OR (c.id IS NOT NULL AND public.can_access_case(c.case_id, auth.uid()))
)
)
);
CREATE POLICY ppi_update ON public.payment_plan_installments FOR UPDATE TO authenticated USING (
EXISTS (
SELECT 1 FROM public.payment_plans p
LEFT JOIN public.collections c ON c.id = p.collection_id
WHERE p.id = plan_id AND (
(p.case_id IS NOT NULL AND public.can_access_case(p.case_id, auth.uid()))
OR (c.id IS NOT NULL AND public.can_access_case(c.case_id, auth.uid()))
)
)
);
CREATE POLICY ppi_delete ON public.payment_plan_installments FOR DELETE TO authenticated USING (
EXISTS (
SELECT 1 FROM public.payment_plans p
LEFT JOIN public.collections c ON c.id = p.collection_id
WHERE p.id = plan_id AND (
(p.case_id IS NOT NULL AND public.can_access_case(p.case_id, auth.uid()))
OR (c.id IS NOT NULL AND public.can_access_case(c.case_id, auth.uid()))
)
)
);