Compare commits

..

7 Commits

Author SHA1 Message Date
Paperclip a7e98c0582 feat(GRO-608): Add Stripe webhook handler for payment events
- Add POST /api/webhooks/stripe endpoint for Stripe event processing
- Handle payment_intent.succeeded: mark invoice as paid, set paymentMethod=card
- Handle payment_intent.payment_failed: record failure reason on invoice
- Handle charge.refunded: mark invoice as void
- Handle charge.dispute.created: log dispute
- Idempotency: skip if stripePaymentIntentId already recorded
- Add stripe_payment_intent_id, stripe_refund_id, payment_failure_reason columns
- Requires STRIPE_WEBHOOK_SECRET env var for signature verification

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 00:09:46 +00:00
groombook-qa[bot] 4f6a1e8149 fix(GRO-574): switch rate limit to memory storage to unblock UAT
* feat(GRO-566): add SKIP_OOBE env var to bypass setup wizard

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* Add rate_limit table migration for Better Auth (GRO-574)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-574): switch rate limit to memory storage to unblock UAT

Better Auth rate_limit table migration exists on branch but hasn't
been deployed to UAT. Switching to memory storage bypasses the
missing table entirely, restoring auth functionality immediately.

Memory storage is per-instance (not shared) — rate limiting still
functions but won't be distributed across pods. This is acceptable
for UAT while the migration is being promoted through the pipeline.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: groombook-qa[bot] <269744346+groombook-qa[bot]@users.noreply.github.com>
2026-04-12 12:20:00 +00:00
groombook-cto[bot] be3cfa9a54 Merge pull request #268 from groombook/feature/gro-565-better-auth-phase3
feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification
2026-04-12 11:29:06 +00:00
groombook-cto[bot] 06e7ddaa61 Merge branch 'main' into feature/gro-565-better-auth-phase3 2026-04-12 11:25:35 +00:00
groombook-cto[bot] 15131b72f0 fix(GRO-574): add rate_limit table migration for Better Auth
Adds the missing rate_limit table that Better Auth v1.5.6 requires when rateLimit.storage is set to 'database'. Without this table, all auth endpoints return HTTP 500.

Also includes GRO-566: SKIP_OOBE env var to bypass setup wizard in dev/test.

cc @cpfarhood
2026-04-12 03:30:45 +00:00
Paperclip bc1f11a901 feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 02:47:17 +00:00
groombook-cto[bot] f4e34f2826 fix(GRO-564): prevent admin nav logout button overflow
fix(GRO-564): prevent admin nav logout button overflow
2026-04-12 02:31:46 +00:00
10 changed files with 168 additions and 3 deletions
+1
View File
@@ -22,6 +22,7 @@
"hono": "^4.6.17",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
+4
View File
@@ -28,6 +28,7 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
const app = new Hono();
@@ -50,6 +51,9 @@ app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header
app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
+2 -2
View File
@@ -95,7 +95,7 @@ export async function initAuth(): Promise<void> {
enabled: true,
max: 10,
window: 60,
storage: "database",
storage: "memory",
},
plugins: [
genericOAuth({
@@ -234,7 +234,7 @@ export async function initAuth(): Promise<void> {
enabled: true,
max: 10,
window: 60,
storage: "database",
storage: "memory",
},
account: {
storeStateStrategy: "cookie" as const,
+16 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>();
@@ -108,6 +108,21 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
}
}
if (!resolvedStaff && jwt.email) {
// Try auto-link by email: staff record exists with matching email but no userId
const [byEmail] = await tx
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
if (byEmail) {
await tx
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, byEmail.id));
resolvedStaff = { ...byEmail, userId: jwt.sub };
}
}
if (!resolvedStaff) {
// Brand new user during OOBE — create staff record
if (!jwt.email) {
+108
View File
@@ -0,0 +1,108 @@
import { Hono } from "hono";
import Stripe from "stripe";
import { eq, getDb, invoices } from "@groombook/db";
export const webhooksRouter = new Hono();
webhooksRouter.post("/stripe", async (c) => {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
return c.json({ error: "Webhook secret not configured" }, 503);
}
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "Missing signature" }, 401);
}
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return c.json({ error: message }, 401);
}
const db = getDb();
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const [inv] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
await db
.update(invoices)
.set({
status: "paid",
paymentMethod: "card",
paidAt: new Date(),
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
}
}
} else if (event.type === "payment_intent.payment_failed") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
await db
.update(invoices)
.set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
}
}
} else if (event.type === "charge.refunded") {
const charge = event.data.object as Stripe.Charge;
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
const [inv] = await db
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
.limit(1);
if (inv) {
const refundId =
typeof charge.refunded === "boolean" && charge.refunded
? `ch_${charge.id}_refund`
: null;
await db
.update(invoices)
.set({
status: "void",
stripeRefundId: refundId,
updatedAt: new Date(),
})
.where(eq(invoices.id, inv.id));
}
}
} else if (event.type === "charge.dispute.created") {
const dispute = event.data.object as Stripe.Dispute;
console.error(
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
);
}
return c.json({ received: true });
});
@@ -0,0 +1,6 @@
-- Better-Auth rate limiting table (GRO-574)
CREATE TABLE "rate_limit" (
key TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL,
last_request BIGINT NOT NULL
);
@@ -0,0 +1,4 @@
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" 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");
@@ -176,6 +176,13 @@
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
}
]
}
+4
View File
@@ -251,6 +251,9 @@ export const invoices = pgTable(
status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
@@ -259,6 +262,7 @@ export const invoices = pgTable(
index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt),
unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
]
);
+16
View File
@@ -40,6 +40,9 @@ importers:
nodemailer:
specifier: ^6.9.16
version: 6.10.1
stripe:
specifier: ^22.0.0
version: 22.0.1(@types/node@22.19.15)
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -4124,6 +4127,15 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@22.0.1:
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strnum@2.2.1:
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
@@ -8774,6 +8786,10 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@22.0.1(@types/node@22.19.15):
optionalDependencies:
'@types/node': 22.19.15
strnum@2.2.1: {}
supports-color@7.2.0: