Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 3d45582609 fix(GRO-874): add requireSuperUser() to GET /api/admin/settings/logo
The logo proxy route was missing auth middleware, allowing any
unauthenticated caller to receive the presigned S3 URL and exposing
the internal Ceph RGW hostname. Matches auth pattern used by all
other /api/admin/* routes in this file.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 03:42:29 +00:00
12 changed files with 351 additions and 253 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ jobs:
name: Update Infra Image Tags name: Update Infra Image Tags
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [docker] needs: [docker]
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
+213 -38
View File
@@ -1,43 +1,218 @@
# GroomBook Monorepo — Archived # GroomBook
> **This repository has been archived and replaced by standalone repositories.** > **The open-source scheduling and client management platform built specifically for independent pet groomers** — giving you the tools of enterprise software without the enterprise price tag or vendor lock-in.
## Successor Repositories **Built for groomers, not corporations.**
| Repository | Description |
|---|---|
| [groombook/api](https://github.com/groombook/api) | Hono REST API (TypeScript, Node.js) |
| [groombook/web](https://github.com/groombook/web) | React PWA frontend |
| [groombook/charts](https://github.com/groombook/charts) | Helm charts for Kubernetes deployment |
## What Changed
- **Monorepo split complete** — The former `apps/api`, `apps/web`, and `packages/*` are now standalone repos
- **`@groombook/types`** — Inlined directly into `groombook/api` and `groombook/web`
- **E2E testing** — Now via Playwright MCP, no standalone repo needed
- **CI/CD** — Each repo has its own pipeline; see individual repos for status
## Migration Notes
If you were cloning `groombook/groombook` for local development:
```bash
# API
git clone https://github.com/groombook/api.git
cd api && pnpm install && pnpm dev
# Web (in a new terminal)
git clone https://github.com/groombook/web.git
cd web && pnpm install && pnpm dev
```
For full Docker Compose setup, see each repo's README.
## Archive Info
This repository was archived on 2026-05-14 as part of the monorepo decommission ([GRO-1081]).
The history is preserved but the repo is read-only.
--- ---
*For Kubernetes deployments, see [groombook/infra](https://github.com/groombook/infra) (private).* ## Key Features
**Stop chasing confirmations**
- **Customer portal** — Clients confirm or cancel appointments on their own. Reduce no-shows with an automated waitlist.
**Your calendar, your way**
- **iCal calendar feed** — Push GroomBook appointments directly into Google Calendar or Apple Calendar. No app switching.
**Know every pet at a glance**
- **Client & pet records** — Detailed profiles with grooming history, preferences, and breed-specific notes. Full appointment notes for context on every regular.
- **Quick-find search** — Find clients and pets instantly without digging through spreadsheets.
**Staff access without stress**
- **Role-based access control (RBAC)** — Front desk sees bookings; only you see financials. Right access for every role.
**Everything else**
- **Appointment scheduling** — Calendar management for single or multiple groomers
- **Service management** — Pricing, duration, and service catalog
- **POS & invoicing** — Payments, tips, and receipt generation
- **Automated reminders** — SMS and email notifications
- **Reporting dashboard** — Revenue, utilization, and trend analytics
- **Staff impersonation** — Managers can view the customer portal as any client, with full audit logging and session controls
- **PWA** — Installable on mobile devices, works offline
---
## 🚀 Try the Demo
[**Live Demo**](https://demo.groombook.app) — explore GroomBook without installing anything.
---
## Quick Start
### Docker Compose (recommended for indie groomers)
Run GroomBook on your own hardware in minutes. Everything you need is in the box — no subscription, no vendor lock-in.
```bash
git clone https://github.com/groombook/groombook.git
cd groombook
# Start everything (Postgres + database migrations + API + web UI)
docker compose up --build
```
- **Web UI**: http://localhost:8080
- **API**: http://localhost:3000
The default `docker-compose.yml` sets `AUTH_DISABLED=true` so you can explore the app without configuring an OIDC provider. **Important:** Disable this in any internet-facing deployment.
---
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | [Hono](https://hono.dev/) (TypeScript, Node.js) |
| Frontend | React 19 + Vite + [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) |
| Database | PostgreSQL via [CNPG](https://cloudnative-pg.io/) + [Drizzle ORM](https://orm.drizzle.team/) |
| Auth | OIDC via [Authentik](https://goauthentik.io/) |
| Infra | Kubernetes (namespace: `groombook`), Flux GitOps |
| CI | GitHub Actions (self-hosted `groombook-runners`) |
## Repository Structure
```
groombook/
├── apps/
│ ├── api/ # Hono REST API
│ └── web/ # React PWA
├── packages/
│ ├── db/ # Drizzle schema + migrations
│ └── types/ # Shared TypeScript types
├── .github/
│ └── workflows/ # CI/CD pipelines
└── docker-compose.yml
```
## Getting Started
### Prerequisites
- Node.js >= 20
- pnpm >= 9 (`npm install -g pnpm`)
- Docker & Docker Compose (for local Postgres)
### Local Development
```bash
# Clone the repo
git clone https://github.com/groombook/groombook.git
cd groombook
# Install dependencies
pnpm install
# Start local Postgres
docker compose up postgres -d
# Run database migrations
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook pnpm db:migrate
# Start API and Web in parallel
pnpm dev
```
API will be available at http://localhost:3000
Web will be available at http://localhost:5173
### Environment Variables
#### API (`apps/api/.env`)
```env
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
CORS_ORIGIN=http://localhost:5173
PORT=3000
```
### Running Tests
```bash
# Unit tests (vitest)
pnpm test
# E2E tests (Playwright) — requires the full Docker Compose stack to be running
docker compose up -d --wait
pnpm --filter @groombook/e2e test
# Open the Playwright UI (interactive test runner)
pnpm --filter @groombook/e2e test:ui
# View the last E2E test report
pnpm --filter @groombook/e2e test:report
```
E2E tests target the Docker Compose stack (`http://localhost:8080`). They use API route mocking where needed so happy-path tests are deterministic without requiring seed data.
### Building
```bash
pnpm build
```
## Self-Hosting
### Production Configuration
Copy `.env.example` to `.env` and configure:
```bash
cp .env.example .env
```
Key variables to update for production:
| Variable | Description |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `AUTH_DISABLED` | Set to `false` in production |
| `OIDC_ISSUER` | Authentik issuer URL |
| `OIDC_AUDIENCE` | OAuth2 audience (default: `groombook`) |
| `CORS_ORIGIN` | Public URL of the web frontend |
To use your `.env` file with Docker Compose:
```bash
docker compose --env-file .env up --build
```
### Kubernetes (production-grade deployments)
See the [groombook/infra](https://github.com/groombook/infra) repository for Kubernetes manifests and Flux configuration.
Groom Book is deployed in the `groombook` Kubernetes namespace using:
- **CNPG** for PostgreSQL
- **Authentik** for OIDC authentication
- **Flux** for GitOps-managed deployments
---
## Contributing
GroomBook thrives on contributions from the grooming community. Whether you're a groomer with a feature request, a developer fixing a bug, or someone improving docs — we'd love your help.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/my-feature`)
3. Commit your changes
4. Open a pull request
All PRs require CI to pass before merge. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
---
## Why GroomBook?
- **Open source** — You own your data. No vendor lock-in.
- **Purpose-built** — Features designed for grooming workflows, not generic scheduling.
- **Self-hosted or managed** — Run it yourself for free, or pay for hosted support (coming soon).
- **Community-driven** — Used and built by actual groomers.
---
## License
AGPL-3.0
+37 -64
View File
@@ -101,8 +101,6 @@ invoicesRouter.get(
paymentMethod: invoices.paymentMethod, paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt, paidAt: invoices.paidAt,
notes: invoices.notes, notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt, updatedAt: invoices.updatedAt,
}) })
@@ -130,17 +128,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]); ]);
let cardLast4: string | null = null; return c.json({ ...invoice, lineItems, tipSplits });
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) // Save tip splits for an invoice (replaces existing splits)
@@ -460,6 +448,9 @@ invoicesRouter.post(
if (invoice.status !== "paid") { if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422); 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) => { return await db.transaction(async (tx) => {
if (body.idempotencyKey) { if (body.idempotencyKey) {
@@ -472,75 +463,57 @@ invoicesRouter.post(
} }
} }
let refundId: string; const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
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({ await tx.insert(refunds).values({
invoiceId: id, invoiceId: id,
stripeRefundId: refundId, stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null, idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null, amountCents: body.amountCents ?? null,
}); });
return c.json({ refundId }); return c.json({ refundId: result.refundId });
}); });
} }
); );
// Payment stats for admin dashboard // Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", async (c) => { invoicesRouter.get("/stats/summary", async (c) => {
try { const db = getDb();
const db = getDb(); const now = new Date();
const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices) .from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices) .from(invoices)
.where(eq(invoices.status, "pending")); .where(eq(invoices.status, "pending"));
const [refundsResult] = await db const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds) .from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`); .where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db const methodBreakdown = await db
.select({ .select({
method: invoices.paymentMethod, method: invoices.paymentMethod,
total: sql<number>`count(*)`, total: sql<number>`count(*)`,
}) })
.from(invoices) .from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)) .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod); .groupBy(invoices.paymentMethod);
return c.json({ return c.json({
revenueThisMonth: revenueResult?.total ?? 0, revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0, outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0, refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown, 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) // Get Stripe payment details for an invoice (card last4, payment status, refund status)
+1 -1
View File
@@ -218,7 +218,7 @@ settingsRouter.post(
* Proxies the logo from S3 so the browser never sees an S3 URL. * Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type. * Returns the image bytes with proper Content-Type.
*/ */
settingsRouter.get("/logo", async (c) => { settingsRouter.get("/logo", requireSuperUser(), async (c) => {
const db = getDb(); const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1); const [row] = await db.select().from(businessSettings).limit(1);
-26
View File
@@ -112,17 +112,9 @@ export function AppointmentsPage() {
const [viewMode, setViewMode] = useState<"status" | "groomer">("status"); const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
// null key = unassigned; staffId string = that groomer; undefined set = all visible // null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set()); const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(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); 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 loadAppointments = useCallback(() => {
const from = weekStart.toISOString(); const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString(); const to = addDays(weekStart, 7).toISOString();
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
</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" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</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" }}>${(paymentStats.outstanding / 100).toFixed(2)}</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" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
</div>
</div>
)}
{/* ── View Mode + Groomer Filters ── */} {/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span> <span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
+96 -81
View File
@@ -173,21 +173,22 @@ 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 [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full"); const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [refundAmount, setRefundAmount] = useState(""); const [partialAmount, setPartialAmount] = useState("");
const [refundError, setRefundError] = useState<string | null>(null); const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
const [refunding, setRefunding] = useState(false);
// Fetch current staff role to determine manager access // Fetch Stripe details when modal opens for paid invoices with a payment intent
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/staff/me") if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
.then((r) => r.json()) fetch(`/api/invoices/${invoice.id}/stripe-details`)
.then((d) => setStaffMe(d)) .then((r) => r.ok ? r.json() : null)
.catch(() => setStaffMe(null)); .then((data) => { if (data) setStripeDetails(data); })
}, []); .catch(() => {});
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser); } else {
setStripeDetails(null);
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct} // Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId const linkedAppt = invoice.appointmentId
@@ -291,6 +292,35 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
} }
} }
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;
@@ -350,15 +380,15 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/> />
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />} {invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />} {invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{invoice.stripePaymentIntentId && ( {stripeDetails && (
<> <>
{invoice.cardLast4 && ( {stripeDetails.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} /> <SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
)} )}
{invoice.paymentStatus && ( {stripeDetails.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} /> <SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
)} )}
{invoice.stripeRefundId && ( {stripeDetails.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" /> <SummaryRow label="Refund" value="Refunded" />
)} )}
</> </>
@@ -480,92 +510,77 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div> </div>
)} )}
{(invoice.status === "paid" || invoice.status === "void") && ( {(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}> <div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.stripeRefundId && ( {invoice.status === "paid" && invoice.stripePaymentIntentId && (
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}> <button
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span> onClick={() => setShowRefundDialog(true)}
</div> style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)} )}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}> <button onClick={onClose} style={btnStyle}>Close</button>
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
</div> </div>
)} )}
{/* Refund Dialog */}
{showRefundDialog && ( {showRefundDialog && (
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}> <Modal onClose={() => setShowRefundDialog(false)}>
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p> <h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}> <p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}> Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} /> </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 Full refund
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}> <label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} /> <input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund Partial refund
</label> </label>
</div> </div>
{refundType === "partial" && ( {refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}> <div style={{ marginBottom: "1rem" }}>
<input <input
type="number" type="number"
min="0.01" min="0.01"
step="0.01" step="0.01"
placeholder="Amount ($)" placeholder="0.00"
value={refundAmount} value={partialAmount}
onChange={(e) => setRefundAmount(e.target.value)} onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }} style={{ ...inputStyle, width: 120 }}
/> />
</div> </div>
)} )}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>} {error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button <button
onClick={async () => { onClick={issueRefund}
setRefunding(true); disabled={saving}
setRefundError(null); style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
try {
if (refundType === "partial") {
const parsed = parseFloat(refundAmount);
if (isNaN(parsed) || parsed <= 0) {
setRefundError("Please enter a valid amount greater than zero.");
setRefunding(false);
return;
}
}
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
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) {
setRefundError(e instanceof Error ? e.message : "Refund failed");
} finally {
setRefunding(false);
}
}}
disabled={refunding}
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
> >
{refunding ? "Processing…" : "Process Refund"} {saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button> </button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div> </div>
</div> </Modal>
)} )}
</Modal> </Modal>
); );
} }
+1 -1
View File
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)} )}
{/* Main Content */} {/* Main Content */}
<main className="flex-1 min-h-screen overflow-hidden"> <main className="flex-1 min-h-screen overflow-x-hidden">
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white"> <div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
<div> <div>
<h1 className="text-lg font-semibold text-stone-800"> <h1 className="text-lg font-semibold text-stone-800">
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
<div className="flex gap-2 flex-wrap overflow-x-auto"> <div className="flex gap-2 flex-wrap">
{([ {([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard },
-7
View File
@@ -119,10 +119,3 @@ uri
database-url database-url
{{- end -}} {{- end -}}
{{- end }} {{- end }}
{{/*
Auth secret name always use groombook-auth (sealed secret name)
*/}}
{{- define "groombook.authSecretName" -}}
{{- printf "%s" "groombook-auth" }}
{{- end }}
@@ -50,27 +50,6 @@ spec:
- name: OIDC_AUDIENCE - name: OIDC_AUDIENCE
value: {{ .Values.api.env.oidcAudience | quote }} value: {{ .Values.api.env.oidcAudience | quote }}
{{- end }} {{- 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 - name: DATABASE_URL
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: "" corsOrigin: ""
oidcIssuer: "" oidcIssuer: ""
oidcAudience: groombook oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000" port: "3000"
service: service:
type: ClusterIP type: ClusterIP
+1 -10
View File
@@ -883,7 +883,6 @@ async function seed() {
let appointmentCount = 0; let appointmentCount = 0;
let invoiceCount = 0; let invoiceCount = 0;
let visitLogCount = 0; let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable // Process in batches per client to keep memory manageable
const apptBatchSize = 100; const apptBatchSize = 100;
@@ -978,10 +977,6 @@ async function seed() {
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; 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 paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
paidInvoiceCounter++;
const stripePaymentIntentId = invoiceStatus === "paid"
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
: null;
invoiceBatch.push({ invoiceBatch.push({
id: invoiceId, id: invoiceId,
@@ -994,7 +989,6 @@ async function seed() {
status: invoiceStatus, status: invoiceStatus,
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
paidAt, paidAt,
stripePaymentIntentId,
notes: rand() < 0.05 ? "Added extra service at checkout" : null, notes: rand() < 0.05 ? "Added extra service at checkout" : null,
}); });
@@ -1098,16 +1092,13 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08); const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents; const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
paidInvoiceCounter++;
invoiceBatch.push({ invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId, id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents, subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const, status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt, paidAt, notes: null,
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
notes: null,
}); });
lineItemBatch.push({ lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1, id: uuid(), invoiceId, description: svc.name, quantity: 1,