Added generate payment to ledger

X-Lovable-Edit-ID: edt-464e9ffc-9af0-4dfb-ae6d-0c2e067b06c9
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-26 09:06:07 +00:00
co-authored by renee-png
@@ -23,9 +23,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CalendarClock, Loader2, Plus, Trash2 } from "lucide-react";
import {
CalendarClock,
CreditCard,
ExternalLink,
Loader2,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { formatCurrency, formatDate, parseDateOnly } from "@/lib/format";
import { useServerFn } from "@tanstack/react-start";
import { createPaymentRequest, refreshPaymentStatus } from "@/lib/payments.functions";
type Frequency = "weekly" | "biweekly" | "monthly";
type PlanStatus = "active" | "completed" | "defaulted" | "cancelled";
@@ -55,6 +65,13 @@ interface Installment {
paid_on: string | null;
paid_amount: number | null;
notes: string | null;
payment_request_id?: string | null;
}
interface PaymentRequestRow {
id: string;
status: string;
stripe_checkout_url: string | null;
}
function addInterval(dateStr: string, frequency: Frequency, n: number) {
@@ -77,6 +94,10 @@ export function PaymentPlansPanel({
const [installments, setInstallments] = useState<Record<string, Installment[]>>({});
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [requests, setRequests] = useState<Record<string, PaymentRequestRow>>({});
const [busyId, setBusyId] = useState<string | null>(null);
const createReq = useServerFn(createPaymentRequest);
const refreshReq = useServerFn(refreshPaymentStatus);
const load = useCallback(async () => {
setLoading(true);
@@ -102,8 +123,24 @@ export function PaymentPlansPanel({
(grouped[i.plan_id] ||= []).push(i as Installment);
});
setInstallments(grouped);
// Load any associated payment requests
const reqIds = (ins ?? [])
.map((i: any) => i.payment_request_id)
.filter(Boolean) as string[];
if (reqIds.length) {
const { data: prs } = await supabase
.from("payment_requests")
.select("id, status, stripe_checkout_url")
.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);
}, [collectionId]);
@@ -160,6 +197,76 @@ export function PaymentPlansPanel({
else { toast.success("Plan deleted"); load(); }
};
const generatePayment = async (inst: Installment, plan: Plan) => {
setBusyId(inst.id);
try {
// Fetch homeowner + client info for nice recipient label
const { data: coll } = await supabase
.from("collections")
.select(
"case_id, homeowner:homeowners(first_name, last_name, email), case:cases(client_id, client:clients(name))",
)
.eq("id", collectionId)
.maybeSingle();
const homeowner: any = (coll as any)?.homeowner;
const clientName: string | undefined = (coll as any)?.case?.client?.name;
const clientId: string | undefined = (coll as any)?.case?.client_id ?? undefined;
const recipientName =
[homeowner?.first_name, homeowner?.last_name].filter(Boolean).join(" ").trim() ||
clientName ||
"Payment";
const res = await createReq({
data: {
recipient_name: recipientName,
recipient_email: homeowner?.email || undefined,
description: `${plan.name || "Payment Plan"} – Installment ${inst.sort_order + 1}`,
base_amount_cents: Math.round(Number(inst.amount) * 100),
collection_id: collectionId,
case_id: caseId ?? undefined,
client_id: clientId,
},
});
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 refreshReq({ 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);
}
};
return (
<Card className="border-border/60">
<CardContent className="p-4 space-y-4">
@@ -227,6 +334,7 @@ export function PaymentPlansPanel({
{ins.map((i) => {
const overdue =
!i.paid && parseDateOnly(i.due_date)! < new Date(new Date().toDateString());
const req = i.payment_request_id ? requests[i.payment_request_id] : undefined;
return (
<div key={i.id} className="flex items-center gap-3 p-2.5 px-3">
<Checkbox
@@ -239,6 +347,11 @@ export function PaymentPlansPanel({
{i.paid && i.paid_on && (
<span className="ml-2 text-xs text-success">paid {formatDate(i.paid_on)}</span>
)}
{!i.paid && req && (
<Badge variant="outline" className="ml-2 text-[10px] capitalize">
{req.status}
</Badge>
)}
</div>
<div className={`text-xs ${overdue ? "text-destructive" : "text-muted-foreground"}`}>
Due {formatDate(i.due_date)}
@@ -260,6 +373,48 @@ export function PaymentPlansPanel({
{formatCurrency(i.amount)}
</div>
)}
{!i.paid && !req && (
<Button
size="sm"
variant="outline"
className="h-7"
disabled={busyId === i.id}
onClick={() => generatePayment(i, p)}
title="Generate Stripe payment link"
>
{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 text-xs">Generate</span>
</Button>
)}
{!i.paid && req && (
<>
{req.stripe_checkout_url && (
<Button size="icon" variant="ghost" className="h-7 w-7" asChild title="Open payment link">
<a href={req.stripe_checkout_url} target="_blank" rel="noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={busyId === i.id}
onClick={() => refreshStatus(i)}
title="Refresh status"
>
{busyId === i.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
</>
)}
</div>
</div>
);