feat(GRO-607): Stripe Elements payment UI replacing mock flow
* GRO-605: Stripe SDK integration + payment service Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds) Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(GRO-597): Stripe payment backend — schema, service, API, webhooks Consolidates GRO-605, GRO-606, GRO-608 into a single clean PR: - GRO-605: Stripe SDK integration + payment service - GRO-606: Payment API endpoints (pay invoice, payment methods, refunds) - GRO-608: Stripe webhook handler Migration consolidation: - Single 0026_stripe_payment.sql migration adds stripeCustomerId to clients and stripe_payment_intent_id, stripe_refund_id, payment_failure_reason to invoices - Removed duplicate 0027_stripe_identifiers.sql Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-607: Install Stripe frontend packages Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-607: Add /portal/config endpoint + rename date field Co-Authored-By: Paperclip <noreply@paperclip.ing> * GRO-607: Replace mock payment flow with real Stripe Elements Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-607): Stripe Elements payment UI - lint/type fixes Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-607): remove unused eslint-disable directive in CustomerPortal Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-607): CTO review fixes — payment security and correctness - Fix multi-invoice total calculation: use inArray() instead of eq() on single ID, sum all invoices not just first - Add ownership check to payment method deletion: verify the payment method belongs to the authenticated Stripe customer before detaching - Remove duplicate /config endpoint in portal.ts - Fix webhook Stripe client: use getStripeClient() from payment service instead of constructing with WEBHOOK_SECRET - Remove unnecessary body validator on /invoices/:id/pay route - Export getStripeClient() for use by stripe-webhooks.ts - Add inArray import to payment.ts Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #275.
This commit is contained in:
committed by
GitHub
parent
4f6a1e8149
commit
c438f5772c
@@ -0,0 +1,112 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { eq, getDb, invoices } from "@groombook/db";
|
||||
import { getStripeClient } from "../services/payment.js";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
webhooksRouter.post("/stripe", async (c) => {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
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 = getStripeClient();
|
||||
if (!stripe) {
|
||||
return c.json({ error: "Stripe not configured" }, 503);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} 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