feat(rental): Sprint D — panier + checkout + intégration carbet
Some checks failed
CI / test (pull_request) Failing after 1m8s
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:
parent
1165f32a63
commit
91b4d918ea
16 changed files with 1309 additions and 7 deletions
313
src/app/api/rentals/checkout/route.ts
Normal file
313
src/app/api/rentals/checkout/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue