feat(rental): Sprint D — panier + checkout + intégration carbet
Some checks failed
CI / test (pull_request) Failing after 1m8s

Cart lib + cookie persistence (karbe-rental-cart, 30j) avec context React
useCart(). Provider wrappé dans layout pour hydratation server→client.

Page /panier :
- Récap regroupé par prestataire (sous-totaux, caution)
- Édition lignes (dates, qté), suppression, vider panier
- Bouton « Valider et payer » → POST /api/rentals/checkout
- Badge 🛒 dans SiteHeader avec total items

Composant <AddToCart /> sur /materiel/[itemId] avec date picker + qté.

API POST /api/rentals/checkout :
- Validation auth + items actifs + provider approved + qté/dates
- Transaction Prisma : recheck stock par fenêtre + crée 1 RentalBooking
  par prestataire + RentalLines (snapshot prix) + RentalItemAvailability
  (blocage des dispos)
- Calcul commissionAmount selon provider.commissionPct
- Si Stripe activé : Checkout Session unique avec 1 line_item par
  RentalBooking, metadata {type:"rental-bundle", rentalBookingIds:[]}
- Sinon : crée en PENDING, retourne rentalBookingIds
- Vide le cookie panier après création
- Audit log rental.checkout.created

Webhook Stripe étendu :
- checkout.session.completed type=rental-bundle → CONFIRMED+SUCCEEDED
  sur toutes les RentalBookings du bundle
- payment_intent.payment_failed metadata.rentalBookingIds → CANCELLED
  + supprime les RentalItemAvailability (libère le stock)

Intégration carbet :
- /carbets/[slug] : panneau « Compléter votre séjour » avec items des
  prestataires de la même rivière + System D (recommandation contextuelle)
- /reservations/[id] : section « Matériel associé » listant les
  RentalBookings liées
- /mes-locations : page récap toutes les locations (System D + tiers,
  liées carbet ou standalone)
- Lien « Mes locations » dans SiteHeader

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 08:41:53 +00:00
parent 1165f32a63
commit 91b4d918ea
16 changed files with 1309 additions and 7 deletions

View file

@ -0,0 +1,313 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { Prisma } from "@/generated/prisma/client";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
import {
getStripeClient,
isStripeConfigured,
toStripeAmountCents,
} from "@/lib/stripe";
export const runtime = "nodejs";
type LineInput = {
itemId: string;
qty: number;
startDate: Date;
endDate: Date;
nights: number;
};
function parseDateOnly(s: string): Date {
return new Date(s + "T00:00:00Z");
}
export async function POST() {
const session = await auth();
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
}
const jar = await cookies();
const cart = parseCart(jar.get(CART_COOKIE)?.value);
if (cart.items.length === 0) {
return NextResponse.json({ error: "Panier vide." }, { status: 400 });
}
// Charge tous les items du panier
const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId)));
const items = await prisma.rentalItem.findMany({
where: { id: { in: itemIds }, active: true },
include: {
provider: {
select: {
id: true,
name: true,
active: true,
approved: true,
commissionPct: true,
isSystemD: true,
},
},
},
});
const itemById = new Map(items.map((i) => [i.id, i]));
// Validations préliminaires : items valides + provider actif/approved
for (const entry of cart.items) {
const it = itemById.get(entry.itemId);
if (!it) {
return NextResponse.json(
{ error: `Item ${entry.itemId} introuvable ou désactivé.` },
{ status: 409 },
);
}
if (!it.provider.active || !it.provider.approved) {
return NextResponse.json(
{ error: `Prestataire ${it.provider.name} indisponible.` },
{ status: 409 },
);
}
if (entry.qty < 1 || entry.qty > it.totalQty) {
return NextResponse.json(
{ error: `Quantité invalide pour « ${it.name} ».` },
{ status: 400 },
);
}
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) {
return NextResponse.json(
{ error: `Dates invalides pour « ${it.name} ».` },
{ status: 400 },
);
}
}
// Groupe par provider
type Group = {
providerId: string;
providerName: string;
commissionPct: number;
lines: LineInput[];
itemsTotal: Prisma.Decimal;
depositTotal: Prisma.Decimal;
startDate: Date;
endDate: Date;
};
const groups = new Map<string, Group>();
for (const entry of cart.items) {
const it = itemById.get(entry.itemId)!;
const start = parseDateOnly(entry.startDate);
const end = parseDateOnly(entry.endDate);
const nights = Math.max(1, diffDays(entry.startDate, entry.endDate));
const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights);
const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty);
let g = groups.get(it.provider.id);
if (!g) {
g = {
providerId: it.provider.id,
providerName: it.provider.name,
commissionPct: Number(it.provider.commissionPct),
lines: [],
itemsTotal: new Prisma.Decimal(0),
depositTotal: new Prisma.Decimal(0),
startDate: start,
endDate: end,
};
groups.set(it.provider.id, g);
}
g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights });
g.itemsTotal = g.itemsTotal.add(lineSub);
g.depositTotal = g.depositTotal.add(lineDeposit);
if (start < g.startDate) g.startDate = start;
if (end > g.endDate) g.endDate = end;
}
// Transaction : recheck stock + crée RentalBookings + Lines + Availabilities
let grandTotal = new Prisma.Decimal(0);
let grandDeposit = new Prisma.Decimal(0);
let rentalBookingIds: string[] = [];
try {
rentalBookingIds = await prisma.$transaction(async (tx) => {
const created: string[] = [];
for (const g of groups.values()) {
// Recheck stock disponible pour chaque ligne
for (const line of g.lines) {
const blocked = await tx.rentalItemAvailability.aggregate({
where: {
itemId: line.itemId,
startDate: { lt: line.endDate },
endDate: { gt: line.startDate },
},
_sum: { qty: true },
});
const item = itemById.get(line.itemId)!;
const used = Number(blocked._sum.qty ?? 0);
const free = item.totalQty - used;
if (line.qty > free) {
throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`);
}
}
const commissionAmount = g.itemsTotal
.mul(g.commissionPct)
.div(100)
.toDecimalPlaces(2);
const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2);
const rb = await tx.rentalBooking.create({
data: {
tenantId: session.user!.id!,
providerId: g.providerId,
startDate: g.startDate,
endDate: g.endDate,
status: RentalBookingStatus.PENDING,
paymentStatus: PaymentStatus.PENDING,
itemsTotal: g.itemsTotal.toDecimalPlaces(2),
depositTotal: g.depositTotal.toDecimalPlaces(2),
commissionAmount,
amount,
currency: "EUR",
lines: {
create: g.lines.map((line) => {
const item = itemById.get(line.itemId)!;
const lineTotal = new Prisma.Decimal(item.pricePerDay)
.mul(line.qty)
.mul(line.nights)
.toDecimalPlaces(2);
return {
itemId: line.itemId,
qty: line.qty,
pricePerDay: new Prisma.Decimal(item.pricePerDay),
deposit: new Prisma.Decimal(item.deposit),
lineTotal,
};
}),
},
},
select: { id: true },
});
// Bloque les dispos
for (const line of g.lines) {
await tx.rentalItemAvailability.create({
data: {
itemId: line.itemId,
startDate: line.startDate,
endDate: line.endDate,
qty: line.qty,
reason: "RENTAL_BOOKING",
rentalBookingId: rb.id,
},
});
}
created.push(rb.id);
grandTotal = grandTotal.add(g.itemsTotal);
grandDeposit = grandDeposit.add(g.depositTotal);
}
return created;
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Erreur lors de la création." },
{ status: 409 },
);
}
const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2);
await recordAudit({
scope: "rental",
event: "rental.checkout.created",
target: rentalBookingIds.join(","),
actorEmail: session.user.email,
details: {
rentalBookingIds,
amount: totalAmount.toNumber(),
depositTotal: grandDeposit.toNumber(),
providers: Array.from(groups.keys()),
},
});
// Vide le panier
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
httpOnly: false,
sameSite: "lax",
path: "/",
maxAge: 0,
});
// Stripe ou paiement différé
if (!isStripeConfigured()) {
return NextResponse.json(
{ rentalBookingIds, totalAmount: totalAmount.toNumber() },
{ status: 201 },
);
}
const appUrl = process.env.APP_URL;
if (!appUrl) {
return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 });
}
// Une session Stripe avec une ligne par RentalBooking (agrégée)
const stripe = getStripeClient();
const bookingDetails = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true } },
lines: { select: { qty: true, item: { select: { name: true } } } },
},
});
const line_items = bookingDetails.map((rb) => ({
quantity: 1,
price_data: {
currency: "eur",
unit_amount: toStripeAmountCents(Number(rb.amount)),
product_data: {
name: `Matériel — ${rb.provider.name}`,
description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500),
},
},
}));
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`,
cancel_url: `${appUrl}/panier?payment=cancel`,
customer_email: session.user.email,
line_items,
metadata: {
type: "rental-bundle",
rentalBookingIds: rentalBookingIds.join(","),
},
});
await prisma.rentalBooking.updateMany({
where: { id: { in: rentalBookingIds } },
data: { stripeSessionId: checkoutSession.id },
});
return NextResponse.json(
{
rentalBookingIds,
totalAmount: totalAmount.toNumber(),
checkoutSessionId: checkoutSession.id,
checkoutUrl: checkoutSession.url,
},
{ status: 201 },
);
}

View file

@ -4,6 +4,7 @@ import Stripe from "stripe";
import {
BookingStatus,
PaymentStatus,
RentalBookingStatus,
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
@ -51,6 +52,21 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return;
}
if (type === "rental-bundle") {
const idsRaw = session.metadata?.rentalBookingIds;
if (!idsRaw) return;
const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) return;
await prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.SUCCEEDED,
status: RentalBookingStatus.CONFIRMED,
},
});
return;
}
if (type === "owner_subscription") {
const ownerId = session.metadata?.ownerId;
const carbetId = session.metadata?.carbetId;
@ -79,6 +95,27 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) {
const bookingId = paymentIntent.metadata?.bookingId;
const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds;
if (rentalIdsRaw) {
const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
// Marque les paiements échoués + libère les blocages de dispo
await prisma.$transaction([
prisma.rentalBooking.updateMany({
where: { id: { in: ids } },
data: {
paymentStatus: PaymentStatus.FAILED,
status: RentalBookingStatus.CANCELLED,
},
}),
prisma.rentalItemAvailability.deleteMany({
where: { rentalBookingId: { in: ids } },
}),
]);
}
}
if (!bookingId) {
return;
}