Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e98c0582 |
@@ -22,6 +22,7 @@
|
|||||||
"hono": "^4.6.17",
|
"hono": "^4.6.17",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
|
"stripe": "^22.0.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
|
|||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
|
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -50,6 +51,9 @@ app.route("/api/book", bookRouter);
|
|||||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||||
app.route("/api/portal", portalRouter);
|
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
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -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,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");
|
||||||
@@ -251,6 +251,9 @@ export const invoices = pgTable(
|
|||||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||||
paymentMethod: paymentMethodEnum("payment_method"),
|
paymentMethod: paymentMethodEnum("payment_method"),
|
||||||
paidAt: timestamp("paid_at"),
|
paidAt: timestamp("paid_at"),
|
||||||
|
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||||
|
stripeRefundId: text("stripe_refund_id"),
|
||||||
|
paymentFailureReason: text("payment_failure_reason"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
@@ -259,6 +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),
|
||||||
|
unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Generated
+16
@@ -40,6 +40,9 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.16
|
specifier: ^6.9.16
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
|
stripe:
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -4124,6 +4127,15 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
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:
|
strnum@2.2.1:
|
||||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||||
|
|
||||||
@@ -8774,6 +8786,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
stripe@22.0.1(@types/node@22.19.15):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.15
|
||||||
|
|
||||||
strnum@2.2.1: {}
|
strnum@2.2.1: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user