feat(payment): intégration Stripe (subscription loueur + booking checkout + webhook)
This commit is contained in:
parent
eb398fe3f5
commit
74f39293cc
5 changed files with 513 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
252
src/app/api/stripe/checkout/booking/route.ts
Normal file
252
src/app/api/stripe/checkout/booking/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
77
src/app/api/stripe/checkout/subscription/route.ts
Normal file
77
src/app/api/stripe/checkout/subscription/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
149
src/app/api/stripe/webhook/route.ts
Normal file
149
src/app/api/stripe/webhook/route.ts
Normal 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
33
src/lib/stripe.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue