feat(rental): Sprint E — emails + plugin toggle + tests
Some checks failed
CI / test (pull_request) Failing after 1m10s

3 nouveaux templates email (best-effort, dry-run sans Resend) :
- sendRentalRequestedTenant : récap de demande au locataire (par RB)
- sendRentalRequestedProvider : nouvelle demande au prestataire
- sendRentalConfirmed : confirmation paiement reçu

Branchements :
- POST /api/rentals/checkout : envoie tenant + provider après création
  des RentalBooking (PENDING), catch global pour ne pas bloquer
- Webhook Stripe rental-bundle : envoie sendRentalConfirmed à chaque
  locataire après update CONFIRMED+SUCCEEDED

Plugin gear-rental :
- Ajout au registry (catégorie business)
- layout.tsx /materiel + /espace-prestataire avec requirePluginOr404
- requirePluginOr404 dans /panier et /mes-locations
- isPluginEnabled guard dans POST /api/rentals/checkout (404 si off)
- SiteHeader masque liens Matériel / Mes locations / Espace prestataire
  + CartBadge si plugin désactivé
- CompleteYourStay renvoie null si plugin désactivé
Décision admin → activable depuis /admin/plugins comme tous les autres.

Tests vitest (tests/lib/rentals.test.ts, 16 tests) :
- diffDays (mêmes dates, 1 nuit, 7 jours, négatif)
- parseCart (null/garbage/schéma invalide/valide/format date)
- serializeCart (updatedAt, roundtrip)
- commission formula (0%, 15%, arrondi centime)
- availability arithmetic (totalQty libre, soustractions, plancher 0)

53 tests pass total. Build OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 08:49:39 +00:00
parent 0723e50189
commit 5607a51980
13 changed files with 313 additions and 8 deletions

View file

@ -5,6 +5,11 @@ import { auth } from "@/auth";
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
import { Prisma } from "@/generated/prisma/client";
import { recordAudit } from "@/lib/admin/audit";
import {
sendRentalRequestedProvider,
sendRentalRequestedTenant,
} from "@/lib/email";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
import {
@ -28,6 +33,9 @@ function parseDateOnly(s: string): Date {
}
export async function POST() {
if (!(await isPluginEnabled("gear-rental"))) {
return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
}
const session = await auth();
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
@ -241,6 +249,46 @@ export async function POST() {
},
});
// Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
// à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
try {
const fullBookings = await prisma.rentalBooking.findMany({
where: { id: { in: rentalBookingIds } },
include: {
provider: { select: { name: true, contactEmail: true } },
lines: { include: { item: { select: { name: true } } } },
},
});
const tenantName = session.user.name ?? session.user.email!;
for (const rb of fullBookings) {
const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
await sendRentalRequestedTenant(
session.user.email!,
tenantName,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
rb.amount.toString(),
rb.currency,
lineSummary,
);
if (rb.provider.contactEmail) {
await sendRentalRequestedProvider(
rb.provider.contactEmail,
rb.provider.name,
rb.id,
tenantName,
rb.startDate,
rb.endDate,
lineSummary,
);
}
}
} catch (e) {
console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
}
// Vide le panier
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
httpOnly: false,

View file

@ -8,6 +8,7 @@ import {
SubscriptionStatus,
} from "@/generated/prisma/enums";
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
import { sendRentalConfirmed } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
@ -64,6 +65,28 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
status: RentalBookingStatus.CONFIRMED,
},
});
try {
const rentals = await prisma.rentalBooking.findMany({
where: { id: { in: ids } },
include: {
provider: { select: { name: true } },
tenant: { select: { email: true, firstName: true } },
},
});
for (const rb of rentals) {
if (!rb.tenant.email) continue;
await sendRentalConfirmed(
rb.tenant.email,
rb.tenant.firstName ?? rb.tenant.email,
rb.id,
rb.provider.name,
rb.startDate,
rb.endDate,
);
}
} catch (e) {
console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
}
return;
}

View file

@ -1,5 +1,6 @@
import Link from "next/link";
import { isPluginEnabled } from "@/lib/plugins/server";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
@ -17,6 +18,7 @@ const EMOJI: Record<string, string> = {
};
export async function CompleteYourStay({ river, capacity }: Props) {
if (!(await isPluginEnabled("gear-rental"))) return null;
const providers = await prisma.rentalProvider.findMany({
where: {
active: true,

View file

@ -0,0 +1,6 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
export default async function ProviderLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("gear-rental");
return <>{children}</>;
}

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { getPublicRentalItem } from "@/lib/rentals-public";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
@ -23,6 +24,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
}
export default async function RentalItemDetailPage({ params }: PageProps) {
await requirePluginOr404("gear-rental");
const { itemId } = await params;
const item = await getPublicRentalItem(itemId);
if (!item) notFound();

View file

@ -0,0 +1,6 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
export default async function MaterielLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("gear-rental");
return <>{children}</>;
}

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { RentalCategory } from "@/generated/prisma/enums";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { isRentalCategory } from "@/lib/rental-category-labels";
import {
listPublicProviders,
@ -29,6 +30,7 @@ type PageProps = {
};
export default async function MaterialPage({ searchParams }: PageProps) {
await requirePluginOr404("gear-rental");
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,

View file

@ -1,6 +1,7 @@
import Link from "next/link";
import { requireAuth } from "@/lib/authorization";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { prisma } from "@/lib/prisma";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
@ -27,6 +28,7 @@ const PAYMENT_LABEL: Record<string, string> = {
type SearchParams = Promise<{ payment?: string; ids?: string; ok?: string }>;
export default async function MyRentalsPage({ searchParams }: { searchParams: SearchParams }) {
await requirePluginOr404("gear-rental");
const session = await requireAuth();
const sp = await searchParams;

View file

@ -1,5 +1,6 @@
import Link from "next/link";
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { prisma } from "@/lib/prisma";
import { readCartFromCookies } from "@/lib/rental-cart-server";
@ -10,6 +11,7 @@ export const dynamic = "force-dynamic";
export const metadata = { title: "Mon panier matériel" };
export default async function CartPage() {
await requirePluginOr404("gear-rental");
const cart = await readCartFromCookies();
// Charge les items du panier en bulk pour rendu

View file

@ -7,6 +7,7 @@ import Link from "next/link";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { isPluginEnabled } from "@/lib/plugins/server";
import { CartBadge } from "./CartBadge";
import { SignOutButton } from "./SignOutButton";
@ -17,6 +18,7 @@ export async function SiteHeader() {
const isAdmin = u?.role === UserRole.ADMIN;
const isOwner = u?.role === UserRole.OWNER || isAdmin;
const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
const rentalEnabled = await isPluginEnabled("gear-rental");
return (
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
@ -35,13 +37,15 @@ export async function SiteHeader() {
<Link href="/carbets" className="hover:text-zinc-900">
Catalogue
</Link>
<Link href="/materiel" className="hover:text-zinc-900">
Matériel
</Link>
{rentalEnabled ? (
<Link href="/materiel" className="hover:text-zinc-900">
Matériel
</Link>
) : null}
</nav>
<div className="flex items-center gap-3 text-sm">
<CartBadge />
{rentalEnabled ? <CartBadge /> : null}
{u ? (
<>
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
@ -50,9 +54,11 @@ export async function SiteHeader() {
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes réservations
</Link>
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes locations
</Link>
{rentalEnabled ? (
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes locations
</Link>
) : null}
<Link href="/mon-compte" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mon compte
</Link>
@ -61,7 +67,7 @@ export async function SiteHeader() {
Espace hôte
</Link>
) : null}
{isRentalProvider ? (
{isRentalProvider && rentalEnabled ? (
<Link href="/espace-prestataire" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Espace prestataire
</Link>

View file

@ -224,6 +224,99 @@ export async function sendPasswordReset(
});
}
type RentalLineSummary = { qty: number; itemName: string };
function renderLines(lines: RentalLineSummary[]): string {
return lines.map((l) => `<li>${l.qty}× ${l.itemName}</li>`).join("");
}
export async function sendRentalRequestedTenant(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
amount: string,
currency: string,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Demande de location matériel — ${providerName}`,
html: wrap(
"Votre demande de location est enregistrée",
`<p>Bonjour ${firstName},</p>
<p>Votre demande de location auprès de <strong>${providerName}</strong> est bien enregistrée :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
</ul>
<p><strong>Matériel demandé :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes locations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalRequestedProvider(
to: string,
providerName: string,
rentalBookingId: string,
tenantName: string,
startDate: Date,
endDate: Date,
lines: RentalLineSummary[],
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Nouvelle demande de location — ${tenantName}`,
html: wrap(
"Nouvelle demande à préparer",
`<p>Bonjour ${providerName},</p>
<p><strong>${tenantName}</strong> vient de réserver du matériel :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
</ul>
<p><strong>Matériel :</strong></p>
<ul>${renderLines(lines)}</ul>
<p>Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.</p>
<p><a href="${SITE_URL}/espace-prestataire/reservations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes réservations</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendRentalConfirmed(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
startDate: Date,
endDate: Date,
): Promise<void> {
const fmt = (d: Date) =>
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
await sendEmail({
to,
subject: `Location confirmée — ${providerName}`,
html: wrap(
"Votre location est confirmée",
`<p>Bonjour ${firstName},</p>
<p>Le paiement de votre location auprès de <strong>${providerName}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est validé.</p>
<p>Le prestataire vous contactera pour organiser la remise du matériel sur place.</p>
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma location</a></p>
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
),
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,

View file

@ -109,6 +109,14 @@ export const PLUGINS: PluginDescriptor[] = [
category: "business",
version: "0.1.0",
},
{
key: "gear-rental",
name: "Location matériel (sous-marketplace)",
description:
"Catalogue matériel (hamac, moustiquaire, pirogue, kayak…) loué par System D et prestataires tiers. Inclut panier, checkout Stripe, espace prestataire, recommandations carbet. Si désactivé : /materiel, /espace-prestataire et /mes-locations renvoient 404; liens header masqués.",
category: "business",
version: "0.1.0",
},
// Contenus / i18n
{

105
tests/lib/rentals.test.ts Normal file
View file

@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { diffDays, parseCart, serializeCart, EMPTY_CART } from "@/lib/rental-cart";
describe("diffDays", () => {
it("renvoie 0 pour mêmes dates", () => {
expect(diffDays("2026-06-01", "2026-06-01")).toBe(0);
});
it("compte 1 nuit entre J et J+1", () => {
expect(diffDays("2026-06-01", "2026-06-02")).toBe(1);
});
it("compte 7 jours sur une semaine", () => {
expect(diffDays("2026-06-01", "2026-06-08")).toBe(7);
});
it("ne renvoie pas de valeur négative si end < start", () => {
expect(diffDays("2026-06-08", "2026-06-01")).toBe(0);
});
});
describe("parseCart", () => {
it("retourne EMPTY_CART pour null/undefined/garbage", () => {
expect(parseCart(null)).toEqual(EMPTY_CART);
expect(parseCart(undefined)).toEqual(EMPTY_CART);
expect(parseCart("")).toEqual(EMPTY_CART);
expect(parseCart("not json")).toEqual(EMPTY_CART);
});
it("retourne EMPTY_CART quand le schéma est invalide", () => {
expect(parseCart(JSON.stringify({ v: 99, items: [] }))).toEqual(EMPTY_CART);
expect(parseCart(JSON.stringify({ v: 1, items: "nope" }))).toEqual(EMPTY_CART);
});
it("accepte un panier valide", () => {
const valid = {
v: 1,
items: [
{ itemId: "abc", qty: 2, startDate: "2026-06-01", endDate: "2026-06-03" },
],
};
const out = parseCart(JSON.stringify(valid));
expect(out.items).toHaveLength(1);
expect(out.items[0].qty).toBe(2);
});
it("rejette une date au mauvais format", () => {
const bad = {
v: 1,
items: [{ itemId: "abc", qty: 1, startDate: "1/6/2026", endDate: "2026-06-03" }],
};
expect(parseCart(JSON.stringify(bad))).toEqual(EMPTY_CART);
});
});
describe("serializeCart", () => {
it("ajoute un updatedAt ISO", () => {
const s = serializeCart({ v: 1, items: [] });
const parsed = JSON.parse(s);
expect(parsed.v).toBe(1);
expect(parsed.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("roundtrip parse(serialize(x)) === x sur les items", () => {
const cart = {
v: 1 as const,
items: [
{ itemId: "k1", qty: 3, startDate: "2026-07-01", endDate: "2026-07-08" },
],
};
const round = parseCart(serializeCart(cart));
expect(round.items).toEqual(cart.items);
});
});
// Calcul de commission (snapshot de la logique métier dans l'API checkout).
// Ce test sert de garde-fou : si la formule change, faire évoluer aussi
// `/api/rentals/checkout` (cf. commissionAmount = itemsTotal * pct / 100).
describe("rental commission formula", () => {
function commission(itemsTotal: number, pct: number): number {
return Math.round((itemsTotal * pct) / 100 * 100) / 100;
}
it("0% commission System D", () => {
expect(commission(120, 0)).toBe(0);
});
it("15% sur 200€ = 30€", () => {
expect(commission(200, 15)).toBe(30);
});
it("arrondit au centime", () => {
expect(commission(33.33, 15)).toBe(5);
});
});
// Disponibilité : la quantité libre = totalQty - somme des qty bloquées
// chevauchant la fenêtre. Snapshot de la logique de `getItemAvailability`.
describe("rental availability arithmetic", () => {
function availableQty(totalQty: number, blockedQtys: number[]): number {
const used = blockedQtys.reduce((a, b) => a + b, 0);
return Math.max(0, totalQty - used);
}
it("totalQty quand rien n'est bloqué", () => {
expect(availableQty(5, [])).toBe(5);
});
it("soustrait les blocages", () => {
expect(availableQty(5, [2, 1])).toBe(2);
});
it("ne renvoie jamais de valeur négative", () => {
expect(availableQty(3, [5])).toBe(0);
});
});