Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e98c0582 |
@@ -23,7 +23,6 @@
|
|||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
|
||||||
import { portalAuditMiddleware } from "../middleware/portalAudit.js";
|
|
||||||
|
|
||||||
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
|
||||||
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
|
|
||||||
|
|
||||||
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
|
|
||||||
const pastDate = () => new Date(Date.now() - 5 * 60 * 1000);
|
|
||||||
|
|
||||||
const ACTIVE_SESSION = {
|
|
||||||
id: SESSION_ID,
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
status: "active" as const,
|
|
||||||
expiresAt: futureDate(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXPIRED_SESSION = {
|
|
||||||
id: SESSION_ID,
|
|
||||||
clientId: CLIENT_ID,
|
|
||||||
status: "active" as const,
|
|
||||||
expiresAt: pastDate(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let selectSessionRow: Record<string, unknown> | null = null;
|
|
||||||
let insertedAuditLogs: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
function resetMock() {
|
|
||||||
selectSessionRow = null;
|
|
||||||
insertedAuditLogs = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => {
|
|
||||||
function makeChainable(data: unknown[]): unknown {
|
|
||||||
const arr = [...data];
|
|
||||||
const chain = new Proxy(arr, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
|
||||||
return () => chain;
|
|
||||||
}
|
|
||||||
// @ts-expect-error proxy
|
|
||||||
return target[prop];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return chain;
|
|
||||||
}
|
|
||||||
|
|
||||||
const impersonationSessions = new Proxy(
|
|
||||||
{ _name: "impersonationSessions" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
const impersonationAuditLogs = new Proxy(
|
|
||||||
{ _name: "impersonationAuditLogs" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getDb: () => ({
|
|
||||||
select: () => ({
|
|
||||||
from: (table: { _name: string }) => {
|
|
||||||
if (table._name === "impersonationSessions") {
|
|
||||||
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
|
|
||||||
}
|
|
||||||
return makeChainable([]);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
insert: () => ({
|
|
||||||
values: (vals: Record<string, unknown>) => {
|
|
||||||
insertedAuditLogs.push(vals);
|
|
||||||
return {
|
|
||||||
returning: () => [{ id: "audit-log-uuid-1", ...vals }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
impersonationSessions,
|
|
||||||
impersonationAuditLogs,
|
|
||||||
eq: vi.fn(),
|
|
||||||
and: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = new Hono();
|
|
||||||
app.use(validatePortalSession);
|
|
||||||
app.use(portalAuditMiddleware);
|
|
||||||
app.get("/test", (c) => c.json({ ok: true }));
|
|
||||||
|
|
||||||
function makeRequest(path: string, headers?: Record<string, string>) {
|
|
||||||
return app.request(path, { headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => resetMock());
|
|
||||||
|
|
||||||
// ─── validatePortalSession tests ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("validatePortalSession", () => {
|
|
||||||
it("calls next and sets context variables for valid active session", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when X-Impersonation-Session-Id header is missing", async () => {
|
|
||||||
const res = await makeRequest("/test");
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when session is expired", async () => {
|
|
||||||
selectSessionRow = EXPIRED_SESSION;
|
|
||||||
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when session is not found", async () => {
|
|
||||||
selectSessionRow = null;
|
|
||||||
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── portalAuditMiddleware tests ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("portalAuditMiddleware", () => {
|
|
||||||
it("inserts audit log entry after successful request", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(insertedAuditLogs).toHaveLength(1);
|
|
||||||
expect(insertedAuditLogs[0].sessionId).toBe(SESSION_ID);
|
|
||||||
expect(insertedAuditLogs[0].action).toBe("GET /test");
|
|
||||||
expect(insertedAuditLogs[0].pageVisited).toBe("/test");
|
|
||||||
expect(insertedAuditLogs[0].metadata).toEqual({ method: "GET", statusCode: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not throw when audit log insert fails", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
const res = await makeRequest("/test", { "X-Impersonation-Session-Id": SESSION_ID });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not insert audit log when portalSessionId is not set", async () => {
|
|
||||||
const res = await makeRequest("/test");
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
expect(insertedAuditLogs).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
|
||||||
import type { PortalSessionEnv } from "./portalSession.js";
|
|
||||||
|
|
||||||
export const portalAuditMiddleware: MiddlewareHandler<PortalSessionEnv> = async (
|
|
||||||
c,
|
|
||||||
next
|
|
||||||
) => {
|
|
||||||
await next();
|
|
||||||
|
|
||||||
const sessionId = c.get("portalSessionId");
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
const action = `${c.req.method} ${c.req.path}`;
|
|
||||||
const metadata = { method: c.req.method, statusCode: c.res.status };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
await db.insert(impersonationAuditLogs).values({
|
|
||||||
sessionId,
|
|
||||||
action,
|
|
||||||
pageVisited: c.req.path,
|
|
||||||
metadata,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[portalAudit] failed to insert audit log:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
|
||||||
|
|
||||||
export interface PortalSessionEnv {
|
|
||||||
Variables: {
|
|
||||||
portalClientId: string;
|
|
||||||
portalSessionId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const validatePortalSession: MiddlewareHandler<PortalSessionEnv> = async (
|
|
||||||
c,
|
|
||||||
next
|
|
||||||
) => {
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(impersonationSessions.id, sessionId),
|
|
||||||
eq(impersonationSessions.status, "active")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("portalClientId", session.clientId);
|
|
||||||
c.set("portalSessionId", session.id);
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
sql,
|
sql,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const invoicesRouter = new Hono<AppEnv>();
|
export const invoicesRouter = new Hono();
|
||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
appointmentId: z.string().uuid().optional(),
|
appointmentId: z.string().uuid().optional(),
|
||||||
@@ -339,41 +338,3 @@ invoicesRouter.patch(
|
|||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { processRefund } from "../services/payment.js";
|
|
||||||
|
|
||||||
const refundSchema = z.object({
|
|
||||||
amountCents: z.number().int().nonnegative().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
invoicesRouter.post(
|
|
||||||
"/:id/refund",
|
|
||||||
zValidator("json", refundSchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const staff = c.get("staff");
|
|
||||||
if (!staff) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
if (staff.role !== "manager" && !staff.isSuperUser) {
|
|
||||||
return c.json({ error: "Manager role required" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = c.req.param("id");
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
|
||||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
|
||||||
if (invoice.status !== "paid") {
|
|
||||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
|
||||||
}
|
|
||||||
if (!invoice.stripePaymentIntentId) {
|
|
||||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
|
||||||
|
|
||||||
return c.json({ refundId: result.refundId });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
+119
-129
@@ -1,25 +1,33 @@
|
|||||||
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 { eq, inArray } from "@groombook/db";
|
import { and, eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import type { PortalSessionEnv } from "../middleware/portalSession.js";
|
|
||||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
|
||||||
import { portalAuditMiddleware } from "../middleware/portalAudit.js";
|
|
||||||
|
|
||||||
type PortalEnv = AppEnv & PortalSessionEnv;
|
export const portalRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
export const portalRouter = new Hono<PortalEnv>();
|
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
portalRouter.use(validatePortalSession);
|
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||||
portalRouter.use(portalAuditMiddleware);
|
if (!sessionId) return null;
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
if (!session || session.expiresAt <= new Date()) return null;
|
||||||
|
return session.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
portalRouter.get("/me", async (c) => {
|
portalRouter.get("/me", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
if (!client) return c.json({ error: "Not found" }, 404);
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
@@ -27,12 +35,6 @@ portalRouter.get("/me", async (c) => {
|
|||||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/config", async (c) => {
|
|
||||||
return c.json({
|
|
||||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.get("/services", async (c) => {
|
portalRouter.get("/services", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||||
@@ -41,7 +43,9 @@ portalRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/appointments", async (c) => {
|
portalRouter.get("/appointments", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
@@ -91,7 +95,9 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||||
@@ -99,7 +105,9 @@ portalRouter.get("/pets", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
const invoiceIds = clientInvoices.map(i => i.id);
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
@@ -115,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
|
|||||||
id: inv.id,
|
id: inv.id,
|
||||||
status: inv.status,
|
status: inv.status,
|
||||||
totalCents: inv.totalCents,
|
totalCents: inv.totalCents,
|
||||||
date: inv.createdAt,
|
createdAt: inv.createdAt,
|
||||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
@@ -123,6 +131,7 @@ portalRouter.get("/invoices", async (c) => {
|
|||||||
// ─── Appointment action routes ────────────────────────────────────────────────
|
// ─── Appointment action routes ────────────────────────────────────────────────
|
||||||
|
|
||||||
const customerNotesSchema = z.object({
|
const customerNotesSchema = z.object({
|
||||||
|
// .min(1) prevents empty strings — clearing notes is not a supported use case
|
||||||
customerNotes: z.string().min(1).max(500),
|
customerNotes: z.string().min(1).max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +142,12 @@ portalRouter.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 clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -176,7 +190,12 @@ portalRouter.patch(
|
|||||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -225,7 +244,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -246,7 +270,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (appt.status === "cancelled" || appt.status === "completed") {
|
if (appt.status === "cancelled" || appt.status === "completed") {
|
||||||
return c.json({ error: "Cannot cancel a cancelled or completed appointment" }, 422);
|
return c.json({ error: "Appointment is already cancelled or completed" }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -289,7 +313,28 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
let clientId: string | null = null;
|
||||||
|
if (sessionId) {
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
clientId = session.clientId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.insert(waitlistEntries)
|
.insert(waitlistEntries)
|
||||||
@@ -313,7 +358,26 @@ portalRouter.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 clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -322,7 +386,7 @@ portalRouter.patch(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
if (existing.clientId !== clientId) {
|
if (existing.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +408,26 @@ portalRouter.patch(
|
|||||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -353,7 +436,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||||
if (entry.clientId !== clientId) {
|
if (entry.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,105 +448,6 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Payment routes ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import {
|
|
||||||
createPaymentIntent,
|
|
||||||
listPaymentMethods,
|
|
||||||
detachPaymentMethod,
|
|
||||||
createSetupIntent,
|
|
||||||
getOrCreateStripeCustomer,
|
|
||||||
getStripeClient,
|
|
||||||
} from "../services/payment.js";
|
|
||||||
|
|
||||||
const payMultipleSchema = z.object({
|
|
||||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/invoices/pay-multiple",
|
|
||||||
zValidator("json", payMultipleSchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
|
||||||
const invoiceRows = await db
|
|
||||||
.select()
|
|
||||||
.from(invoices)
|
|
||||||
.where(inArray(invoices.id, body.invoiceIds));
|
|
||||||
|
|
||||||
if (invoiceRows.length !== body.invoiceIds.length) {
|
|
||||||
return c.json({ error: "One or more invoices not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const inv of invoiceRows) {
|
|
||||||
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
if (inv.status === "draft" || inv.status === "void") {
|
|
||||||
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
|
|
||||||
}
|
|
||||||
if (inv.status === "paid") {
|
|
||||||
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstInvoice = invoiceRows[0];
|
|
||||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
|
||||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
|
||||||
if (!allSameClient) {
|
|
||||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
|
||||||
const result = await createPaymentIntent(body.invoiceIds, clientId);
|
|
||||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
|
||||||
|
|
||||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
portalRouter.get("/payment-methods", async (c) => {
|
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
|
||||||
const methods = await listPaymentMethods(clientId);
|
|
||||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
|
||||||
return c.json(methods);
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post("/payment-methods", async (c) => {
|
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
|
||||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
|
||||||
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
|
|
||||||
|
|
||||||
const result = await createSetupIntent(customerId);
|
|
||||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
|
||||||
|
|
||||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
|
||||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
|
||||||
|
|
||||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
|
||||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
|
||||||
return c.json({ error: "Payment method not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await detachPaymentMethod(paymentMethodId);
|
|
||||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||||
// Allows the dev login selector to vend an impersonation session for a client
|
// Allows the dev login selector to vend an impersonation session for a client
|
||||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||||
@@ -483,6 +467,7 @@ portalRouter.post(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
// Verify client exists
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -492,6 +477,10 @@ portalRouter.post(
|
|||||||
return c.json({ error: "Client not found" }, 404);
|
return c.json({ error: "Client not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a staff record to associate with the dev impersonation session.
|
||||||
|
// Use the demo-manager if it exists (created by seed with known ID),
|
||||||
|
// otherwise fall back to the first active staff record.
|
||||||
|
// This avoids hardcoding a UUID that may not exist in all environments.
|
||||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
let staffId = DEMO_STAFF_ID;
|
let staffId = DEMO_STAFF_ID;
|
||||||
@@ -502,6 +491,7 @@ portalRouter.post(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!demoStaff) {
|
if (!demoStaff) {
|
||||||
|
// Fall back to any active staff member
|
||||||
const [firstStaff] = await db
|
const [firstStaff] = await db
|
||||||
.select({ id: staff.id })
|
.select({ id: staff.id })
|
||||||
.from(staff)
|
.from(staff)
|
||||||
@@ -519,10 +509,10 @@ portalRouter.post(
|
|||||||
staffId,
|
staffId,
|
||||||
clientId: body.clientId,
|
clientId: body.clientId,
|
||||||
reason: "dev-mode-client-portal",
|
reason: "dev-mode-client-portal",
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return c.json(session, 201);
|
return c.json(session, 201);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
import { getStripeClient } from "../services/payment.js";
|
|
||||||
|
|
||||||
export const webhooksRouter = new Hono();
|
export const webhooksRouter = new Hono();
|
||||||
|
|
||||||
webhooksRouter.post("/stripe", async (c) => {
|
webhooksRouter.post("/stripe", async (c) => {
|
||||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
if (!webhookSecret) {
|
if (!secret) {
|
||||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,14 +22,11 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
return c.json({ error: "Could not read body" }, 400);
|
return c.json({ error: "Could not read body" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
||||||
if (!stripe) {
|
|
||||||
return c.json({ error: "Stripe not configured" }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
let event: Stripe.Event;
|
||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||||
return c.json({ error: message }, 401);
|
return c.json({ error: message }, 401);
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import Stripe from "stripe";
|
|
||||||
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
|
||||||
|
|
||||||
let _stripe: Stripe | null | undefined;
|
|
||||||
|
|
||||||
export function getStripeClient(): Stripe | null {
|
|
||||||
if (_stripe === undefined) {
|
|
||||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
|
||||||
if (!secretKey) return null;
|
|
||||||
_stripe = new Stripe(secretKey);
|
|
||||||
}
|
|
||||||
return _stripe;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return null;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
||||||
if (!client) return null;
|
|
||||||
|
|
||||||
if (client.stripeCustomerId) return client.stripeCustomerId;
|
|
||||||
|
|
||||||
const customer = await stripe.customers.create({
|
|
||||||
metadata: { groombook_client_id: clientId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(clients)
|
|
||||||
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
|
|
||||||
.where(eq(clients.id, clientId));
|
|
||||||
|
|
||||||
return customer.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPaymentIntent(
|
|
||||||
invoiceIdOrIds: string | string[],
|
|
||||||
clientId: string
|
|
||||||
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return null;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
|
||||||
const firstInvoiceId = invoiceIds[0];
|
|
||||||
if (!firstInvoiceId) return null;
|
|
||||||
|
|
||||||
const invoiceRows = await db
|
|
||||||
.select()
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.id, firstInvoiceId));
|
|
||||||
|
|
||||||
const [invoice] = invoiceRows;
|
|
||||||
if (!invoice) return null;
|
|
||||||
|
|
||||||
let totalCents = invoice.totalCents;
|
|
||||||
if (invoiceIds.length > 1) {
|
|
||||||
const allInvoices = await db
|
|
||||||
.select({ totalCents: invoices.totalCents })
|
|
||||||
.from(invoices)
|
|
||||||
.where(inArray(invoices.id, invoiceIds));
|
|
||||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
|
||||||
if (!stripeCustomerId) return null;
|
|
||||||
|
|
||||||
const paymentIntent = await stripe.paymentIntents.create({
|
|
||||||
amount: totalCents,
|
|
||||||
currency: "usd",
|
|
||||||
customer: stripeCustomerId,
|
|
||||||
metadata: {
|
|
||||||
groombook_invoice_ids: invoiceIds.join(","),
|
|
||||||
groombook_client_id: clientId,
|
|
||||||
},
|
|
||||||
automatic_payment_methods: { enabled: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const invId of invoiceIds) {
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
|
|
||||||
.where(eq(invoices.id, invId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientSecret = paymentIntent.client_secret;
|
|
||||||
if (!clientSecret) return null;
|
|
||||||
|
|
||||||
return { clientSecret, paymentIntentId: paymentIntent.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processRefund(
|
|
||||||
invoiceId: string,
|
|
||||||
amountCents?: number
|
|
||||||
): Promise<{ refundId: string } | null> {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return null;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
|
|
||||||
if (!invoice?.stripePaymentIntentId) return null;
|
|
||||||
|
|
||||||
const refund = await stripe.refunds.create({
|
|
||||||
payment_intent: invoice.stripePaymentIntentId,
|
|
||||||
amount: amountCents,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
|
|
||||||
.where(eq(invoices.id, invoiceId));
|
|
||||||
|
|
||||||
return { refundId: refund.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return null;
|
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
|
||||||
if (!stripeCustomerId) return null;
|
|
||||||
|
|
||||||
const methods = await stripe.paymentMethods.list({
|
|
||||||
customer: stripeCustomerId,
|
|
||||||
type: "card",
|
|
||||||
});
|
|
||||||
|
|
||||||
return methods.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function attachPaymentMethod(
|
|
||||||
clientId: string,
|
|
||||||
paymentMethodId: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return false;
|
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
|
||||||
if (!stripeCustomerId) return false;
|
|
||||||
|
|
||||||
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return false;
|
|
||||||
|
|
||||||
await stripe.paymentMethods.detach(paymentMethodId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return null;
|
|
||||||
|
|
||||||
const setupIntent = await stripe.setupIntents.create({
|
|
||||||
customer: customerId,
|
|
||||||
payment_method_types: ["card"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return { clientSecret: setupIntent.client_secret! };
|
|
||||||
}
|
|
||||||
@@ -14,8 +14,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@groombook/types": "workspace:*",
|
"@groombook/types": "workspace:*",
|
||||||
"@stripe/react-stripe-js": "^6.1.0",
|
|
||||||
"@stripe/stripe-js": "^9.1.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment as any}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
|
||||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
|
||||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
@@ -12,28 +10,31 @@ interface Invoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentMethod {
|
interface PaymentMethod {
|
||||||
id: string;
|
|
||||||
brand: string;
|
brand: string;
|
||||||
last4: string;
|
last4: string;
|
||||||
expiryMonth: number;
|
expiryMonth: number;
|
||||||
expiryYear: number;
|
expiryYear: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Package {
|
||||||
|
name: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface BillingPaymentsProps {
|
interface BillingPaymentsProps {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
const [packages] = useState<{ name: string; remaining: number }[]>([]);
|
const [packages, setPackages] = useState<Package[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||||
const [autopay, setAutopay] = useState(false);
|
const [autopay, setAutopay] = useState(false);
|
||||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
const [publishableKey, setPublishableKey] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@@ -43,37 +44,20 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [configRes, invoicesRes, methodsRes] = await Promise.all([
|
const response = await fetch("/api/portal/invoices", {
|
||||||
fetch("/api/portal/config", {
|
headers: {
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
"X-Impersonation-Session-Id": sessionId,
|
||||||
}),
|
},
|
||||||
fetch("/api/portal/invoices", {
|
});
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
|
||||||
}),
|
|
||||||
fetch("/api/portal/payment-methods", {
|
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!configRes.ok) throw new Error("Failed to fetch config");
|
if (!response.ok) {
|
||||||
const configData = await configRes.json();
|
throw new Error("Failed to fetch invoices");
|
||||||
setPublishableKey(configData.stripePublishableKey ?? "");
|
|
||||||
|
|
||||||
const invoicesData = await invoicesRes.json();
|
|
||||||
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
|
|
||||||
|
|
||||||
if (methodsRes.ok) {
|
|
||||||
const methodsData = await methodsRes.json();
|
|
||||||
setPaymentMethods(
|
|
||||||
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
|
|
||||||
id: m.id,
|
|
||||||
brand: m.card?.brand ?? "unknown",
|
|
||||||
last4: m.card?.last4 ?? "****",
|
|
||||||
expiryMonth: m.card?.exp_month ?? 0,
|
|
||||||
expiryYear: m.card?.exp_year ?? 0,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setInvoices(Array.isArray(data) ? data : data.invoices || []);
|
||||||
|
setPaymentMethods(data.paymentMethods || []);
|
||||||
|
setPackages(data.packages || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred");
|
setError(err instanceof Error ? err.message : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,8 +68,12 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const formatCents = (cents: number) =>
|
const formatCents = (cents: number) => {
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(cents / 100);
|
||||||
|
};
|
||||||
|
|
||||||
const pending = invoices.filter((i) => i.status === "pending");
|
const pending = invoices.filter((i) => i.status === "pending");
|
||||||
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
@@ -94,9 +82,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-4">
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/3" />
|
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
|
||||||
<div className="h-24 bg-gray-200 rounded" />
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
<div className="h-24 bg-gray-200 rounded" />
|
<div className="h-24 bg-gray-200 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -112,6 +100,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Outstanding Balance Banner */}
|
||||||
{totalPending > 0 && (
|
{totalPending > 0 && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -121,15 +110,16 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPaymentModal(true)}
|
onClick={() => setShowPaymentModal(true)}
|
||||||
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||||
>
|
>
|
||||||
Pay Now
|
Pay Now
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
@@ -151,6 +141,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Invoices */}
|
||||||
{tab === "invoices" && (
|
{tab === "invoices" && (
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -161,7 +152,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<th className="px-5 py-3 font-medium">Description</th>
|
<th className="px-5 py-3 font-medium">Description</th>
|
||||||
<th className="px-5 py-3 font-medium">Amount</th>
|
<th className="px-5 py-3 font-medium">Amount</th>
|
||||||
<th className="px-5 py-3 font-medium">Status</th>
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
<th className="px-5 py-3 font-medium" />
|
<th className="px-5 py-3 font-medium"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -169,7 +160,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||||
<td className="px-5 py-3 text-stone-700">
|
<td className="px-5 py-3 text-stone-700">
|
||||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||||
month: "short", day: "numeric", year: "numeric",
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-stone-600">
|
<td className="px-5 py-3 text-stone-600">
|
||||||
@@ -208,6 +201,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Payment Methods */}
|
||||||
{tab === "payment" && (
|
{tab === "payment" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{paymentMethods.length === 0 ? (
|
{paymentMethods.length === 0 ? (
|
||||||
@@ -216,7 +210,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paymentMethods.map((method) => (
|
{paymentMethods.map((method) => (
|
||||||
<div
|
<div
|
||||||
key={method.id}
|
key={`${method.brand}-${method.last4}`}
|
||||||
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -229,18 +223,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button className="text-sm text-blue-600 hover:underline">
|
||||||
onClick={async () => {
|
|
||||||
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-sm text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -249,6 +232,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Autopay */}
|
||||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -257,7 +241,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
<p className="text-xs text-stone-500">
|
||||||
|
Automatically charge after each appointment
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
@@ -283,13 +269,17 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
{tab === "packages" && (
|
{tab === "packages" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{packages.length === 0 ? (
|
{packages.length === 0 ? (
|
||||||
<p className="text-gray-500 italic">No packages purchased</p>
|
<p className="text-gray-500 italic">No packages purchased</p>
|
||||||
) : (
|
) : (
|
||||||
packages.map((pkg, index) => (
|
packages.map((pkg, index) => (
|
||||||
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-stone-800">{pkg.name}</span>
|
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||||
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||||
@@ -300,124 +290,60 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPaymentModal && publishableKey && (
|
{/* Payment Modal */}
|
||||||
<PaymentModalWrapper
|
{showPaymentModal && (
|
||||||
key={Date.now()}
|
<PaymentModal
|
||||||
sessionId={sessionId ?? ""}
|
|
||||||
publishableKey={publishableKey}
|
|
||||||
pending={pending}
|
pending={pending}
|
||||||
|
totalPending={totalPending}
|
||||||
onClose={() => setShowPaymentModal(false)}
|
onClose={() => setShowPaymentModal(false)}
|
||||||
onSuccess={() => {
|
|
||||||
setInvoices((prev) =>
|
|
||||||
prev.map((inv) =>
|
|
||||||
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setShowPaymentModal(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentModalWrapperProps {
|
function PaymentModal({
|
||||||
sessionId: string;
|
pending,
|
||||||
publishableKey: string;
|
totalPending: _totalPending,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
pending: Invoice[];
|
pending: Invoice[];
|
||||||
|
totalPending: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
}) {
|
||||||
}
|
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
|
||||||
|
new Set(pending.map((i) => i.id))
|
||||||
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
|
|
||||||
const [stripePromise] = useState(() =>
|
|
||||||
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
|
||||||
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
|
|
||||||
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
|
|
||||||
</Elements>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaymentModalProps {
|
|
||||||
sessionId: string;
|
|
||||||
pending: Invoice[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
|
|
||||||
const stripe = useStripe();
|
|
||||||
const elements = useElements();
|
|
||||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
|
|
||||||
const [saveCard, setSaveCard] = useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const formatCents = (cents: number) =>
|
const formatCents = (cents: number) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(cents / 100);
|
||||||
|
|
||||||
const toggleInvoice = (id: string) => {
|
const toggleInvoice = (id: string) => {
|
||||||
const next = new Set(selectedInvoices);
|
const next = new Set(selectedInvoices);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) {
|
||||||
else next.add(id);
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
setSelectedInvoices(next);
|
setSelectedInvoices(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
|
|
||||||
|
|
||||||
const handlePay = async () => {
|
const handlePay = async () => {
|
||||||
if (!stripe || !elements) return;
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setError(null);
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
setIsProcessing(false);
|
||||||
try {
|
setIsComplete(true);
|
||||||
const isMulti = selectedInvoices.size > 1;
|
|
||||||
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
|
|
||||||
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
|
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Impersonation-Session-Id": sessionId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error ?? "Failed to initialize payment");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { clientSecret } = await res.json();
|
|
||||||
|
|
||||||
const { error: stripeError } = await stripe.confirmPayment({
|
|
||||||
elements,
|
|
||||||
clientSecret,
|
|
||||||
confirmParams: saveCard
|
|
||||||
? { setup_future_usage: "off_session" }
|
|
||||||
: undefined,
|
|
||||||
redirect: "if_required",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stripeError) {
|
|
||||||
setError(stripeError.message ?? "Payment failed");
|
|
||||||
setIsProcessing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsComplete(true);
|
|
||||||
onSuccess();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "An unexpected error occurred");
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedTotal = pending
|
||||||
|
.filter((i) => selectedInvoices.has(i.id))
|
||||||
|
.reduce((sum, i) => sum + i.totalCents, 0);
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
@@ -431,7 +357,10 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
<p className="text-stone-500 text-sm mb-6">
|
<p className="text-stone-500 text-sm mb-6">
|
||||||
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
|
||||||
</p>
|
</p>
|
||||||
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,36 +408,22 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span>
|
<span className="text-sm font-medium text-stone-800">
|
||||||
|
{formatCents(inv.totalCents)}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-stone-600">Total</span>
|
<span className="text-sm text-stone-600">Total</span>
|
||||||
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
|
<span className="text-lg font-bold text-stone-800">
|
||||||
|
{formatCents(selectedTotal)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaymentElement />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 mb-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={saveCard}
|
|
||||||
onChange={(e) => setSaveCard(e.target.checked)}
|
|
||||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-stone-600">Save card for future payments</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -518,7 +433,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
|
disabled={selectedInvoices.size === 0 || isProcessing}
|
||||||
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isProcessing ? "Processing..." : "Pay Now"}
|
{isProcessing ? "Processing..." : "Pay Now"}
|
||||||
@@ -529,8 +444,4 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingPayments(props: BillingPaymentsProps) {
|
|
||||||
return <BillingPaymentsInner {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BillingPayments;
|
export default BillingPayments;
|
||||||
+1
-1
Submodule infra updated: b667a3f005...d6c0d13d02
+1
-3
@@ -1,6 +1,4 @@
|
|||||||
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
|
|
||||||
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
|
|
||||||
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
||||||
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
||||||
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
||||||
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "0026_stripe_payment",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"authProviderConfig": {
|
|
||||||
"name": "auth_provider_config",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
|
||||||
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
|
||||||
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
|
||||||
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
|
||||||
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
|
||||||
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
|
||||||
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
|
||||||
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {}
|
|
||||||
},
|
|
||||||
"businessSettings": {
|
|
||||||
"name": "business_settings",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
|
||||||
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
|
||||||
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
|
||||||
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
|
||||||
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
|
||||||
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {}
|
|
||||||
},
|
|
||||||
"clients": {
|
|
||||||
"name": "clients",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
|
||||||
"email": { "name": "email", "type": "text", "isNullable": true },
|
|
||||||
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
|
||||||
"address": { "name": "address", "type": "text", "isNullable": true },
|
|
||||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
|
||||||
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
|
||||||
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
|
||||||
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
|
||||||
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
|
||||||
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
|
||||||
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
|
||||||
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
|
||||||
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
|
||||||
},
|
|
||||||
"invoices": {
|
|
||||||
"name": "invoices",
|
|
||||||
"columns": {
|
|
||||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
|
||||||
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
|
||||||
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
|
||||||
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
|
||||||
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
|
||||||
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
|
||||||
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
|
||||||
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
|
||||||
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
|
||||||
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
|
||||||
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
|
||||||
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
|
||||||
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
|
||||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
|
||||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
|
||||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
|
||||||
},
|
|
||||||
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
|
||||||
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {
|
|
||||||
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
|
||||||
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
|
||||||
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
|
||||||
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
|
||||||
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
|
||||||
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
|
||||||
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
|
||||||
},
|
|
||||||
"nativeEnums": {}
|
|
||||||
}
|
|
||||||
@@ -183,13 +183,6 @@
|
|||||||
"when": 1775482467192,
|
"when": 1775482467192,
|
||||||
"tag": "0025_rate_limit",
|
"tag": "0025_rate_limit",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 26,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775568867192,
|
|
||||||
"tag": "0026_stripe_payment",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
|||||||
address: "1 Main St, Springfield, CA 90000",
|
address: "1 Main St, Springfield, CA 90000",
|
||||||
notes: null,
|
notes: null,
|
||||||
emailOptOut: false,
|
emailOptOut: false,
|
||||||
stripeCustomerId: null,
|
|
||||||
status: "active",
|
status: "active",
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ export const clients = pgTable("clients", {
|
|||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
address: text("address"),
|
address: text("address"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
// Set to true if the client has opted out of email reminders/notifications
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
disabledAt: timestamp("disabled_at"),
|
disabledAt: timestamp("disabled_at"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
@@ -262,7 +262,7 @@ export const invoices = pgTable(
|
|||||||
index("idx_invoices_client_id").on(t.clientId),
|
index("idx_invoices_client_id").on(t.clientId),
|
||||||
index("idx_invoices_status").on(t.status),
|
index("idx_invoices_status").on(t.status),
|
||||||
index("idx_invoices_created_at").on(t.createdAt),
|
index("idx_invoices_created_at").on(t.createdAt),
|
||||||
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Generated
-54
@@ -86,12 +86,6 @@ importers:
|
|||||||
'@groombook/types':
|
'@groombook/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/types
|
version: link:../../packages/types
|
||||||
'@stripe/react-stripe-js':
|
|
||||||
specifier: ^6.1.0
|
|
||||||
version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
||||||
'@stripe/stripe-js':
|
|
||||||
specifier: ^9.1.0
|
|
||||||
version: 9.1.0
|
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
|
||||||
@@ -2118,17 +2112,6 @@ packages:
|
|||||||
'@standard-schema/utils@0.3.0':
|
'@standard-schema/utils@0.3.0':
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@stripe/react-stripe-js@6.1.0':
|
|
||||||
resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==}
|
|
||||||
peerDependencies:
|
|
||||||
'@stripe/stripe-js': '>=9.0.0 <10.0.0'
|
|
||||||
react: '>=16.8.0 <20.0.0'
|
|
||||||
react-dom: '>=16.8.0 <20.0.0'
|
|
||||||
|
|
||||||
'@stripe/stripe-js@9.1.0':
|
|
||||||
resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==}
|
|
||||||
engines: {node: '>=12.16'}
|
|
||||||
|
|
||||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||||
|
|
||||||
@@ -3628,10 +3611,6 @@ packages:
|
|||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
|
||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@@ -3723,10 +3702,6 @@ packages:
|
|||||||
nwsapi@2.2.23:
|
nwsapi@2.2.23:
|
||||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
object-inspect@1.13.4:
|
object-inspect@1.13.4:
|
||||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3844,9 +3819,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3859,9 +3831,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
react: ^19.2.4
|
||||||
|
|
||||||
react-is@16.13.1:
|
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
|
||||||
|
|
||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
@@ -6714,15 +6683,6 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
|
||||||
dependencies:
|
|
||||||
'@stripe/stripe-js': 9.1.0
|
|
||||||
prop-types: 15.8.1
|
|
||||||
react: 19.2.4
|
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
|
||||||
|
|
||||||
'@stripe/stripe-js@9.1.0': {}
|
|
||||||
|
|
||||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
ejs: 3.1.10
|
ejs: 3.1.10
|
||||||
@@ -8277,10 +8237,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
|
||||||
dependencies:
|
|
||||||
js-tokens: 4.0.0
|
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
@@ -8355,8 +8311,6 @@ snapshots:
|
|||||||
|
|
||||||
nwsapi@2.2.23: {}
|
nwsapi@2.2.23: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|
||||||
object-keys@1.1.1: {}
|
object-keys@1.1.1: {}
|
||||||
@@ -8461,12 +8415,6 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
prop-types@15.8.1:
|
|
||||||
dependencies:
|
|
||||||
loose-envify: 1.4.0
|
|
||||||
object-assign: 4.1.1
|
|
||||||
react-is: 16.13.1
|
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
@@ -8478,8 +8426,6 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||||
|
|||||||
Reference in New Issue
Block a user