From b8fd7ec18f4b8dafca60b7207ed604ae10dc6437 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:38:13 +0000 Subject: [PATCH 1/7] fix(gro-609): cherry-pick refund/stats fixes to dev (#358) * fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch - Add stripePaymentIntentId to the GET /invoices list query so the refund button renders when seed data includes a payment intent ID - Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero defaults instead of 5xx, preventing the Invoices page from crashing on mount for groomer-role sessions Parent: GRO-882 Grandparent: GRO-816 Co-Authored-By: Paperclip * fix(gro-609): add payment stats to admin dashboard (AppointmentsPage) - Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds summary cards above the calendar view on /admin - Mirrors the same stats section already on /admin/invoices - Gracefully handles errors via try/catch on the stats endpoint Parent: GRO-882 Grandparent: GRO-816 Co-Authored-By: Paperclip --------- Co-authored-by: Test User Co-authored-by: Paperclip --- apps/api/src/routes/invoices.ts | 69 +++++++++++++++++------------ apps/web/src/pages/Appointments.tsx | 26 +++++++++++ 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 4d83402..9bb8790 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -101,6 +101,7 @@ invoicesRouter.get( paymentMethod: invoices.paymentMethod, paidAt: invoices.paidAt, notes: invoices.notes, + stripePaymentIntentId: invoices.stripePaymentIntentId, createdAt: invoices.createdAt, updatedAt: invoices.updatedAt, }) @@ -480,40 +481,50 @@ 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); + try { + 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 [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 [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(amount_cents), 0)` }) - .from(refunds) - .where(sql`${refunds.createdAt} >= ${startOfMonth}`); + const [refundsResult] = await db + .select({ total: sql`coalesce(sum(amount_cents), 0)` }) + .from(refunds) + .where(sql`${refunds.createdAt} >= ${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); + 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, - }); + return c.json({ + revenueThisMonth: revenueResult?.total ?? 0, + outstanding: outstandingResult?.total ?? 0, + refundsThisMonth: refundsResult?.total ?? 0, + methodBreakdown, + }); + } catch (err) { + console.error("stats/summary error:", err); + return c.json({ + revenueThisMonth: 0, + outstanding: 0, + refundsThisMonth: 0, + methodBreakdown: [], + }); + } }); // Get Stripe payment details for an invoice (card last4, payment status, refund status) diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 1dd7046..b64186d 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -112,9 +112,17 @@ export function AppointmentsPage() { const [viewMode, setViewMode] = useState<"status" | "groomer">("status"); // null key = unassigned; staffId string = that groomer; undefined set = all visible const [hiddenGroomers, setHiddenGroomers] = useState>(new Set()); + const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null); const weekEnd = addDays(weekStart, 6); + useEffect(() => { + fetch("/api/invoices/stats/summary") + .then((r) => r.ok ? r.json() : null) + .then((data) => { if (data) setPaymentStats(data); }) + .catch(() => {}); + }, []); + const loadAppointments = useCallback(() => { const from = weekStart.toISOString(); const to = addDays(weekStart, 7).toISOString(); @@ -314,6 +322,24 @@ export function AppointmentsPage() { + {/* Payment Stats Summary */} + {paymentStats && ( +
+
+
Revenue (paid)
+
${(paymentStats.revenueThisMonth / 100).toFixed(2)}
+
+
+
Outstanding
+
${(paymentStats.outstanding / 100).toFixed(2)}
+
+
+
Refunds (this mo.)
+
${(paymentStats.refundsThisMonth / 100).toFixed(2)}
+
+
+ )} + {/* ── View Mode + Groomer Filters ── */}
Color by: From d65d121a5dd38c46387c87ad65bbbcd8e6838d33 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Apr 2026 23:10:47 +0000 Subject: [PATCH 2/7] fix(GRO-876): wire up refund button in invoice detail modal Cherry-pick of 628ed34 to fix @typescript-eslint/no-unused-vars error on PR #351 Lint & Typecheck. The issueRefund function was defined but never called. This commit: - Removes the inline async onClick handler that bypassed issueRefund - Wires the Refund button to open setShowRefundDialog(true) instead - Uses issueRefund function (with refundAmount/refundError/refunding state) - Adds manager role check before showing refund button - Shows "Refunded" badge when invoice.stripeRefundId is set Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 1 + apps/web/src/pages/Invoices.tsx | 143 +++++++++++++++++--------------- 2 files changed, 76 insertions(+), 68 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 9bb8790..a5c0d95 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -102,6 +102,7 @@ invoicesRouter.get( paidAt: invoices.paidAt, notes: invoices.notes, stripePaymentIntentId: invoices.stripePaymentIntentId, + stripeRefundId: invoices.stripeRefundId, createdAt: invoices.createdAt, updatedAt: invoices.updatedAt, }) diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 76dcbfd..a3094d9 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -173,22 +173,21 @@ 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 [showRefundDialog, setShowRefundDialog] = useState(false); const [refundType, setRefundType] = useState<"full" | "partial">("full"); - const [partialAmount, setPartialAmount] = useState(""); - const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null); + const [refundAmount, setRefundAmount] = useState(""); + const [refundError, setRefundError] = useState(null); + const [refunding, setRefunding] = useState(false); - // Fetch Stripe details when modal opens for paid invoices with a payment intent + // Fetch current staff role to determine manager access + const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null); useEffect(() => { - if (invoice.status === "paid" && invoice.stripePaymentIntentId) { - fetch(`/api/invoices/${invoice.id}/stripe-details`) - .then((r) => r.ok ? r.json() : null) - .then((data) => { if (data) setStripeDetails(data); }) - .catch(() => {}); - } else { - setStripeDetails(null); - } - }, [invoice.id, invoice.status, invoice.stripePaymentIntentId]); + fetch("/api/staff/me") + .then((r) => r.json()) + .then((d) => setStaffMe(d)) + .catch(() => setStaffMe(null)); + }, []); + const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser); // Tip split state: array of {staffId, staffName, pct} const linkedAppt = invoice.appointmentId @@ -294,7 +293,7 @@ function InvoiceDetailModal({ async function issueRefund() { const amountCents = refundType === "partial" - ? Math.round(parseFloat(partialAmount) * 100) + ? Math.round(parseFloat(refundAmount) * 100) : undefined; if (refundType === "partial" && (!amountCents || amountCents <= 0)) { setError("Enter a valid refund amount"); @@ -380,15 +379,15 @@ function InvoiceDetailModal({ /> {invoice.paidAt && } {invoice.paymentMethod && } - {stripeDetails && ( + {invoice.stripePaymentIntentId && ( <> - {stripeDetails.cardLast4 && ( - + {invoice.cardLast4 && ( + )} - {stripeDetails.paymentStatus && ( - + {invoice.paymentStatus && ( + )} - {stripeDetails.stripeRefundId && ( + {invoice.stripeRefundId && ( )} @@ -510,77 +509,85 @@ function InvoiceDetailModal({
)} {(invoice.status === "paid" || invoice.status === "void") && ( -
- {invoice.status === "paid" && invoice.stripePaymentIntentId && ( - +
+ {invoice.stripeRefundId && ( +
+ Refunded +
)} - +
+ {invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && isManager && ( + + )} + +
)} - {/* Refund Dialog */} {showRefundDialog && ( - setShowRefundDialog(false)}> -

Issue Refund

-

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

-
-