From 7f715ecdfcd977e024b2ecba1616d3d90b96669a Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 02:42:06 +0000 Subject: [PATCH 1/7] fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email The resolveStaffMiddleware auto-links on first API call when staff.user_id IS NULL. Setting userId at seed time blocks this path since Better-Auth's user.id is opaque and unknown pre-auth. Remove userId from all staff inserts so the middleware can populate it on first authenticated call. Co-Authored-By: Paperclip --- packages/db/src/seed.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index a19f254..dca21d4 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -399,7 +399,6 @@ async function seedKnownUsers() { name: adminName, email: adminEmail, oidcSub: adminEmail, - userId: adminEmail, role: "manager", isSuperUser: true, active: true, @@ -426,7 +425,6 @@ async function seedKnownUsers() { name: "UAT Super User", email: "uat-super@groombook.dev", oidcSub: uatSuperOidcSub, - userId: uatSuperOidcSub, role: "manager", isSuperUser: true, active: true, @@ -453,7 +451,6 @@ async function seedKnownUsers() { name: "UAT Staff Groomer", email: "uat-groomer@groombook.dev", oidcSub: uatStaffOidcSub, - userId: uatStaffOidcSub, role: "groomer", isSuperUser: false, active: true, @@ -648,7 +645,6 @@ async function seed() { name: adminName, email: adminEmail, oidcSub: adminEmail, - userId: adminEmail, role: "manager", isSuperUser: true, active: true, -- 2.52.0 From b1b89966d9f497374cf2f992353d4c746aa8ddc4 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 18 Apr 2026 10:36:23 +0000 Subject: [PATCH 2/7] fix: allow groomer role to access invoices endpoint Co-Authored-By: Paperclip --- apps/api/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c6e90a5..6d48d66 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -202,7 +202,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager" api.use("/admin/*", requireRoleOrSuperUser("manager")); api.use("/admin/settings/*", requireSuperUser()); api.use("/reports/*", requireRole("manager")); -api.use("/invoices/*", requireRole("manager")); +api.use("/invoices/*", requireRole("manager", "groomer")); api.use("/impersonation/*", requireRole("manager")); // Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist -- 2.52.0 From d59cb1ab1d4c9c4da77d9372cf5333021576a7d0 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 19 Apr 2026 00:17:42 +0000 Subject: [PATCH 3/7] feat(gro-609): add refund handling and payment stats to admin - Add stripePaymentIntentId to Invoice schema and types - Add POST /api/invoices/:id/refund endpoint (Stripe placeholder) - Add GET /api/invoices/stats/summary for payment analytics - Add refund button + dialog (full/partial) to InvoiceDetailModal - Add payment stats cards to Invoices page (revenue, outstanding, refunds, method breakdown) Ref: GRO-609 Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 38 +++++++++ apps/web/src/pages/Invoices.tsx | 136 +++++++++++++++++++++++++++++++- packages/types/src/index.ts | 3 + 3 files changed, 176 insertions(+), 1 deletion(-) 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; -- 2.52.0 From 50e9e70935adec49661446ac98d7c3d9beb1b9de Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 19 Apr 2026 01:02:49 +0000 Subject: [PATCH 4/7] feat(gro-609): add Stripe details to invoice modal and fix stats date filter - Add GET /api/invoices/:id/stripe-details endpoint to fetch card last4 and payment status from Stripe - Add getPaymentIntentDetails() to payment service - Fix stats summary query to filter by startOfMonth - Add cardLast4, paymentStatus, stripeRefundId transient fields to Invoice type - Display Stripe details (card last4, payment status, refund status) in modal - Add stripeRefundId and paymentFailureReason to Invoice schema (was missing in dev types) Ref: GRO-609 Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 29 ++++++++++++++++++++++++++++- apps/api/src/services/payment.ts | 16 ++++++++++++++++ apps/web/src/pages/Invoices.tsx | 26 ++++++++++++++++++++++++++ packages/types/src/index.ts | 3 +++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 5a730b9..0d2565a 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -422,7 +422,7 @@ invoicesRouter.patch( // ─── Refund ─────────────────────────────────────────────────────────────────── -import { processRefund } from "../services/payment.js"; +import { processRefund, getPaymentIntentDetails } from "../services/payment.js"; const refundSchema = z.object({ amountCents: z.number().int().nonnegative().optional(), @@ -515,3 +515,30 @@ invoicesRouter.get("/stats/summary", async (c) => { methodBreakdown, }); }); + +// Get Stripe payment details for an invoice (card last4, payment status, refund status) +invoicesRouter.get("/:id/stripe-details", 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); + + let cardLast4: string | null = null; + let paymentStatus: string | null = null; + + if (invoice.stripePaymentIntentId) { + const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId); + if (details) { + cardLast4 = details.cardLast4; + paymentStatus = details.paymentStatus; + } + } + + return c.json({ + stripePaymentIntentId: invoice.stripePaymentIntentId, + stripeRefundId: invoice.stripeRefundId, + cardLast4, + paymentStatus, + }); +}); diff --git a/apps/api/src/services/payment.ts b/apps/api/src/services/payment.ts index d09d1db..cad0894 100644 --- a/apps/api/src/services/payment.ts +++ b/apps/api/src/services/payment.ts @@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec return { clientSecret: setupIntent.client_secret! }; } + +export async function getPaymentIntentDetails( + paymentIntentId: string +): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> { + const stripe = getStripeClient(); + if (!stripe) return null; + + const pi = await stripe.paymentIntents.retrieve(paymentIntentId); + const cardLast4 = pi.payment_method + ? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null + : null; + return { + cardLast4, + paymentStatus: pi.status ?? null, + }; +} diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 5058442..76dcbfd 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -176,6 +176,19 @@ function InvoiceDetailModal({ 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); + + // Fetch Stripe details when modal opens for paid invoices with a payment intent + 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]); // Tip split state: array of {staffId, staffName, pct} const linkedAppt = invoice.appointmentId @@ -367,6 +380,19 @@ function InvoiceDetailModal({ /> {invoice.paidAt && } {invoice.paymentMethod && } + {stripeDetails && ( + <> + {stripeDetails.cardLast4 && ( + + )} + {stripeDetails.paymentStatus && ( + + )} + {stripeDetails.stripeRefundId && ( + + )} + + )} {/* ── Tip Distribution ── */} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 918bfc2..90ef116 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -159,6 +159,9 @@ export interface Invoice { createdAt: string; updatedAt: string; lineItems?: InvoiceLineItem[]; + // Transient fields populated from Stripe API (not stored in DB) + cardLast4?: string | null; + paymentStatus?: string | null; tipSplits?: InvoiceTipSplit[]; } -- 2.52.0 From 560d33edf8284c8b372201938b3e643bcacb212c Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 19 Apr 2026 01:55:32 +0000 Subject: [PATCH 5/7] fix(gro-609): fix two bugs found by CTO review 1. Refund stats now sum actual refund amounts from refunds table instead of incorrectly summing tip_cents from invoices table. 2. Stripe payment_intents.retrieve now expands payment_method so card.last4 is correctly available instead of null. Co-Authored-By: Paperclip --- apps/api/src/routes/invoices.ts | 6 +++--- apps/api/src/services/payment.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 0d2565a..4d83402 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -495,9 +495,9 @@ invoicesRouter.get("/stats/summary", async (c) => { .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}`)); + .select({ total: sql`coalesce(sum(amount_cents), 0)` }) + .from(refunds) + .where(sql`${refunds.createdAt} >= ${startOfMonth}`); const methodBreakdown = await db .select({ diff --git a/apps/api/src/services/payment.ts b/apps/api/src/services/payment.ts index cad0894..eb97597 100644 --- a/apps/api/src/services/payment.ts +++ b/apps/api/src/services/payment.ts @@ -169,7 +169,7 @@ export async function getPaymentIntentDetails( const stripe = getStripeClient(); if (!stripe) return null; - const pi = await stripe.paymentIntents.retrieve(paymentIntentId); + const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] }); const cardLast4 = pi.payment_method ? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null : null; -- 2.52.0 From 03bd2d0235ed001cf4a5dc651b510d51c1b025d5 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 19 Apr 2026 08:13:53 +0000 Subject: [PATCH 6/7] fix(GRO-816): update PetProfiles.tsx to use new appointments response shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PetProfiles.tsx: update AppointmentsResponse interface to use flat appointments[] array instead of { upcoming, past } - PetProfiles.tsx: update petHistory filter to use appointments.appointments with date filter for past-only appointments - portal.ts: change /api/portal/appointments response to { appointments: [] } instead of { upcoming: [], past: [] } - portal.ts: change /api/portal/pets response field names to match frontend Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl, groomingNotes→notes Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 7 ++----- apps/web/src/portal/sections/PetProfiles.tsx | 7 +++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index dc556c8..f7f5b07 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -142,10 +142,7 @@ portalRouter.get("/appointments", async (c) => { staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); - const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled"); - const past = appts.filter(a => a.startTime <= now || a.status === "cancelled"); - - return c.json({ upcoming, past }); + return c.json({ appointments: appts }); }); portalRouter.get("/pets", async (c) => { @@ -153,7 +150,7 @@ portalRouter.get("/pets", async (c) => { const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); - return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); + return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes }))); }); portalRouter.get("/invoices", async (c) => { diff --git a/apps/web/src/portal/sections/PetProfiles.tsx b/apps/web/src/portal/sections/PetProfiles.tsx index f657e6a..e9fb07b 100644 --- a/apps/web/src/portal/sections/PetProfiles.tsx +++ b/apps/web/src/portal/sections/PetProfiles.tsx @@ -27,8 +27,7 @@ interface Appointment { } interface AppointmentsResponse { - upcoming: Appointment[]; - past: Appointment[]; + appointments: Appointment[]; } interface Props { @@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record { export function PetProfiles({ sessionId, readOnly }: Props) { const [pets, setPets] = useState([]); - const [appointments, setAppointments] = useState({ upcoming: [], past: [] }); + const [appointments, setAppointments] = useState({ appointments: [] }); const [selectedPetId, setSelectedPetId] = useState(""); const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info"); const [editingPetId, setEditingPetId] = useState(null); @@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) { }, [sessionId]); const selectedPet = pets.find(p => p.id === selectedPetId) ?? null; - const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId); + const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date()); const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null; function handlePetSave(updatedPet: Pet) { -- 2.52.0 From ff149f75dc0db2f0f70d0d2c9d5a795d6d94eaed Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 19 Apr 2026 10:52:13 +0000 Subject: [PATCH 7/7] fix(GRO-816): remove unused 'now' variable from portal.ts appointments handler The PR refactored appointments response from { upcoming, past } to { appointments: [] } but the `now` variable used to compute those filters was left behind. ESLint correctly flags it as unused. Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 1 - apps/api/src/routes/setup.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index f7f5b07..a4c2b87 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -102,7 +102,6 @@ portalRouter.get("/appointments", async (c) => { const db = getDb(); const clientId = c.get("portalClientId"); - const now = new Date(); const allAppts = await db .select({ id: appointments.id, diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index a84e61d..495fd66 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10; const rateLimitMap = new Map(); function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } { - const now = Date.now(); const entry = rateLimitMap.get(ip); + const now = Date.now(); if (!entry || now > entry.resetAt) { rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); return { allowed: true, remaining: RATE_LIMIT_MAX - 1 }; -- 2.52.0