diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index aaa7fb3..5a730b9 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -477,3 +477,41 @@ invoicesRouter.post( }); } ); + +// Payment stats for admin dashboard +invoicesRouter.get("/stats/summary", async (c) => { + const db = getDb(); + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const [revenueResult] = await db + .select({ total: sql`coalesce(sum(total_cents), 0)` }) + .from(invoices) + .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); + + const [outstandingResult] = await db + .select({ total: sql`coalesce(sum(total_cents), 0)` }) + .from(invoices) + .where(eq(invoices.status, "pending")); + + const [refundsResult] = await db + .select({ total: sql`coalesce(sum(tip_cents), 0)` }) + .from(invoices) + .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); + + const methodBreakdown = await db + .select({ + method: invoices.paymentMethod, + total: sql`count(*)`, + }) + .from(invoices) + .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)) + .groupBy(invoices.paymentMethod); + + return c.json({ + revenueThisMonth: revenueResult?.total ?? 0, + outstanding: outstandingResult?.total ?? 0, + refundsThisMonth: refundsResult?.total ?? 0, + methodBreakdown, + }); +}); diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 04fc7ea..5058442 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -173,6 +173,9 @@ function InvoiceDetailModal({ const [error, setError] = useState(null); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash"); + const [showRefundDialog, setShowRefundDialog] = useState(false); + const [refundType, setRefundType] = useState<"full" | "partial">("full"); + const [partialAmount, setPartialAmount] = useState(""); // Tip split state: array of {staffId, staffName, pct} const linkedAppt = invoice.appointmentId @@ -276,6 +279,35 @@ function InvoiceDetailModal({ } } + async function issueRefund() { + const amountCents = refundType === "partial" + ? Math.round(parseFloat(partialAmount) * 100) + : undefined; + if (refundType === "partial" && (!amountCents || amountCents <= 0)) { + setError("Enter a valid refund amount"); + return; + } + setSaving(true); + setError(null); + try { + const res = await fetch(`/api/invoices/${invoice.id}/refund`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(amountCents ? { amountCents } : {}), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + setShowRefundDialog(false); + onUpdated(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to issue refund"); + } finally { + setSaving(false); + } + } + if (loading) return

Loading…

; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; @@ -452,10 +484,76 @@ function InvoiceDetailModal({ )} {(invoice.status === "paid" || invoice.status === "void") && ( -
+
+ {invoice.status === "paid" && invoice.stripePaymentIntentId && ( + + )}
)} + + {/* Refund Dialog */} + {showRefundDialog && ( + setShowRefundDialog(false)}> +

Issue Refund

+

+ Invoice total: {fmtMoney(invoice.totalCents)} +

+
+ + +
+ {refundType === "partial" && ( +
+ setPartialAmount(e.target.value)} + style={{ ...inputStyle, width: 120 }} + /> +
+ )} + {error &&

{error}

} +
+ + +
+
+ )} ); } @@ -497,9 +595,17 @@ export function InvoicesPage() { const [createLoading, setCreateLoading] = useState(false); const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null); const [detailLoading, setDetailLoading] = useState(false); + const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null); const LIMIT = 50; + useEffect(() => { + fetch("/api/invoices/stats/summary") + .then((r) => r.ok ? r.json() : null) + .then((data) => { if (data) setPaymentStats(data); }) + .catch(() => {}); + }, []); + async function loadInvoices(newOffset: number) { const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) }); if (statusFilter) params.set("status", statusFilter); @@ -578,6 +684,34 @@ export function InvoicesPage() {
+ {/* Payment Stats Summary */} + {paymentStats && ( +
+
+
Revenue (paid)
+
{fmtMoney(paymentStats.revenueThisMonth)}
+
+
+
Outstanding
+
{fmtMoney(paymentStats.outstanding)}
+
+
+
Refunds (this mo.)
+
{fmtMoney(paymentStats.refundsThisMonth)}
+
+ {paymentStats.methodBreakdown.length > 0 && ( +
+
By method
+
+ {paymentStats.methodBreakdown.map((b) => ( +
{b.method ?? "other"}: {b.total}
+ ))} +
+
+ )} +
+ )} + {invoiceList.length === 0 ? (

No invoices yet. Create one from a completed appointment. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 753b1cf..918bfc2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -152,6 +152,9 @@ export interface Invoice { status: InvoiceStatus; paymentMethod: PaymentMethod | null; paidAt: string | null; + stripePaymentIntentId: string | null; + stripeRefundId: string | null; + paymentFailureReason: string | null; notes: string | null; createdAt: string; updatedAt: string;