karbe/src/app/api/stripe/webhook/route.ts
Karbé Architect c9be24a969 SYS-18: add production deployment stack for karbe.cosmolan.fr (Stripe test)
- enable Next.js standalone output and add Docker/Caddy production stack
- add production env template and deployment runbook
- add healthcheck endpoint for container supervision
- fix existing lint/type blockers discovered during validation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-30 18:01:56 +00:00

163 lines
4.7 KiB
TypeScript

import { NextResponse } from "next/server";
import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
import { prisma } from "@/lib/prisma";
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
export const runtime = "nodejs";
function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
switch (status) {
case "trialing":
return SubscriptionStatus.TRIAL;
case "active":
return SubscriptionStatus.ACTIVE;
case "past_due":
case "unpaid":
case "paused":
return SubscriptionStatus.PAST_DUE;
case "canceled":
case "incomplete_expired":
return SubscriptionStatus.CANCELED;
case "incomplete":
return SubscriptionStatus.TRIAL;
default:
return SubscriptionStatus.TRIAL;
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const bookingId = session.metadata?.bookingId;
const type = session.metadata?.type;
if (type === "booking" && bookingId) {
const booking = await prisma.booking.update({
where: { id: bookingId },
data: {
paymentStatus: PaymentStatus.SUCCEEDED,
status: BookingStatus.CONFIRMED,
},
select: {
carbetId: true,
},
});
await refreshCarbetLastBookedAt(booking.carbetId);
return;
}
if (type === "owner_subscription") {
const ownerId = session.metadata?.ownerId;
const carbetId = session.metadata?.carbetId;
const providerSubId = typeof session.subscription === "string" ? session.subscription : null;
if (!ownerId || !carbetId || !providerSubId) {
return;
}
await prisma.subscription.upsert({
where: { providerSubId },
update: {
status: SubscriptionStatus.ACTIVE,
renewedAt: new Date(),
},
create: {
ownerId,
carbetId,
provider: "stripe",
providerSubId,
status: SubscriptionStatus.ACTIVE,
},
});
}
}
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
const bookingId = paymentIntent.metadata?.bookingId;
if (!bookingId) {
return;
}
const booking = await prisma.booking.update({
where: { id: bookingId },
data: {
paymentStatus: PaymentStatus.FAILED,
status: BookingStatus.CANCELLED,
},
select: {
carbetId: true,
},
});
await refreshCarbetLastBookedAt(booking.carbetId);
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const currentPeriodEnd =
subscription.items.data[0]?.current_period_end ??
subscription.trial_end ??
subscription.canceled_at;
await prisma.subscription.upsert({
where: { providerSubId: subscription.id },
update: {
status: mapStripeSubscriptionStatus(subscription.status),
renewedAt: fromStripeTimestamp(currentPeriodEnd),
canceledAt: fromStripeTimestamp(subscription.canceled_at),
},
create: {
ownerId: subscription.metadata.ownerId,
carbetId: subscription.metadata.carbetId,
provider: "stripe",
providerSubId: subscription.id,
status: mapStripeSubscriptionStatus(subscription.status),
startedAt: fromStripeTimestamp(subscription.start_date) ?? new Date(),
renewedAt: fromStripeTimestamp(currentPeriodEnd),
canceledAt: fromStripeTimestamp(subscription.canceled_at),
},
});
}
export async function POST(request: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json({ error: "STRIPE_WEBHOOK_SECRET manquante." }, { status: 500 });
}
const stripe = getStripeClient();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Signature Stripe absente." }, { status: 400 });
}
const payload = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, secret);
} catch {
return NextResponse.json({ error: "Signature Stripe invalide." }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case "payment_intent.payment_failed":
await handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
default:
break;
}
return NextResponse.json({ received: true });
}