From 5607a51980c8d91a1ca9cb827885b695fe3a993a Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 2 Jun 2026 08:49:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(rental):=20Sprint=20E=20=E2=80=94=20emails?= =?UTF-8?q?=20+=20plugin=20toggle=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/rentals/checkout/route.ts | 48 ++++++++ src/app/api/stripe/webhook/route.ts | 23 ++++ .../[slug]/_components/CompleteYourStay.tsx | 2 + src/app/espace-prestataire/layout.tsx | 6 + src/app/materiel/[itemId]/page.tsx | 2 + src/app/materiel/layout.tsx | 6 + src/app/materiel/page.tsx | 2 + src/app/mes-locations/page.tsx | 2 + src/app/panier/page.tsx | 2 + src/components/SiteHeader.tsx | 22 ++-- src/lib/email.ts | 93 ++++++++++++++++ src/lib/plugins/registry.ts | 8 ++ tests/lib/rentals.test.ts | 105 ++++++++++++++++++ 13 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 src/app/espace-prestataire/layout.tsx create mode 100644 src/app/materiel/layout.tsx create mode 100644 tests/lib/rentals.test.ts diff --git a/src/app/api/rentals/checkout/route.ts b/src/app/api/rentals/checkout/route.ts index faaeb8e..06fccb6 100644 --- a/src/app/api/rentals/checkout/route.ts +++ b/src/app/api/rentals/checkout/route.ts @@ -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, diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index a6b9b99..6a896ec 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -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; } diff --git a/src/app/carbets/[slug]/_components/CompleteYourStay.tsx b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx index 3e87eae..a1b8b42 100644 --- a/src/app/carbets/[slug]/_components/CompleteYourStay.tsx +++ b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx @@ -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 = { }; export async function CompleteYourStay({ river, capacity }: Props) { + if (!(await isPluginEnabled("gear-rental"))) return null; const providers = await prisma.rentalProvider.findMany({ where: { active: true, diff --git a/src/app/espace-prestataire/layout.tsx b/src/app/espace-prestataire/layout.tsx new file mode 100644 index 0000000..fb8b7d1 --- /dev/null +++ b/src/app/espace-prestataire/layout.tsx @@ -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}; +} diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx index 00e8348..e073f9b 100644 --- a/src/app/materiel/[itemId]/page.tsx +++ b/src/app/materiel/[itemId]/page.tsx @@ -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 } export default async function RentalItemDetailPage({ params }: PageProps) { + await requirePluginOr404("gear-rental"); const { itemId } = await params; const item = await getPublicRentalItem(itemId); if (!item) notFound(); diff --git a/src/app/materiel/layout.tsx b/src/app/materiel/layout.tsx new file mode 100644 index 0000000..d6cebd2 --- /dev/null +++ b/src/app/materiel/layout.tsx @@ -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}; +} diff --git a/src/app/materiel/page.tsx b/src/app/materiel/page.tsx index f18f2ac..31fcd77 100644 --- a/src/app/materiel/page.tsx +++ b/src/app/materiel/page.tsx @@ -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, diff --git a/src/app/mes-locations/page.tsx b/src/app/mes-locations/page.tsx index 606ae92..5e3d737 100644 --- a/src/app/mes-locations/page.tsx +++ b/src/app/mes-locations/page.tsx @@ -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 = { 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; diff --git a/src/app/panier/page.tsx b/src/app/panier/page.tsx index a547512..04f13c6 100644 --- a/src/app/panier/page.tsx +++ b/src/app/panier/page.tsx @@ -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 diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index b58c423..3d3f8f0 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -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 (
@@ -35,13 +37,15 @@ export async function SiteHeader() { Catalogue - - Matériel - + {rentalEnabled ? ( + + Matériel + + ) : null}
- + {rentalEnabled ? : null} {u ? ( <> @@ -50,9 +54,11 @@ export async function SiteHeader() { Mes réservations - - Mes locations - + {rentalEnabled ? ( + + Mes locations + + ) : null} Mon compte @@ -61,7 +67,7 @@ export async function SiteHeader() { Espace hôte ) : null} - {isRentalProvider ? ( + {isRentalProvider && rentalEnabled ? ( Espace prestataire diff --git a/src/lib/email.ts b/src/lib/email.ts index a2dc4e6..2e9f79a 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -224,6 +224,99 @@ export async function sendPasswordReset( }); } +type RentalLineSummary = { qty: number; itemName: string }; + +function renderLines(lines: RentalLineSummary[]): string { + return lines.map((l) => `
  • ${l.qty}× ${l.itemName}
  • `).join(""); +} + +export async function sendRentalRequestedTenant( + to: string, + firstName: string, + rentalBookingId: string, + providerName: string, + startDate: Date, + endDate: Date, + amount: string, + currency: string, + lines: RentalLineSummary[], +): Promise { + 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", + `

    Bonjour ${firstName},

    +

    Votre demande de location auprès de ${providerName} est bien enregistrée :

    +
      +
    • Du ${fmt(startDate)} au ${fmt(endDate)}
    • +
    • Montant : ${Number(amount).toFixed(2)} ${currency}
    • +
    +

    Matériel demandé :

    +
      ${renderLines(lines)}
    +

    Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.

    +

    Mes locations

    +

    Référence : ${rentalBookingId}

    `, + ), + }); +} + +export async function sendRentalRequestedProvider( + to: string, + providerName: string, + rentalBookingId: string, + tenantName: string, + startDate: Date, + endDate: Date, + lines: RentalLineSummary[], +): Promise { + 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", + `

    Bonjour ${providerName},

    +

    ${tenantName} vient de réserver du matériel :

    +
      +
    • Du ${fmt(startDate)} au ${fmt(endDate)}
    • +
    +

    Matériel :

    +
      ${renderLines(lines)}
    +

    Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.

    +

    Mes réservations

    +

    Référence : ${rentalBookingId}

    `, + ), + }); +} + +export async function sendRentalConfirmed( + to: string, + firstName: string, + rentalBookingId: string, + providerName: string, + startDate: Date, + endDate: Date, +): Promise { + 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", + `

    Bonjour ${firstName},

    +

    Le paiement de votre location auprès de ${providerName} du ${fmt(startDate)} au ${fmt(endDate)} est validé.

    +

    Le prestataire vous contactera pour organiser la remise du matériel sur place.

    +

    Voir ma location

    +

    Référence : ${rentalBookingId}

    `, + ), + }); +} + export async function sendBookingRefunded( to: string, firstName: string, diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts index d5ee90b..3294a20 100644 --- a/src/lib/plugins/registry.ts +++ b/src/lib/plugins/registry.ts @@ -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 { diff --git a/tests/lib/rentals.test.ts b/tests/lib/rentals.test.ts new file mode 100644 index 0000000..121ee06 --- /dev/null +++ b/tests/lib/rentals.test.ts @@ -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); + }); +});