Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58232381c7 | |||
| 4fa4859eaf | |||
| ca88385b8d | |||
| 3f2769a43a | |||
| 0ed87f9ed8 | |||
| 648755eee5 | |||
| 77a6319459 | |||
| df07f2d6dc | |||
| dadabb0ea7 | |||
| d5a8b19322 | |||
| f4f522d5e6 | |||
| e8455195ee |
@@ -7,3 +7,5 @@ apps/web/dist
|
|||||||
apps/api/dist
|
apps/api/dist
|
||||||
packages/db/dist
|
packages/db/dist
|
||||||
packages/types/dist
|
packages/types/dist
|
||||||
|
.turbo
|
||||||
|
screenshots/
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -42,6 +44,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -62,6 +66,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -101,6 +107,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -238,7 +246,6 @@ jobs:
|
|||||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||||
|
|
||||||
# Run migration with PR image
|
# Run migration with PR image
|
||||||
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
|
|
||||||
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
@@ -303,6 +310,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: '9.15.4'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -409,11 +418,17 @@ jobs:
|
|||||||
|
|
||||||
git push -u origin "chore/update-image-tags-${TAG}"
|
git push -u origin "chore/update-image-tags-${TAG}"
|
||||||
|
|
||||||
# Create PR and merge immediately (no required checks on groombook/infra)
|
# Check if PR already exists for this branch
|
||||||
PR_URL=$(gh pr create \
|
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
||||||
--repo groombook/infra \
|
if [ -n "$EXISTING_PR" ]; then
|
||||||
--base main \
|
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
||||||
--head "chore/update-image-tags-${TAG}" \
|
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
||||||
--title "chore: deploy ${TAG} to dev" \
|
else
|
||||||
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
PR_URL=$(gh pr create \
|
||||||
gh pr merge "$PR_URL" --merge
|
--repo groombook/infra \
|
||||||
|
--base main \
|
||||||
|
--head "chore/update-image-tags-${TAG}" \
|
||||||
|
--title "chore: deploy ${TAG} to dev" \
|
||||||
|
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||||
|
gh pr merge "$PR_URL" --merge
|
||||||
|
fi
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
|
|||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/health || exit 1
|
||||||
CMD ["node", "apps/api/dist/index.js"]
|
CMD ["node", "apps/api/dist/index.js"]
|
||||||
|
|
||||||
# Migrate stage — runs drizzle-kit migrate against the database
|
# Migrate stage — runs drizzle-kit migrate against the database
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ describe("auth init", () => {
|
|||||||
...originalEnv,
|
...originalEnv,
|
||||||
AUTH_DISABLED: "true",
|
AUTH_DISABLED: "true",
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
BETTER_AUTH_SECRET: "placeholder-for-test-only",
|
|
||||||
};
|
};
|
||||||
|
delete process.env.BETTER_AUTH_SECRET;
|
||||||
|
|
||||||
const { initAuth, getAuth } = await reimportAuth();
|
const { initAuth, getAuth } = await reimportAuth();
|
||||||
await expect(initAuth()).resolves.toBeUndefined();
|
await expect(initAuth()).resolves.toBeUndefined();
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ const BASE_APPT = {
|
|||||||
|
|
||||||
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
let mockAppt: (typeof BASE_APPT & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
|
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
||||||
let lastUpdate: Record<string, unknown> = {};
|
let lastUpdate: Record<string, unknown> = {};
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
|
mockAppt = { ...BASE_APPT };
|
||||||
lastUpdate = {};
|
lastUpdate = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,39 +55,19 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
update: () => ({
|
update: () => ({
|
||||||
set: (vals: Record<string, unknown>) => {
|
set: (vals: Record<string, unknown>) => ({
|
||||||
const setVals = vals;
|
where: () => {
|
||||||
return {
|
lastUpdate = { ...vals };
|
||||||
where: () => {
|
if (mockAppt) {
|
||||||
const preUpdate = mockAppt ? { ...mockAppt } : null;
|
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
||||||
const preStatus = preUpdate?.confirmationStatus;
|
}
|
||||||
const preStart = preUpdate?.startTime;
|
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
||||||
lastUpdate = { ...setVals };
|
},
|
||||||
const whereMatched =
|
}),
|
||||||
preUpdate != null &&
|
|
||||||
preStatus === "pending" &&
|
|
||||||
preStart != null &&
|
|
||||||
preStart > new Date();
|
|
||||||
if (whereMatched && mockAppt) {
|
|
||||||
mockAppt = { ...mockAppt, ...setVals } as typeof BASE_APPT & { confirmationToken: string };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
returning: () => {
|
|
||||||
if (!preUpdate) return [];
|
|
||||||
if (preStatus !== "pending") return [];
|
|
||||||
if (preStart && preStart <= new Date()) return [];
|
|
||||||
return whereMatched && mockAppt ? [mockAppt] : [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
appointments,
|
appointments,
|
||||||
eq: () => ({}),
|
eq: () => ({}),
|
||||||
and: (a: unknown, b: unknown, c?: unknown) => (c ? [a, b, c] : [a, b]),
|
|
||||||
gt: () => ({}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/role 'receptionist' is not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||||
@@ -370,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/role 'groomer' is not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a manager with multiple allowed roles", async () => {
|
it("allows a manager with multiple allowed roles", async () => {
|
||||||
|
|||||||
@@ -42,23 +42,6 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// CSRF protection for state-changing requests
|
|
||||||
app.use("/api/*", async (c, next) => {
|
|
||||||
const method = c.req.method;
|
|
||||||
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const origin = c.req.header("origin");
|
|
||||||
const trustedOrigin = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
|
||||||
if (origin && origin !== trustedOrigin) {
|
|
||||||
c.status(403);
|
|
||||||
c.json({ error: "CSRF validation failed: origin mismatch" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Health check (no auth required)
|
// Health check (no auth required)
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
|
|
||||||
|
|||||||
+11
-35
@@ -86,15 +86,10 @@ export async function initAuth(): Promise<void> {
|
|||||||
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
||||||
// config so auth.handler exists (middleware bypasses it anyway)
|
// config so auth.handler exists (middleware bypasses it anyway)
|
||||||
if (process.env.AUTH_DISABLED === "true") {
|
if (process.env.AUTH_DISABLED === "true") {
|
||||||
if (!BETTER_AUTH_SECRET) {
|
|
||||||
throw new Error(
|
|
||||||
"[FATAL] BETTER_AUTH_SECRET must be set when AUTH_DISABLED=true"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET,
|
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -204,36 +199,20 @@ export async function initAuth(): Promise<void> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const validateIssuerHost = (url: string, issuerUrl: string): boolean => {
|
|
||||||
try {
|
|
||||||
const discovered = new URL(url);
|
|
||||||
const expected = new URL(issuerUrl);
|
|
||||||
return discovered.hostname === expected.hostname;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const authzUrl = discovery.authorization_endpoint;
|
const authzUrl = discovery.authorization_endpoint;
|
||||||
const tokenUrl = discovery.token_endpoint;
|
const tokenUrl = discovery.token_endpoint;
|
||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
const validAuthz = validateIssuerHost(authzUrl, providerConfig.issuerUrl);
|
oidcConfig = {
|
||||||
const validToken = validateIssuerHost(tokenUrl, providerConfig.issuerUrl);
|
authorizationUrl: authzUrl,
|
||||||
const validUserInfo = validateIssuerHost(userInfoUrl, providerConfig.issuerUrl);
|
tokenUrl: providerConfig.internalBaseUrl
|
||||||
if (!validAuthz || !validToken || !validUserInfo) {
|
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||||
console.warn("[auth] OIDC discovery URL host mismatch — possible redirection attack, rejecting");
|
: tokenUrl,
|
||||||
} else {
|
userInfoUrl: providerConfig.internalBaseUrl
|
||||||
oidcConfig = {
|
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||||
authorizationUrl: authzUrl,
|
: userInfoUrl,
|
||||||
tokenUrl: providerConfig.internalBaseUrl
|
};
|
||||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||||
: tokenUrl,
|
|
||||||
userInfoUrl: providerConfig.internalBaseUrl
|
|
||||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
|
||||||
: userInfoUrl,
|
|
||||||
};
|
|
||||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||||
}
|
}
|
||||||
@@ -308,9 +287,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
maxAge: 5 * 60, // 5 minutes
|
maxAge: 5 * 60, // 5 minutes
|
||||||
},
|
},
|
||||||
cookieAttributes: {
|
|
||||||
sameSite: "strict",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,9 +149,9 @@ export function requireRoleOrSuperUser(
|
|||||||
}
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: hasAllowedRole
|
error: staffRow.isSuperUser
|
||||||
? "Forbidden: super user privileges required"
|
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
: "Forbidden: super user privileges required",
|
||||||
},
|
},
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const appointmentGroupsRouter = new Hono();
|
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
const clientId = c.req.query("clientId");
|
const clientId = c.req.query("clientId");
|
||||||
const from = c.req.query("from");
|
const from = c.req.query("from");
|
||||||
const to = c.req.query("to");
|
const to = c.req.query("to");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const groupConditions = clientId
|
const groupConditions = clientId
|
||||||
? [eq(appointmentGroups.clientId, clientId)]
|
? [eq(appointmentGroups.clientId, clientId)]
|
||||||
@@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => !from || g.appointments.length > 0);
|
.filter((g) => !from || g.appointments.length > 0);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
return c.json(
|
||||||
|
result.filter((g) =>
|
||||||
|
g.appointments.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,6 +109,8 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
serviceId: appointments.serviceId,
|
serviceId: appointments.serviceId,
|
||||||
serviceName: services.name,
|
serviceName: services.name,
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
|
batherStaffId: appointments.batherStaffId,
|
||||||
staffName: staff.name,
|
staffName: staff.name,
|
||||||
status: appointments.status,
|
status: appointments.status,
|
||||||
startTime: appointments.startTime,
|
startTime: appointments.startTime,
|
||||||
@@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
.where(eq(appointments.groupId, id))
|
.where(eq(appointments.groupId, id))
|
||||||
.orderBy(appointments.startTime);
|
.orderBy(appointments.startTime);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isGroomer &&
|
||||||
|
!groupAppts.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select({ name: clients.name, email: clients.email })
|
.select({ name: clients.name, email: clients.email })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -140,6 +165,13 @@ appointmentGroupsRouter.post(
|
|||||||
zValidator("json", createGroupSchema),
|
zValidator("json", createGroupSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
if (staffRow?.role === "groomer") {
|
||||||
|
return c.json(
|
||||||
|
{ error: "Forbidden: groomers cannot create group bookings" },
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const startTime = new Date(body.startTime);
|
const startTime = new Date(body.startTime);
|
||||||
|
|
||||||
@@ -244,6 +276,28 @@ appointmentGroupsRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
const [group] = await db
|
||||||
|
.select({ id: appointmentGroups.id })
|
||||||
|
.from(appointmentGroups)
|
||||||
|
.where(eq(appointmentGroups.id, id));
|
||||||
|
if (!group) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const groupAppts = await db
|
||||||
|
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.groupId, id));
|
||||||
|
if (
|
||||||
|
!groupAppts.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(appointmentGroups)
|
.update(appointmentGroups)
|
||||||
@@ -261,6 +315,8 @@ appointmentGroupsRouter.patch(
|
|||||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select({ id: appointmentGroups.id })
|
.select({ id: appointmentGroups.id })
|
||||||
@@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
|||||||
.where(eq(appointmentGroups.id, id));
|
.where(eq(appointmentGroups.id, id));
|
||||||
if (!group) return c.json({ error: "Not found" }, 404);
|
if (!group) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const groupAppts = await db
|
||||||
|
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.groupId, id));
|
||||||
|
if (
|
||||||
|
!groupAppts.some(
|
||||||
|
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
|
|||||||
@@ -163,6 +163,28 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apptFields.batherStaffId) {
|
||||||
|
const bathConflicts = await tx
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, apptFields.batherStaffId),
|
||||||
|
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
||||||
|
),
|
||||||
|
lt(appointments.startTime, end),
|
||||||
|
gte(appointments.endTime, start),
|
||||||
|
ne(appointments.status, "cancelled"),
|
||||||
|
ne(appointments.status, "no_show"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (bathConflicts.length > 0) {
|
||||||
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!recurrence) {
|
if (!recurrence) {
|
||||||
// Single appointment
|
// Single appointment
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
@@ -398,7 +420,8 @@ appointmentsRouter.patch(
|
|||||||
const needsConflictCheck =
|
const needsConflictCheck =
|
||||||
updateFields.startTime !== undefined ||
|
updateFields.startTime !== undefined ||
|
||||||
updateFields.endTime !== undefined ||
|
updateFields.endTime !== undefined ||
|
||||||
updateFields.staffId !== undefined;
|
updateFields.staffId !== undefined ||
|
||||||
|
updateFields.batherStaffId !== undefined;
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
...updateFields,
|
...updateFields,
|
||||||
@@ -434,6 +457,11 @@ appointmentsRouter.patch(
|
|||||||
updateFields.staffId !== undefined
|
updateFields.staffId !== undefined
|
||||||
? updateFields.staffId
|
? updateFields.staffId
|
||||||
: current.staffId;
|
: current.staffId;
|
||||||
|
// Use provided batherStaffId (may be null to unassign); fall back to existing
|
||||||
|
const batherStaffId =
|
||||||
|
updateFields.batherStaffId !== undefined
|
||||||
|
? updateFields.batherStaffId
|
||||||
|
: current.batherStaffId;
|
||||||
|
|
||||||
if (end <= start) {
|
if (end <= start) {
|
||||||
throw Object.assign(new Error("end before start"), {
|
throw Object.assign(new Error("end before start"), {
|
||||||
@@ -461,6 +489,29 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batherStaffId) {
|
||||||
|
const bathConflicts = await tx
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, batherStaffId),
|
||||||
|
eq(appointments.batherStaffId, batherStaffId)
|
||||||
|
),
|
||||||
|
lt(appointments.startTime, end),
|
||||||
|
gte(appointments.endTime, start),
|
||||||
|
ne(appointments.status, "cancelled"),
|
||||||
|
ne(appointments.status, "no_show"),
|
||||||
|
ne(appointments.id, id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (bathConflicts.length > 0) {
|
||||||
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set(update)
|
.set(update)
|
||||||
|
|||||||
+49
-38
@@ -255,37 +255,39 @@ bookRouter.get("/confirm/:token", async (c) => {
|
|||||||
const token = c.req.param("token");
|
const token = c.req.param("token");
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Atomic: consume token and confirm in a single query to prevent replay.
|
|
||||||
// Only future appointments can be confirmed.
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
|
.select()
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.confirmationToken, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!appt) {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if appointment is in the past
|
||||||
|
if (appt.startTime < new Date()) {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent confirm: if already confirmed, redirect to success
|
||||||
|
if (appt.confirmationStatus === "confirmed") {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if already cancelled
|
||||||
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "confirmed",
|
confirmationStatus: "confirmed",
|
||||||
confirmedAt: new Date(),
|
confirmedAt: new Date(),
|
||||||
confirmationToken: null,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(appointments.id, appt.id));
|
||||||
and(
|
|
||||||
eq(appointments.confirmationToken, token),
|
|
||||||
eq(appointments.confirmationStatus, "pending"),
|
|
||||||
gt(appointments.startTime, new Date())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!appt) {
|
|
||||||
// Check status for idempotency: already-confirmed → redirect to confirmed
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ confirmationStatus: appointments.confirmationStatus })
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.confirmationToken, token))
|
|
||||||
.limit(1);
|
|
||||||
if (existing?.confirmationStatus === "confirmed") {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
|
||||||
}
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
});
|
});
|
||||||
@@ -297,9 +299,29 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
const token = c.req.param("token");
|
const token = c.req.param("token");
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Atomic: consume token and cancel in a single query to prevent replay.
|
|
||||||
// Only future appointments can be cancelled.
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
|
.select()
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.confirmationToken, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!appt) {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if appointment is in the past
|
||||||
|
if (appt.startTime < new Date()) {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if already cancelled (token was nullified — this path won't normally hit,
|
||||||
|
// but guard against edge cases where token lookup still works)
|
||||||
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-use cancellation: nullify token after use
|
||||||
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "cancelled",
|
confirmationStatus: "cancelled",
|
||||||
@@ -307,18 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
confirmationToken: null,
|
confirmationToken: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(appointments.id, appt.id));
|
||||||
and(
|
|
||||||
eq(appointments.confirmationToken, token),
|
|
||||||
eq(appointments.confirmationStatus, "pending"),
|
|
||||||
gt(appointments.startTime, new Date())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!appt) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -84,12 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
|||||||
.where(eq(staff.id, staffId))
|
.where(eq(staff.id, staffId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (
|
if (!staffMember || staffMember.icalToken !== token) {
|
||||||
!staffMember ||
|
|
||||||
!staffMember.icalToken ||
|
|
||||||
staffMember.icalToken.length !== token.length ||
|
|
||||||
!timingSafeEqual(Buffer.from(staffMember.icalToken), Buffer.from(token))
|
|
||||||
) {
|
|
||||||
return c.text("Unauthorized", 401);
|
return c.text("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const groomingLogsRouter = new Hono();
|
export const groomingLogsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createLogSchema = z.object({
|
const createLogSchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const petId = c.req.query("petId");
|
const petId = c.req.query("petId");
|
||||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(groomingVisitLogs)
|
.from(groomingVisitLogs)
|
||||||
@@ -33,11 +54,50 @@ groomingLogsRouter.post(
|
|||||||
zValidator("json", createLogSchema),
|
zValidator("json", createLogSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { groomedAt, ...rest } = c.req.valid("json");
|
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
if (appointmentId) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.id, appointmentId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
} else {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(groomingVisitLogs)
|
.insert(groomingVisitLogs)
|
||||||
.values({
|
.values({
|
||||||
...rest,
|
...rest,
|
||||||
|
petId,
|
||||||
|
appointmentId: appointmentId ?? null,
|
||||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -47,10 +107,37 @@ groomingLogsRouter.post(
|
|||||||
|
|
||||||
groomingLogsRouter.delete("/:id", async (c) => {
|
groomingLogsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [row] = await db
|
const id = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
|
|
||||||
|
const [log] = await db
|
||||||
|
.select()
|
||||||
|
.from(groomingVisitLogs)
|
||||||
|
.where(eq(groomingVisitLogs.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!log) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (isGroomer) {
|
||||||
|
const [appt] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.petId, log.petId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
.delete(groomingVisitLogs)
|
.delete(groomingVisitLogs)
|
||||||
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
.where(eq(groomingVisitLogs.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod/v3";
|
|||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
|
gte,
|
||||||
getDb,
|
getDb,
|
||||||
invoices,
|
invoices,
|
||||||
invoiceLineItems,
|
invoiceLineItems,
|
||||||
@@ -377,3 +378,106 @@ invoicesRouter.post(
|
|||||||
return c.json({ refundId: result.refundId });
|
return c.json({ refundId: result.refundId });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Stripe Payment Info ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { getStripeClient } from "../services/payment.js";
|
||||||
|
|
||||||
|
invoicesRouter.get("/:id/stripe-payment", 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);
|
||||||
|
|
||||||
|
if (!invoice.stripePaymentIntentId) {
|
||||||
|
return c.json({ error: "No Stripe payment found for this invoice" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return c.json({ error: "Stripe not configured" }, 503);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paymentIntent = await stripe.paymentIntents.retrieve(invoice.stripePaymentIntentId);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const cardDetails = (paymentIntent as any).payment_details?.card;
|
||||||
|
const refundStatus = invoice.stripeRefundId
|
||||||
|
? await stripe.refunds.retrieve(invoice.stripeRefundId).then((r) => r.status).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
paymentIntentId: invoice.stripePaymentIntentId,
|
||||||
|
amountPaidCents: paymentIntent.amount_received,
|
||||||
|
status: paymentIntent.status,
|
||||||
|
cardLast4: cardDetails?.last4 ?? null,
|
||||||
|
cardBrand: cardDetails?.brand ?? null,
|
||||||
|
refundId: invoice.stripeRefundId,
|
||||||
|
refundStatus,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Failed to retrieve Stripe payment info" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Payment Stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
invoicesRouter.get("/stats", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const thisMonthInvoices = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(invoices.createdAt, startOfMonth),
|
||||||
|
eq(invoices.status, "paid")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenueCents = thisMonthInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||||
|
|
||||||
|
const pendingInvoices = await db
|
||||||
|
.select({ totalCents: invoices.totalCents })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
|
const outstandingCents = pendingInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||||
|
|
||||||
|
const refundedInvoices = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(invoices.createdAt, startOfMonth),
|
||||||
|
sql`${invoices.stripeRefundId} IS NOT NULL`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const refundsCents = refundedInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||||
|
|
||||||
|
const paymentMethodBreakdown = await db
|
||||||
|
.select({
|
||||||
|
paymentMethod: invoices.paymentMethod,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
totalCents: sql<number>`sum(${invoices.totalCents})`,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(invoices.createdAt, startOfMonth),
|
||||||
|
sql`${invoices.paymentMethod} IS NOT NULL`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
revenueCents,
|
||||||
|
outstandingCents,
|
||||||
|
refundsCents,
|
||||||
|
revenueCount: thisMonthInvoices.length,
|
||||||
|
refundCount: refundedInvoices.length,
|
||||||
|
paymentMethodBreakdown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
||||||
|
|
||||||
|
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const churnRisk = await db
|
const churnRisk = await db
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.id,
|
clientId: clients.id,
|
||||||
@@ -298,15 +302,34 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
.having(
|
.having(
|
||||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||||
)
|
)
|
||||||
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
|
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const [churnCountRow] = await db
|
||||||
|
.select({ total: sql<number>`count(*)::int` })
|
||||||
|
.from(
|
||||||
|
db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.leftJoin(appointments, eq(appointments.clientId, clients.id))
|
||||||
|
.groupBy(clients.id)
|
||||||
|
.having(
|
||||||
|
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
||||||
|
)
|
||||||
|
.as("churn_count")
|
||||||
|
);
|
||||||
|
const churnRiskTotal = churnCountRow?.total ?? 0;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
from: from.toISOString(),
|
from: from.toISOString(),
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
newClients,
|
newClients,
|
||||||
activeInPeriodCount: activeInPeriod.length,
|
activeInPeriodCount: activeInPeriod.length,
|
||||||
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
|
churnRisk,
|
||||||
churnRiskTotal: churnRisk.length,
|
churnRiskTotal,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,25 +6,6 @@ import type { AppEnv } from "../middleware/rbac.js";
|
|||||||
|
|
||||||
export const setupRouter = new Hono<AppEnv>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// Simple in-memory rate limiter: 10 req/min per IP for setup endpoints
|
|
||||||
const setupRateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
||||||
const SETUP_RATE_LIMIT = 10;
|
|
||||||
const SETUP_RATE_WINDOW_MS = 60 * 1000;
|
|
||||||
|
|
||||||
function checkSetupRateLimit(ip: string): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = setupRateLimitMap.get(ip);
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
setupRateLimitMap.set(ip, { count: 1, resetAt: now + SETUP_RATE_WINDOW_MS });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (entry.count >= SETUP_RATE_LIMIT) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
entry.count++;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) => {
|
||||||
@@ -204,11 +185,6 @@ const authProviderTestSchema = z.object({
|
|||||||
* After setup completes, this endpoint permanently returns 403.
|
* After setup completes, this endpoint permanently returns 403.
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider", async (c) => {
|
setupRouter.post("/auth-provider", async (c) => {
|
||||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
if (!checkSetupRateLimit(ip)) {
|
|
||||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
@@ -278,11 +254,6 @@ setupRouter.post("/auth-provider", async (c) => {
|
|||||||
* Only available when needsSetup is true (no super user = fresh install).
|
* Only available when needsSetup is true (no super user = fresh install).
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider/test", async (c) => {
|
setupRouter.post("/auth-provider/test", async (c) => {
|
||||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
if (!checkSetupRateLimit(ip)) {
|
|
||||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ FROM nginx:alpine AS runner
|
|||||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:80/ || exit 1
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit, StripePaymentInfo, PaymentStats } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -173,6 +173,23 @@ 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 [stripeInfo, setStripeInfo] = useState<StripePaymentInfo | null>(null);
|
||||||
|
const [stripeLoading, setStripeLoading] = useState(false);
|
||||||
|
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
|
const [refundAmountStr, setRefundAmountStr] = useState("");
|
||||||
|
const [refunding, setRefunding] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
|
||||||
|
setStripeLoading(true);
|
||||||
|
fetch(`/api/invoices/${invoice.id}/stripe-payment`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: StripePaymentInfo) => setStripeInfo(data))
|
||||||
|
.catch(() => { /* non-blocking */ })
|
||||||
|
.finally(() => setStripeLoading(false));
|
||||||
|
}
|
||||||
|
}, [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
|
||||||
@@ -271,6 +288,31 @@ function InvoiceDetailModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitRefund() {
|
||||||
|
setRefunding(true);
|
||||||
|
setError(null);
|
||||||
|
const amountCents = refundType === "partial"
|
||||||
|
? Math.round(parseFloat(refundAmountStr) * 100)
|
||||||
|
: undefined;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ 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 : "Refund failed");
|
||||||
|
} finally {
|
||||||
|
setRefunding(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;
|
||||||
@@ -330,6 +372,18 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{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} />}
|
||||||
|
{stripeLoading && <SummaryRow label="Stripe" value="Loading…" />}
|
||||||
|
{stripeInfo && (
|
||||||
|
<>
|
||||||
|
{stripeInfo.cardLast4 && (
|
||||||
|
<SummaryRow label="Card" value={`${stripeInfo.cardBrand ?? "Card"} •••• ${stripeInfo.cardLast4}`} />
|
||||||
|
)}
|
||||||
|
<SummaryRow label="Stripe status" value={stripeInfo.status} />
|
||||||
|
{invoice.stripeRefundId && stripeInfo.refundStatus && (
|
||||||
|
<SummaryRow label="Refund status" value={stripeInfo.refundStatus === "succeeded" ? "Refunded" : stripeInfo.refundStatus} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tip Distribution ── */}
|
{/* ── Tip Distribution ── */}
|
||||||
@@ -447,10 +501,101 @@ 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 && !invoice.stripeRefundId && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRefundType("full");
|
||||||
|
setRefundAmountStr("");
|
||||||
|
setShowRefundDialog(true);
|
||||||
|
}}
|
||||||
|
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626" }}
|
||||||
|
>
|
||||||
|
Refund
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showRefundDialog && (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 110,
|
||||||
|
}}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowRefundDialog(false); }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||||
|
maxWidth: 400, width: "calc(100% - 2rem)",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: "0 0 1rem" }}>Process Refund</h3>
|
||||||
|
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
||||||
|
Invoice total: {fmtMoney(invoice.totalCents)}
|
||||||
|
</p>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
|
||||||
|
Refund type
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setRefundType("full")}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: refundType === "full" ? "var(--color-primary)" : "#fff",
|
||||||
|
color: refundType === "full" ? "#fff" : "#374151",
|
||||||
|
borderColor: refundType === "full" ? "var(--color-primary)" : "#d1d5db",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Full refund
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRefundType("partial"); setRefundAmountStr((invoice.totalCents / 100).toFixed(2)); }}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: refundType === "partial" ? "var(--color-primary)" : "#fff",
|
||||||
|
color: refundType === "partial" ? "#fff" : "#374151",
|
||||||
|
borderColor: refundType === "partial" ? "var(--color-primary)" : "#d1d5db",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Partial refund
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{refundType === "partial" && (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
|
||||||
|
Refund amount
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span style={{ color: "#6b7280" }}>$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
max={(invoice.totalCents / 100).toFixed(2)}
|
||||||
|
step="0.01"
|
||||||
|
value={refundAmountStr}
|
||||||
|
onChange={(e) => setRefundAmountStr(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={submitRefund}
|
||||||
|
disabled={refunding || (refundType === "partial" && (!refundAmountStr || parseFloat(refundAmountStr) <= 0))}
|
||||||
|
style={{ ...btnStyle, backgroundColor: "#dc2626", color: "#fff", borderColor: "#dc2626" }}
|
||||||
|
>
|
||||||
|
{refunding ? "Refunding…" : "Refund"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,6 +637,8 @@ 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 [stats, setStats] = useState<PaymentStats | null>(null);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(true);
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
@@ -513,6 +660,15 @@ export function InvoicesPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStatsLoading(true);
|
||||||
|
fetch("/api/invoices/stats")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: PaymentStats) => setStats(data))
|
||||||
|
.catch(() => { /* non-blocking */ })
|
||||||
|
.finally(() => setStatsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
function loadCreateData() {
|
function loadCreateData() {
|
||||||
if (createData) return Promise.resolve();
|
if (createData) return Promise.resolve();
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -573,6 +729,36 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!statsLoading && stats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1rem" }}>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Revenue this month</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#065f46" }}>{fmtMoney(stats.revenueCents)}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.revenueCount} paid</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#92400e" }}>{fmtMoney(stats.outstandingCents)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Refunds this month</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#991b1b" }}>{fmtMoney(stats.refundsCents)}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.refundCount} refunds</div>
|
||||||
|
</div>
|
||||||
|
{stats.paymentMethodBreakdown.length > 0 && (
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>By payment method</div>
|
||||||
|
{stats.paymentMethodBreakdown.map((b) => (
|
||||||
|
<div key={b.paymentMethod} style={{ fontSize: 13, display: "flex", justifyContent: "space-between", marginTop: "0.2rem" }}>
|
||||||
|
<span style={{ textTransform: "capitalize" }}>{b.paymentMethod}</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{fmtMoney(b.totalCents)}</span>
|
||||||
|
</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.
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ async function seed() {
|
|||||||
|
|
||||||
// ── Staff ──
|
// ── Staff ──
|
||||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
||||||
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
|
||||||
);
|
);
|
||||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
||||||
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })
|
||||||
|
|||||||
@@ -153,10 +153,38 @@ export interface Invoice {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
stripePaymentIntentId?: string | null;
|
||||||
|
stripeRefundId?: string | null;
|
||||||
|
paymentFailureReason?: string | null;
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StripePaymentInfo {
|
||||||
|
paymentIntentId: string;
|
||||||
|
amountPaidCents: number;
|
||||||
|
status: string;
|
||||||
|
cardLast4: string | null;
|
||||||
|
cardBrand: string | null;
|
||||||
|
refundId: string | null;
|
||||||
|
refundStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMethodBreakdown {
|
||||||
|
paymentMethod: PaymentMethod;
|
||||||
|
count: number;
|
||||||
|
totalCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentStats {
|
||||||
|
revenueCents: number;
|
||||||
|
outstandingCents: number;
|
||||||
|
refundsCents: number;
|
||||||
|
revenueCount: number;
|
||||||
|
refundCount: number;
|
||||||
|
paymentMethodBreakdown: PaymentMethodBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
||||||
|
|||||||
Reference in New Issue
Block a user