diff --git a/package.json b/package.json index f6e1dc5..4b2d77d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/stripe/checkout/booking/route.ts b/src/app/api/stripe/checkout/booking/route.ts new file mode 100644 index 0000000..b786f61 --- /dev/null +++ b/src/app/api/stripe/checkout/booking/route.ts @@ -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 }, + ); +} diff --git a/src/app/api/stripe/checkout/subscription/route.ts b/src/app/api/stripe/checkout/subscription/route.ts new file mode 100644 index 0000000..617d449 --- /dev/null +++ b/src/app/api/stripe/checkout/subscription/route.ts @@ -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 }, + ); +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..d3bc17a --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -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 }); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..adda277 --- /dev/null +++ b/src/lib/stripe.ts @@ -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); +}