diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 96c3e0e..7444c81 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -4,6 +4,7 @@ import { z } from "zod/v3"; import { and, eq, + gte, getDb, invoices, invoiceLineItems, @@ -377,3 +378,106 @@ invoicesRouter.post( return c.json({ refundId: result.refundId }); } ); + +// ─── Stripe Payment Info ─────────────────────────────────────────────────────── + +import { getStripeClient } from "../services/payment.js"; + +invoicesRouter.get("/:id/stripe-payment", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)); + if (!invoice) return c.json({ error: "Not found" }, 404); + + if (!invoice.stripePaymentIntentId) { + return c.json({ error: "No Stripe payment found for this invoice" }, 404); + } + + const stripe = getStripeClient(); + if (!stripe) return c.json({ error: "Stripe not configured" }, 503); + + try { + const paymentIntent = await stripe.paymentIntents.retrieve(invoice.stripePaymentIntentId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cardDetails = (paymentIntent as any).payment_details?.card; + const refundStatus = invoice.stripeRefundId + ? await stripe.refunds.retrieve(invoice.stripeRefundId).then((r) => r.status).catch(() => null) + : null; + + return c.json({ + paymentIntentId: invoice.stripePaymentIntentId, + amountPaidCents: paymentIntent.amount_received, + status: paymentIntent.status, + cardLast4: cardDetails?.last4 ?? null, + cardBrand: cardDetails?.brand ?? null, + refundId: invoice.stripeRefundId, + refundStatus, + }); + } catch { + return c.json({ error: "Failed to retrieve Stripe payment info" }, 500); + } +}); + +// ─── Payment Stats ───────────────────────────────────────────────────────────── + +invoicesRouter.get("/stats", async (c) => { + const db = getDb(); + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const thisMonthInvoices = await db + .select() + .from(invoices) + .where( + and( + gte(invoices.createdAt, startOfMonth), + eq(invoices.status, "paid") + ) + ); + + const revenueCents = thisMonthInvoices.reduce((sum, inv) => sum + inv.totalCents, 0); + + const pendingInvoices = await db + .select({ totalCents: invoices.totalCents }) + .from(invoices) + .where(eq(invoices.status, "pending")); + + const outstandingCents = pendingInvoices.reduce((sum, inv) => sum + inv.totalCents, 0); + + const refundedInvoices = await db + .select() + .from(invoices) + .where( + and( + gte(invoices.createdAt, startOfMonth), + sql`${invoices.stripeRefundId} IS NOT NULL` + ) + ); + + const refundsCents = refundedInvoices.reduce((sum, inv) => sum + inv.totalCents, 0); + + const paymentMethodBreakdown = await db + .select({ + paymentMethod: invoices.paymentMethod, + count: sql`count(*)`, + totalCents: sql`sum(${invoices.totalCents})`, + }) + .from(invoices) + .where( + and( + gte(invoices.createdAt, startOfMonth), + sql`${invoices.paymentMethod} IS NOT NULL` + ) + ) + .groupBy(invoices.paymentMethod); + + return c.json({ + revenueCents, + outstandingCents, + refundsCents, + revenueCount: thisMonthInvoices.length, + refundCount: refundedInvoices.length, + paymentMethodBreakdown, + }); +}); diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 8363695..2991da2 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types"; +import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit, StripePaymentInfo, PaymentStats } from "@groombook/types"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -173,6 +173,23 @@ function InvoiceDetailModal({ const [error, setError] = useState(null); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [paymentMethod, setPaymentMethod] = useState(invoice.paymentMethod ?? "cash"); + const [stripeInfo, setStripeInfo] = useState(null); + const [stripeLoading, setStripeLoading] = useState(false); + const [showRefundDialog, setShowRefundDialog] = useState(false); + const [refundType, setRefundType] = useState<"full" | "partial">("full"); + const [refundAmountStr, setRefundAmountStr] = useState(""); + const [refunding, setRefunding] = useState(false); + + useEffect(() => { + if (invoice.status === "paid" && invoice.stripePaymentIntentId) { + setStripeLoading(true); + fetch(`/api/invoices/${invoice.id}/stripe-payment`) + .then((r) => r.json()) + .then((data: StripePaymentInfo) => setStripeInfo(data)) + .catch(() => { /* non-blocking */ }) + .finally(() => setStripeLoading(false)); + } + }, [invoice.id, invoice.status, invoice.stripePaymentIntentId]); // Tip split state: array of {staffId, staffName, pct} const linkedAppt = invoice.appointmentId @@ -271,6 +288,31 @@ function InvoiceDetailModal({ } } + async function submitRefund() { + setRefunding(true); + setError(null); + const amountCents = refundType === "partial" + ? Math.round(parseFloat(refundAmountStr) * 100) + : undefined; + try { + const res = await fetch(`/api/invoices/${invoice.id}/refund`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 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 : "Refund failed"); + } finally { + setRefunding(false); + } + } + if (loading) return

Loading…

; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; @@ -330,6 +372,18 @@ function InvoiceDetailModal({ /> {invoice.paidAt && } {invoice.paymentMethod && } + {stripeLoading && } + {stripeInfo && ( + <> + {stripeInfo.cardLast4 && ( + + )} + + {invoice.stripeRefundId && stripeInfo.refundStatus && ( + + )} + + )} {/* ── Tip Distribution ── */} @@ -447,10 +501,101 @@ function InvoiceDetailModal({ )} {(invoice.status === "paid" || invoice.status === "void") && ( -
+
+ {invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && ( + + )}
)} + + {showRefundDialog && ( +
{ if (e.target === e.currentTarget) setShowRefundDialog(false); }} + > +
+

Process Refund

+

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

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

{error}

} +
+ + +
+
+
+ )} ); } @@ -492,6 +637,8 @@ export function InvoicesPage() { const [createLoading, setCreateLoading] = useState(false); const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null); const [detailLoading, setDetailLoading] = useState(false); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); const LIMIT = 50; @@ -513,6 +660,15 @@ export function InvoicesPage() { .finally(() => setLoading(false)); }, [statusFilter]); + useEffect(() => { + setStatsLoading(true); + fetch("/api/invoices/stats") + .then((r) => r.json()) + .then((data: PaymentStats) => setStats(data)) + .catch(() => { /* non-blocking */ }) + .finally(() => setStatsLoading(false)); + }, []); + function loadCreateData() { if (createData) return Promise.resolve(); setCreateLoading(true); @@ -573,6 +729,36 @@ export function InvoicesPage() {
+ {!statsLoading && stats && ( +
+
+
Revenue this month
+
{fmtMoney(stats.revenueCents)}
+
{stats.revenueCount} paid
+
+
+
Outstanding
+
{fmtMoney(stats.outstandingCents)}
+
+
+
Refunds this month
+
{fmtMoney(stats.refundsCents)}
+
{stats.refundCount} refunds
+
+ {stats.paymentMethodBreakdown.length > 0 && ( +
+
By payment method
+ {stats.paymentMethodBreakdown.map((b) => ( +
+ {b.paymentMethod} + {fmtMoney(b.totalCents)} +
+ ))} +
+ )} +
+ )} + {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 198b8b3..1d30c3b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -153,10 +153,38 @@ export interface Invoice { notes: string | null; createdAt: string; updatedAt: string; + stripePaymentIntentId?: string | null; + stripeRefundId?: string | null; + paymentFailureReason?: string | null; lineItems?: InvoiceLineItem[]; tipSplits?: InvoiceTipSplit[]; } +export interface StripePaymentInfo { + paymentIntentId: string; + amountPaidCents: number; + status: string; + cardLast4: string | null; + cardBrand: string | null; + refundId: string | null; + refundStatus: string | null; +} + +export interface PaymentMethodBreakdown { + paymentMethod: PaymentMethod; + count: number; + totalCents: number; +} + +export interface PaymentStats { + revenueCents: number; + outstandingCents: number; + refundsCents: number; + revenueCount: number; + refundCount: number; + paymentMethodBreakdown: PaymentMethodBreakdown[]; +} + // ─── Impersonation ────────────────────────────────────────────────────────── export type ImpersonationSessionStatus = "active" | "ended" | "expired";