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>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user