From abee344ca49437acce66283853db92c31e739c07 Mon Sep 17 00:00:00 2001 From: "the-dogfather-cto[bot]" <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:58:00 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Promote=20dev=20=E2=86=92=20uat:=20ARIA=20m?= =?UTF-8?q?odal=20fix=20+=20tip=20split=20atomicity=20(#335)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(GRO-785): validate tip split totals before marking invoice paid - PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits exist or splits don't sum to 100% - POST /invoices/:id/tip-splits now returns 400 (not 422) on validation failure via router-level ZodError handler Co-Authored-By: Paperclip * feat(GRO-786): add ARIA label attributes to Modal dialog component - Update Modal component to accept title and titleStyle props - Add role="dialog", aria-modal="true", and aria-labelledby attributes - Use useId() to generate stable ID for title heading association - Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet, Log Grooming Visit, Permanently Delete Client) with title props - Delete modal passes titleStyle for red color on warning Co-Authored-By: Paperclip * fix(GRO-786): remove duplicate dialog role and restore focus trap - Remove role="dialog" and aria-modal="true" from outer backdrop div - Keep ARIA attributes only on inner dialog div (the actual modal) - Restore useEffect focus management: auto-focus first element, Tab cycle wrapping, Escape key handler, focus restore on close Co-Authored-By: Paperclip * fix(GRO-785): restore atomic tip split save in PATCH and fix error message - When body.tipSplits is provided in PATCH /invoices/:id, validate sum first then atomically replace existing splits (delete + insert) - When no incoming splits, validate existing DB splits with corrected message: "Tip splits are required when tip amount is greater than zero" (previously misleading "must sum to 100%" when no splits existed) Co-Authored-By: Paperclip * fix(GRO-785): address invoice tip split regression - Use body.tipCents ?? current.tipCents for validation condition so that simultaneous status=paid + tipCents=0 skip split validation - Use body.tipCents (now aliased as tipCents) instead of current.tipCents inside the atomic transaction for shareCents calculation - Add explicit check for empty tipSplits array with appropriate error message ("Tip splits are required when tip amount is greater than zero") before the sum-to-100% check - Destructure tipSplits out of body before spreading into update object to prevent it from leaking into the invoices table SET clause Co-Authored-By: Paperclip * fix(GRO-785): wrap tip split save + invoice update in single transaction Both tip split persistence (delete + insert) and the invoice PATCH update are now inside one db.transaction() block. If the invoice update fails after splits are written, the entire operation rolls back. Also removed unnecessary eslint-disable comment on _tipSplits. Co-Authored-By: Paperclip * fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var Co-Authored-By: Paperclip --------- Co-authored-by: Flea Flicker Co-authored-by: Paperclip Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com> --- apps/api/src/routes/invoices.ts | 105 ++++++++++++++++---------------- apps/web/src/pages/Clients.tsx | 23 ++++--- 2 files changed, 62 insertions(+), 66 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index 1527128..aaa7fb3 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -18,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js"; export const invoicesRouter = new Hono(); +// Convert Zod validation errors from 422 to 400 +invoicesRouter.onError((err, c) => { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation failed", issues: err.issues }, 400); + } + throw err; +}); + const createInvoiceSchema = z.object({ appointmentId: z.string().uuid().optional(), clientId: z.string().uuid(), @@ -341,30 +349,23 @@ invoicesRouter.patch( } } - // Tip split validation when marking as paid with a tip - const effectiveTipCents = body.tipCents ?? current.tipCents; - if (body.status === "paid" && effectiveTipCents > 0) { - if (body.tipSplits !== undefined) { - if (body.tipSplits.length === 0) { - return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422); - } - const totalBps = body.tipSplits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); - if (totalBps !== 10000) { - return c.json({ error: "Split percentages must sum to 100" }, 422); - } - } else { - const existingSplits = await db - .select({ id: invoiceTipSplits.id }) - .from(invoiceTipSplits) - .where(eq(invoiceTipSplits.invoiceId, id)); - if (existingSplits.length === 0) { - return c.json({ error: "Tip splits required when tip amount is greater than zero" }, 422); - } + const tipCents = body.tipCents ?? current.tipCents; + + // Validate tip splits when marking invoice as paid + if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) { + if (body.tipSplits.length === 0) { + return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400); + } + const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0); + if (Math.abs(totalPct - 100) > 0.01) { + return c.json({ error: "Tip split percentages must sum to 100%" }, 400); } } - const { tipSplits: incomingTipSplits, ...bodyWithoutSplits } = body; - const update: Record = { ...bodyWithoutSplits, updatedAt: new Date() }; + // Destructure tipSplits out — it belongs to a separate table, not the invoices column + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { tipSplits: _tipSplits, ...updateBody } = body as Record; + const update: Record = { ...updateBody, updatedAt: new Date() }; // Auto-set paidAt when marking as paid if (body.status === "paid" && !body.paidAt && !current.paidAt) { @@ -378,47 +379,43 @@ invoicesRouter.patch( update.totalCents = current.subtotalCents + newTaxCents + newTipCents; } - const [updated] = await db.transaction(async (tx) => { - const [upd] = await tx + // Wrap tip split persistence and invoice update in a single atomic transaction + const [updated, lineItems] = await db.transaction(async (tx) => { + if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) { + await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + const splits = body.tipSplits; + if (splits.length > 0) { + let remaining = tipCents; + const rows = splits.map((s, i) => { + const isLast = i === splits.length - 1; + const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents); + if (!isLast) remaining -= shareCents; + return { + invoiceId: id, + staffId: s.staffId, + staffName: s.staffName, + sharePct: s.sharePct.toFixed(2), + shareCents, + }; + }); + await tx.insert(invoiceTipSplits).values(rows); + } + } + + const [updatedInvoice] = await tx .update(invoices) .set(update) .where(eq(invoices.id, id)) .returning(); - // Atomically save tip splits when marking paid with provided splits - if ( - body.status === "paid" && - effectiveTipCents > 0 && - incomingTipSplits !== undefined && - incomingTipSplits.length > 0 - ) { - await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)); + const lineItems = await tx + .select() + .from(invoiceLineItems) + .where(eq(invoiceLineItems.invoiceId, id)); - let remaining = effectiveTipCents; - const rows = incomingTipSplits.map((s, i) => { - const isLast = i === incomingTipSplits.length - 1; - const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * effectiveTipCents); - if (!isLast) remaining -= shareCents; - return { - invoiceId: id, - staffId: s.staffId, - staffName: s.staffName, - sharePct: s.sharePct.toFixed(2), - shareCents, - }; - }); - - await tx.insert(invoiceTipSplits).values(rows); - } - - return [upd]; + return [updatedInvoice, lineItems]; }); - const lineItems = await db - .select() - .from(invoiceLineItems) - .where(eq(invoiceLineItems.invoiceId, id)); - return c.json({ ...updated, lineItems }); } ); diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 8f89d52..af4b47b 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useRef, useId } from "react"; import { useSearchParams } from "react-router-dom"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; @@ -647,8 +647,7 @@ export function ClientsPage() { {/* ── Client modal ── */} {showClientForm && ( - setShowClientForm(false)}> -

{editingClient ? "Edit Client" : "New Client"}

+ setShowClientForm(false)}>
setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -678,8 +677,7 @@ export function ClientsPage() { {/* ── Pet modal ── */} {showPetForm && ( - setShowPetForm(false)}> -

{editingPet ? "Edit Pet" : "Add Pet"}

+ setShowPetForm(false)}> setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} /> @@ -753,8 +751,7 @@ export function ClientsPage() { {/* ── Visit log modal ── */} {showLogForm && logPetId && ( - setShowLogForm(false)}> -

Log Grooming Visit

+ setShowLogForm(false)}> {logsLoading[logPetId] &&

Loading history…

} {visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
@@ -817,8 +814,7 @@ export function ClientsPage() { {/* ── Delete confirmation modal ── */} {showDeleteConfirm && selectedClient && ( - setShowDeleteConfirm(false)}> -

Permanently Delete Client

+ setShowDeleteConfirm(false)}>

This will permanently delete {selectedClient.name} and all their pets. This action cannot be undone.

@@ -856,7 +852,8 @@ export function ClientsPage() { // ─── Shared UI ─────────────────────────────────────────────────────────────── -function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { +function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) { + const titleId = useId(); const modalRef = useRef(null); useEffect(() => { @@ -898,15 +895,17 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () = return (
{ if (e.target === e.currentTarget) onClose(); }} >
+

{title}

{children}
-- 2.52.0 From c76ea93c297f67c63bb7221df469dc94d80864bd Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 24 Apr 2026 16:18:48 +0000 Subject: [PATCH 2/3] fix(GRO-818): refund button for all paid invoices, inline cardLast4, manual refund for non-Stripe - Backend refund endpoint: allow refunds on paid invoices without stripePaymentIntentId (manual refund path) - Backend GET /invoices/:id: inline fetch cardLast4 + paymentStatus from Stripe when stripePaymentIntentId present - Frontend: show Refund button on all paid invoices for managers (not just Stripe-backed ones) - Seed: add stripePaymentIntentId (pi_test_*) to ~20% of paid invoices for Stripe-path testing cc @cpfarhood --- apps/api/src/routes/invoices.ts | 31 +++++++++++++++++++++++-------- apps/web/src/pages/Invoices.tsx | 2 +- packages/db/src/seed.ts | 5 ++++- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts index a5c0d95..91ac4ee 100644 --- a/apps/api/src/routes/invoices.ts +++ b/apps/api/src/routes/invoices.ts @@ -130,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => { db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), ]); - return c.json({ ...invoice, lineItems, tipSplits }); + 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({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus }); }); // Save tip splits for an invoice (replaces existing splits) @@ -450,9 +460,6 @@ invoicesRouter.post( if (invoice.status !== "paid") { return c.json({ error: "Refund only allowed on paid invoices" }, 422); } - if (!invoice.stripePaymentIntentId) { - return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); - } return await db.transaction(async (tx) => { if (body.idempotencyKey) { @@ -465,17 +472,25 @@ invoicesRouter.post( } } - const result = await processRefund(id, body.amountCents); - if (!result) return c.json({ error: "Refund failed" }, 500); + let refundId: string; + + if (invoice.stripePaymentIntentId) { + const result = await processRefund(id, body.amountCents); + if (!result) return c.json({ error: "Refund failed" }, 500); + refundId = result.refundId; + } else { + // Manual refund — no Stripe call needed + refundId = `manual_${id}_${Date.now()}`; + } await tx.insert(refunds).values({ invoiceId: id, - stripeRefundId: result.refundId, + stripeRefundId: refundId, idempotencyKey: body.idempotencyKey ?? null, amountCents: body.amountCents ?? null, }); - return c.json({ refundId: result.refundId }); + return c.json({ refundId }); }); } ); diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index 246a9be..0eceb4c 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -487,7 +487,7 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
)}
- {invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && isManager && ( + {invoice.status === "paid" && !invoice.stripeRefundId && isManager && ( diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index dca21d4..4563ce7 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -978,6 +978,7 @@ async function seed() { const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null; + const stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null; invoiceBatch.push({ id: invoiceId, appointmentId: apptId, @@ -989,6 +990,7 @@ async function seed() { status: invoiceStatus, paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, paidAt, + stripePaymentIntentId, notes: rand() < 0.05 ? "Added extra service at checkout" : null, }); @@ -1092,13 +1094,14 @@ async function seed() { const taxCents = Math.round(effectivePrice * 0.08); const totalCents = effectivePrice + taxCents + tipCents; const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); + const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null; invoiceBatch.push({ id: invoiceId, appointmentId: apptId, clientId, subtotalCents: effectivePrice, taxCents, tipCents, totalCents, status: "paid" as const, paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", - paidAt, notes: null, + paidAt, stripePaymentIntentId, notes: null, }); lineItemBatch.push({ id: uuid(), invoiceId, description: svc.name, quantity: 1, -- 2.52.0 From a7bcce8b8048fbbd11958870e3a8e72b7fefdce7 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 17:44:10 +0000 Subject: [PATCH 3/3] fix(GRO-887): wire OIDC + BETTER_AUTH env vars into API deployment (#369) Wire BETTER_AUTH_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, BETTER_AUTH_SECRET into API deployment. Add conditional OIDC_INTERNAL_BASE env var. Add new values betterAuthUrl + internalBaseUrl in values.yaml. Add authSecretName helper. Cherry-picked from e26718b (original GRO-898 fix). Co-authored-by: Paperclip Co-authored-by: Paperclip --- charts/groombook/templates/_helpers.tpl | 7 +++++++ .../groombook/templates/api-deployment.yaml | 21 +++++++++++++++++++ charts/groombook/values.yaml | 2 ++ 3 files changed, 30 insertions(+) diff --git a/charts/groombook/templates/_helpers.tpl b/charts/groombook/templates/_helpers.tpl index 9c97648..93f19ad 100644 --- a/charts/groombook/templates/_helpers.tpl +++ b/charts/groombook/templates/_helpers.tpl @@ -119,3 +119,10 @@ uri database-url {{- end -}} {{- end }} + +{{/* +Auth secret name — always use groombook-auth (sealed secret name) +*/}} +{{- define "groombook.authSecretName" -}} +{{- printf "%s" "groombook-auth" }} +{{- end }} diff --git a/charts/groombook/templates/api-deployment.yaml b/charts/groombook/templates/api-deployment.yaml index aaee7b0..6283210 100644 --- a/charts/groombook/templates/api-deployment.yaml +++ b/charts/groombook/templates/api-deployment.yaml @@ -50,6 +50,27 @@ spec: - name: OIDC_AUDIENCE value: {{ .Values.api.env.oidcAudience | quote }} {{- end }} + {{- if .Values.api.env.internalBaseUrl }} + - name: OIDC_INTERNAL_BASE + value: {{ .Values.api.env.internalBaseUrl | quote }} + {{- end }} + - name: BETTER_AUTH_URL + value: {{ .Values.api.env.betterAuthUrl | quote }} + - name: OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ include "groombook.authSecretName" . }} + key: OIDC_CLIENT_ID + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "groombook.authSecretName" . }} + key: OIDC_CLIENT_SECRET + - name: BETTER_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ include "groombook.authSecretName" . }} + key: BETTER_AUTH_SECRET - name: DATABASE_URL valueFrom: secretKeyRef: diff --git a/charts/groombook/values.yaml b/charts/groombook/values.yaml index 5f888a5..0e85682 100644 --- a/charts/groombook/values.yaml +++ b/charts/groombook/values.yaml @@ -18,6 +18,8 @@ api: corsOrigin: "" oidcIssuer: "" oidcAudience: groombook + betterAuthUrl: "" + internalBaseUrl: "" port: "3000" service: type: ClusterIP -- 2.52.0