feat(payment): intégration Stripe (subscription loueur + booking checkout + webhook)

This commit is contained in:
Claude Integration 2026-05-30 15:00:21 +00:00
parent eb398fe3f5
commit 74f39293cc
5 changed files with 513 additions and 1 deletions

View file

@ -18,7 +18,8 @@
"next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"stripe": "^18.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View file

@ -0,0 +1,252 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
AvailabilityScope,
BookingStatus,
CarbetStatus,
PaymentStatus,
UserRole,
} from "@/generated/prisma/enums";
import {
enumerateUtcDays,
hasOverlap,
isCeUserRole,
isPublicAllowedByDefaultPolicy,
normalizeUtcDayStart,
parseIsoDate,
} from "@/lib/booking";
import { prisma } from "@/lib/prisma";
import { getStripeClient, toStripeAmountCents } from "@/lib/stripe";
export const runtime = "nodejs";
type BookingCheckoutBody = {
carbetId?: string;
startDate?: string;
endDate?: string;
guestCount?: number;
amount?: number;
currency?: string;
};
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
let body: BookingCheckoutBody;
try {
body = (await request.json()) as BookingCheckoutBody;
} catch {
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
}
if (!body.carbetId) {
return NextResponse.json({ error: "carbetId requis." }, { status: 400 });
}
const startDateRaw = parseIsoDate(body.startDate);
const endDateRaw = parseIsoDate(body.endDate);
const guestCount = Number(body.guestCount);
const amount = Number(body.amount);
const currency = (body.currency ?? "EUR").toLowerCase();
if (!startDateRaw || !endDateRaw) {
return NextResponse.json(
{ error: "startDate et endDate valides sont requis." },
{ status: 400 },
);
}
const startDate = normalizeUtcDayStart(startDateRaw);
const endDate = normalizeUtcDayStart(endDateRaw);
if (endDate <= startDate) {
return NextResponse.json(
{ error: "La date de fin doit être après la date de début." },
{ status: 400 },
);
}
if (!Number.isInteger(guestCount) || guestCount <= 0) {
return NextResponse.json(
{ error: "guestCount doit être un entier strictement positif." },
{ status: 400 },
);
}
if (!Number.isFinite(amount) || amount <= 0) {
return NextResponse.json({ error: "amount doit être > 0." }, { status: 400 });
}
const carbet = await prisma.carbet.findUnique({
where: { id: body.carbetId },
select: {
id: true,
ownerId: true,
title: true,
capacity: true,
status: true,
},
});
if (!carbet) {
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
}
const isManager =
session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId;
if (!isManager && carbet.status !== CarbetStatus.PUBLISHED) {
return NextResponse.json({ error: "Carbet indisponible." }, { status: 404 });
}
if (guestCount > carbet.capacity) {
return NextResponse.json(
{ error: `Capacité max dépassée (${carbet.capacity}).` },
{ status: 400 },
);
}
const [overlappingBookings, availabilities] = await Promise.all([
prisma.booking.findMany({
where: {
carbetId: carbet.id,
status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] },
startDate: { lt: endDate },
endDate: { gt: startDate },
},
select: { id: true, startDate: true, endDate: true },
}),
prisma.availability.findMany({
where: {
carbetId: carbet.id,
startDate: { lt: endDate },
endDate: { gt: startDate },
},
select: {
id: true,
startDate: true,
endDate: true,
isAvailable: true,
scope: true,
},
}),
]);
if (overlappingBookings.length > 0) {
return NextResponse.json(
{ error: "Ce créneau est déjà réservé." },
{ status: 409 },
);
}
const ceAccess = isCeUserRole(session.user.role);
const days = enumerateUtcDays(startDate, endDate);
for (const day of days) {
const nextDay = new Date(day);
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
const coveredSlots = availabilities.filter((a) =>
hasOverlap(day, nextDay, a.startDate, a.endDate),
);
if (coveredSlots.length === 0) {
const defaultAllowed = isManager || ceAccess || isPublicAllowedByDefaultPolicy(day);
if (defaultAllowed) {
continue;
}
return NextResponse.json(
{
error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (week-end réservé CE).`,
},
{ status: 409 },
);
}
const allowedSlot = coveredSlots.find((slot) => {
if (!slot.isAvailable) {
return false;
}
if (slot.scope === AvailabilityScope.CE_ONLY && !ceAccess && !isManager) {
return false;
}
return true;
});
if (!allowedSlot) {
return NextResponse.json(
{
error: `Créneau non réservable le ${day.toISOString().slice(0, 10)} (restriction CE ou blocage).`,
},
{ status: 409 },
);
}
}
const booking = await prisma.booking.create({
data: {
carbetId: carbet.id,
tenantId: session.user.id,
startDate,
endDate,
guestCount,
status: BookingStatus.PENDING,
paymentStatus: PaymentStatus.PENDING,
amount,
currency: currency.toUpperCase(),
},
select: {
id: true,
amount: true,
currency: true,
startDate: true,
endDate: true,
},
});
const appUrl = process.env.APP_URL;
if (!appUrl) {
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
}
const stripe = getStripeClient();
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
success_url: `${appUrl}/reservations/${booking.id}?payment=success`,
cancel_url: `${appUrl}/reservations/${booking.id}?payment=cancel`,
customer_email: session.user.email ?? undefined,
line_items: [
{
quantity: 1,
price_data: {
currency,
unit_amount: toStripeAmountCents(amount),
product_data: {
name: `Réservation carbet: ${carbet.title}`,
description: `${booking.startDate.toISOString().slice(0, 10)} au ${booking.endDate.toISOString().slice(0, 10)}`,
},
},
},
],
metadata: {
bookingId: booking.id,
type: "booking",
},
});
return NextResponse.json(
{
bookingId: booking.id,
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
},
{ status: 201 },
);
}

View file

@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
import { getStripeClient } from "@/lib/stripe";
export const runtime = "nodejs";
type SubscriptionCheckoutBody = {
carbetId?: string;
};
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
let body: SubscriptionCheckoutBody;
try {
body = (await request.json()) as SubscriptionCheckoutBody;
} catch {
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
}
if (!body.carbetId) {
return NextResponse.json({ error: "carbetId requis." }, { status: 400 });
}
const carbet = await prisma.carbet.findUnique({
where: { id: body.carbetId },
select: { id: true, ownerId: true, title: true },
});
if (!carbet) {
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
}
const canManage =
session.user.role === UserRole.ADMIN || session.user.id === carbet.ownerId;
if (!canManage) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
const priceId = process.env.STRIPE_OWNER_SUBSCRIPTION_PRICE_ID;
const appUrl = process.env.APP_URL;
if (!priceId || !appUrl) {
return NextResponse.json(
{ error: "Configuration Stripe abonnement incomplète." },
{ status: 500 },
);
}
const stripe = getStripeClient();
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
success_url: `${appUrl}/espace-hote/carbets/${carbet.id}?subscription=success`,
cancel_url: `${appUrl}/espace-hote/carbets/${carbet.id}?subscription=cancel`,
customer_email: session.user.email ?? undefined,
line_items: [{ price: priceId, quantity: 1 }],
metadata: {
ownerId: carbet.ownerId,
carbetId: carbet.id,
type: "owner_subscription",
},
});
return NextResponse.json(
{
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
},
{ status: 201 },
);
}

View file

@ -0,0 +1,149 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
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) {
await prisma.booking.update({
where: { id: bookingId },
data: {
paymentStatus: PaymentStatus.SUCCEEDED,
status: BookingStatus.CONFIRMED,
},
});
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;
}
await prisma.booking.update({
where: { id: bookingId },
data: {
paymentStatus: PaymentStatus.FAILED,
status: BookingStatus.CANCELLED,
},
});
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
await prisma.subscription.upsert({
where: { providerSubId: subscription.id },
update: {
status: mapStripeSubscriptionStatus(subscription.status),
renewedAt: fromStripeTimestamp(subscription.current_period_end),
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(subscription.current_period_end),
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 });
}

33
src/lib/stripe.ts Normal file
View file

@ -0,0 +1,33 @@
import Stripe from "stripe";
let stripeClient: Stripe | null = null;
export function getStripeClient(): Stripe {
if (stripeClient) {
return stripeClient;
}
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error("STRIPE_SECRET_KEY manquante.");
}
stripeClient = new Stripe(secretKey);
return stripeClient;
}
export function toStripeAmountCents(amount: number): number {
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error("Montant invalide.");
}
return Math.round(amount * 100);
}
export function fromStripeTimestamp(ts: number | null | undefined): Date | null {
if (!ts) {
return null;
}
return new Date(ts * 1000);
}