Compare commits

..

3 Commits

Author SHA1 Message Date
Test User f362aa61b4 fix: allow groomer role to access invoices endpoint
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 00:24:45 +00:00
Test User 4e9abd793d 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 <noreply@paperclip.ing>
2026-04-19 00:17:42 +00:00
Pawla Abdul 0019511061 fix(e2e): use domcontentloaded instead of networkidle in admin invoices test
The networkidle wait causes flakiness in CI due to slow external resource loading.
Use domcontentloaded which fires earlier and is sufficient for SPA navigation checks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 16:43:45 +00:00
20 changed files with 276 additions and 224 deletions
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook OIDC_AUDIENCE=groombook
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
# database has data but the setup wizard would otherwise block access.
SKIP_OOBE=false
# ── API ─────────────────────────────────────────────────────────────────────── # ── API ───────────────────────────────────────────────────────────────────────
PORT=3000 PORT=3000
CORS_ORIGIN=http://localhost:8080 CORS_ORIGIN=http://localhost:8080
-42
View File
@@ -418,48 +418,6 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
expect(body.showAuthProviderStep).toBe(false); // DB config already exists expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true); expect(body.authConfigExists).toBe(true);
}); });
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
dbStaffRows = []; // no super user
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "true";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.showAuthProviderStep).toBe(false);
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=1 also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "1";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=yes also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "yes";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
}); });
describe("POST /setup/auth-provider — OOBE bootstrap", () => { describe("POST /setup/auth-provider — OOBE bootstrap", () => {
+2 -8
View File
@@ -105,13 +105,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes // Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths // authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono(); const authRouter = new Hono();
authRouter.all("/*", (c) => { authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
api.route("/auth", authRouter); api.route("/auth", authRouter);
// ── Role guards ──────────────────────────────────────────────────────────────── // ── Role guards ────────────────────────────────────────────────────────────────
@@ -123,7 +117,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
api.use("/admin/*", requireRoleOrSuperUser("manager")); api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/admin/settings/*", requireSuperUser()); api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager")); api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager", "groomer"));
api.use("/impersonation/*", requireRole("manager")); api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist // Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
+4 -15
View File
@@ -90,12 +90,6 @@ export async function initAuth(): Promise<void> {
database: drizzleAdapter(getDb(), { provider: "pg" }), database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod", secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -176,6 +170,8 @@ export async function initAuth(): Promise<void> {
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
// Build Better-Auth instance using resolved config // Build Better-Auth instance using resolved config
authInstance = betterAuth({ authInstance = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -183,15 +179,6 @@ export async function initAuth(): Promise<void> {
}), }),
secret: BETTER_AUTH_SECRET, secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
account: {
storeStateStrategy: "cookie" as const,
},
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -218,12 +205,14 @@ export async function initAuth(): Promise<void> {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID!, clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: `${callbackBase}/google`,
}, },
} : {}), } : {}),
...(hasGitHub ? { ...(hasGitHub ? {
github: { github: {
clientId: process.env.GITHUB_CLIENT_ID!, clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!, clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectURI: `${callbackBase}/github`,
}, },
} : {}), } : {}),
}, },
+2 -8
View File
@@ -23,6 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
} }
export const authMiddleware: MiddlewareHandler = async (c, next) => { export const authMiddleware: MiddlewareHandler = async (c, next) => {
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) { if (c.req.path.startsWith("/api/auth/")) {
await next(); await next();
return; return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return; return;
} }
let auth; const session = await getAuth().api.getSession({
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
headers: c.req.raw.headers, headers: c.req.raw.headers,
}); });
+20 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db"; import { and, eq, getDb, isNull, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect; export type StaffRow = typeof staff.$inferSelect;
@@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff) .from(staff)
.where(eq(staff.oidcSub, jwt.sub)); .where(eq(staff.oidcSub, jwt.sub));
if (!fallbackRow) { if (!fallbackRow) {
// Auto-link: staff record exists with matching email but no userId — link it now
if (jwt.email) {
const [linkedStaff] = await db
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
if (linkedStaff) {
await db
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, linkedStaff.id));
console.log(
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
);
c.set("staff", linkedStaff);
await next();
return;
}
}
return c.json( return c.json(
{ error: "Forbidden: no staff record found for authenticated user" }, { error: "Forbidden: no staff record found for authenticated user" },
403 403
+66
View File
@@ -338,3 +338,69 @@ invoicesRouter.patch(
return c.json({ ...updated, lineItems }); return c.json({ ...updated, lineItems });
} }
); );
// Issue a refund on a paid invoice (Stripe integration placeholder)
const refundSchema = z.object({
amountCents: z.number().int().positive().optional(), // omitting = full refund
});
invoicesRouter.post(
"/:id/refund",
zValidator("json", refundSchema),
async (c) => {
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
if (invoice.status !== "paid") return c.json({ error: "Can only refund paid invoices" }, 422);
const refundAmount = body.amountCents ?? invoice.totalCents;
// TODO: Integrate Stripe here
// const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// await stripe.refunds.create({ payment_intent: invoice.stripePaymentIntentId, amount: refundAmount });
// For now, log and mark as refunded in a future version
return c.json({ message: "Refund endpoint ready — Stripe integration pending", refundAmount, status: "pending" });
}
);
// 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<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "paid"));
const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "pending"));
const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(tip_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "paid"));
const methodBreakdown = await db
.select({
method: invoices.paymentMethod,
total: sql<number>`count(*)`,
})
.from(invoices)
.where(eq(invoices.status, "paid"))
.groupBy(invoices.paymentMethod);
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown,
});
});
-11
View File
@@ -9,17 +9,6 @@ export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed // GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown // and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => { setupRouter.get("/status", async (c) => {
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
if (skipOobe) {
return c.json({
needsSetup: false,
showAuthProviderStep: false,
authConfigExists: false,
authEnvVarsSet: false,
skipped: true,
});
}
const db = getDb(); const db = getDb();
// Check if any super user exists // Check if any super user exists
-30
View File
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true }); const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => { staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff"); const staffRow = c.get("staff");
return c.json(staffRow); return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
return c.json(row); return c.json(row);
}); });
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
const db = getDb();
const targetId = c.req.param("id");
const body = c.req.valid("json");
const currentStaff = c.get("staff");
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
}
const [existing] = await db
.select()
.from(staff)
.where(eq(staff.id, targetId))
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
const [updated] = await db
.update(staff)
.set({ userId: body.userId, updatedAt: new Date() })
.where(eq(staff.id, targetId))
.returning();
return c.json(updated);
});
staffRouter.delete("/:id", async (c) => { staffRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
@@ -3,13 +3,13 @@ kind: Kustomization
namespace: groombook-uat namespace: groombook-uat
images: images:
- name: ghcr.io/groombook/api - name: ghcr.io/groombook/api
newTag: "2026.04.12-15131b7" newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/web - name: ghcr.io/groombook/web
newTag: "2026.04.12-15131b7" newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/migrate - name: ghcr.io/groombook/migrate
newTag: "2026.04.12-15131b7" newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/seed - name: ghcr.io/groombook/seed
newTag: "2026.04.12-15131b7" newTag: "2026.04.03-90be1be"
resources: resources:
- ../../base - ../../base
- postgres-sealed-secret.yaml - postgres-sealed-secret.yaml
+1 -1
View File
@@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"@groombook/types": "workspace:*", "@groombook/types": "workspace:*",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6", "better-auth": "^1.0.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
+36 -80
View File
@@ -1,4 +1,4 @@
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom"; import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js"; import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js"; import { ClientsPage } from "./pages/Clients.js";
@@ -18,31 +18,22 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js"; import { GlobalSearch } from "./components/GlobalSearch.js";
import { useSession, signIn, signOut } from "./lib/auth-client.js"; import { useSession, signIn } from "./lib/auth-client.js";
function LoginPage() { function LoginPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]); const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/auth/providers") fetch("/api/auth/providers")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setProviders(data.providers ?? [])) .then((data) => setProviders(data.providers ?? []))
.catch(() => setProviders([])); .catch(() => setProviders([]));
const params = new URLSearchParams(window.location.search);
const authError = params.get("error");
if (authError) setError(authError.replace(/_/g, " "));
}, []); }, []);
const handleSocialLogin = async (provider: string) => { const handleSocialLogin = async (provider: string) => {
setIsLoading(true); setIsLoading(true);
setError(null); await signIn.social({ provider, callbackURL: window.location.origin });
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
}
}; };
const isGoogle = providers.includes("google"); const isGoogle = providers.includes("google");
@@ -74,11 +65,6 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}> <p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue Sign in to continue
</p> </p>
{error && (
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
{error}
</div>
)}
{isGoogle && ( {isGoogle && (
<button <button
onClick={() => handleSocialLogin("google")} onClick={() => handleSocialLogin("google")}
@@ -181,7 +167,6 @@ const NAV_LINKS = [
function AdminLayout() { function AdminLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding(); const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +195,6 @@ function AdminLayout() {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
marginRight: "1.25rem", marginRight: "1.25rem",
flexShrink: 0,
}}> }}>
{logoSrc && ( {logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} /> <img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +208,45 @@ function AdminLayout() {
</strong> </strong>
</div> </div>
<GlobalSearch /> <GlobalSearch />
<div style={{ <Link
display: "flex", to="/admin/book"
overflowX: "auto",
flex: 1,
minWidth: 0,
gap: "0.25rem",
}}>
<Link
to="/admin/book"
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
flexShrink: 0,
}}
>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
flexShrink: 0,
}}
>
{label}
</Link>
);
})}
</div>
<button
onClick={async () => {
await signOut();
navigate("/login");
}}
style={{ style={{
flexShrink: 0,
padding: "0.4rem 0.85rem", padding: "0.4rem 0.85rem",
borderRadius: 6, borderRadius: 6,
border: "1px solid #e2e8f0", textDecoration: "none",
background: "#fff",
color: "#4b5563",
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 600,
cursor: "pointer", color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}} }}
> >
Logout Book
</button> </Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
}}
>
{label}
</Link>
);
})}
</nav> </nav>
<main style={{ padding: "1.25rem 1.5rem" }}> <main style={{ padding: "1.25rem 1.5rem" }}>
<Routes> <Routes>
+135 -1
View File
@@ -173,6 +173,9 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash"); const [paymentMethod, setPaymentMethod] = useState<string>(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} // Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId const linkedAppt = invoice.appointmentId
@@ -271,6 +274,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 <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>; if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
@@ -447,10 +479,76 @@ function InvoiceDetailModal({
</div> </div>
)} )}
{(invoice.status === "paid" || invoice.status === "void") && ( {(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}> <div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
<button
onClick={() => setShowRefundDialog(true)}
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button> <button onClick={onClose} style={btnStyle}>Close</button>
</div> </div>
)} )}
{/* Refund Dialog */}
{showRefundDialog && (
<Modal onClose={() => setShowRefundDialog(false)}>
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
</p>
<div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
<input
type="radio"
name="refundType"
value="full"
checked={refundType === "full"}
onChange={() => setRefundType("full")}
/>
Full refund
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "1rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="0.00"
value={partialAmount}
onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 120 }}
/>
</div>
)}
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button
onClick={issueRefund}
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
>
{saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button>
</div>
</Modal>
)}
</Modal> </Modal>
); );
} }
@@ -492,9 +590,17 @@ export function InvoicesPage() {
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null); const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false); 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; 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) { async function loadInvoices(newOffset: number) {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) }); const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (statusFilter) params.set("status", statusFilter); if (statusFilter) params.set("status", statusFilter);
@@ -573,6 +679,34 @@ export function InvoicesPage() {
</button> </button>
</div> </div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
</div>
{paymentStats.methodBreakdown.length > 0 && (
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
<div style={{ fontSize: 13, color: "#64748b" }}>
{paymentStats.methodBreakdown.map((b) => (
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
))}
</div>
</div>
)}
</div>
)}
{invoiceList.length === 0 ? ( {invoiceList.length === 0 ? (
<p style={{ color: "#6b7280" }}> <p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment. No invoices yet. Create one from a completed appointment.
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: { workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [ navigateFallbackDenylist: [
/^\/api\/auth\//, /^\/api\/auth\/oauth2\/callback\//,
], ],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^http.*\/api\/(?!auth\/).*/i, urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst", handler: "NetworkFirst",
options: { options: {
cacheName: "api-cache", cacheName: "api-cache",
+1 -1
Submodule infra updated: d6c0d13d02...49575eb4f6
@@ -1,6 +0,0 @@
-- Better-Auth rate limiting table (GRO-574)
CREATE TABLE "rate_limit" (
key TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL,
last_request BIGINT NOT NULL
);
@@ -176,13 +176,6 @@
"when": 1775396067192, "when": 1775396067192,
"tag": "0024_invoice_indexes", "tag": "0024_invoice_indexes",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -251,6 +251,7 @@ export const invoices = pgTable(
status: invoiceStatusEnum("status").notNull().default("draft"), status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"), paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"), paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
+1
View File
@@ -150,6 +150,7 @@ export interface Invoice {
status: InvoiceStatus; status: InvoiceStatus;
paymentMethod: PaymentMethod | null; paymentMethod: PaymentMethod | null;
paidAt: string | null; paidAt: string | null;
stripePaymentIntentId: string | null;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
+1 -1
View File
@@ -87,7 +87,7 @@ importers:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
better-auth: better-auth:
specifier: ^1.5.6 specifier: ^1.0.0
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
lucide-react: lucide-react:
specifier: ^0.577.0 specifier: ^0.577.0