diff --git a/src/app/api/rentals/[id]/cancel/route.ts b/src/app/api/rentals/[id]/cancel/route.ts new file mode 100644 index 0000000..49aa9ec --- /dev/null +++ b/src/app/api/rentals/[id]/cancel/route.ts @@ -0,0 +1,193 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { + PaymentStatus, + RentalBookingStatus, + UserRole, +} from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { canManageRentalProvider } from "@/lib/rental-access"; +import { sendRentalCancelled } from "@/lib/email"; +import { isStripeConfigured, getStripeClient } from "@/lib/stripe"; +import { prisma } from "@/lib/prisma"; +import { computeRentalRefund } from "@/lib/rental-refund"; + +export const runtime = "nodejs"; + +const CANCELLABLE_STATUSES: RentalBookingStatus[] = [ + RentalBookingStatus.PENDING, + RentalBookingStatus.CONFIRMED, +]; + +type Body = { reason?: string }; + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); + } + const { id } = await params; + const body: Body = await req.json().catch(() => ({})); + const reason = body.reason?.toString().trim().slice(0, 500) ?? null; + + const rb = await prisma.rentalBooking.findUnique({ + where: { id }, + include: { + provider: { select: { id: true, name: true, contactEmail: true, organizationId: true } }, + tenant: { select: { id: true, email: true, firstName: true } }, + lines: { select: { qty: true, item: { select: { name: true } } } }, + }, + }); + if (!rb) { + return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 }); + } + + // Détecte qui annule pour l'auth + l'email : + // - tenant de la booking + // - provider's manager (RENTAL_PROVIDER nominal ou CE_MANAGER de l'org du provider) + // - admin + const role = session.user.role; + const isTenant = rb.tenantId === session.user.id; + const isAdmin = role === UserRole.ADMIN; + const canManage = await canManageRentalProvider( + session.user.id, + role, + rb.providerId, + session.user.organizationId, + ); + const cancelledBy: "tenant" | "provider" | "admin" = isAdmin + ? "admin" + : canManage + ? "provider" + : "tenant"; + + if (!isAdmin && !canManage && !isTenant) { + return NextResponse.json({ error: "Accès refusé." }, { status: 403 }); + } + + if (!CANCELLABLE_STATUSES.includes(rb.status)) { + return NextResponse.json( + { error: `Impossible d'annuler une réservation en statut ${rb.status}.` }, + { status: 409 }, + ); + } + + // Calcule le remboursement selon la politique + const refund = computeRentalRefund({ + startDate: rb.startDate, + itemsTotal: rb.itemsTotal, + depositTotal: rb.depositTotal, + }); + + // Stripe refund best-effort si paiement déjà SUCCEEDED + session Stripe existante + let stripeRefundId: string | null = null; + let stripeRefundError: string | null = null; + if ( + rb.paymentStatus === PaymentStatus.SUCCEEDED && + rb.stripeSessionId && + isStripeConfigured() && + refund.totalRefund.gt(0) + ) { + try { + const stripe = getStripeClient(); + const sess = await stripe.checkout.sessions.retrieve(rb.stripeSessionId, { + expand: ["payment_intent"], + }); + const piId = + typeof sess.payment_intent === "string" + ? sess.payment_intent + : sess.payment_intent?.id; + if (piId) { + const stripeRefund = await stripe.refunds.create({ + payment_intent: piId, + amount: Math.round(Number(refund.totalRefund) * 100), + reason: "requested_by_customer", + }); + stripeRefundId = stripeRefund.id; + } + } catch (e) { + stripeRefundError = e instanceof Error ? e.message : String(e); + console.error("[rental.cancel] Stripe refund failed:", stripeRefundError); + } + } + + // Transaction : update booking + delete availability blocks + await prisma.$transaction([ + prisma.rentalBooking.update({ + where: { id }, + data: { + status: RentalBookingStatus.CANCELLED, + paymentStatus: + rb.paymentStatus === PaymentStatus.SUCCEEDED + ? PaymentStatus.REFUNDED + : PaymentStatus.FAILED, + }, + }), + prisma.rentalItemAvailability.deleteMany({ + where: { rentalBookingId: id }, + }), + ]); + + await recordAudit({ + scope: "rental", + event: "rental.cancel", + target: id, + actorEmail: session.user.email ?? null, + details: { + cancelledBy, + reason, + policy: refund.policy, + itemsRefund: refund.itemsRefund.toString(), + depositRefund: refund.depositRefund.toString(), + totalRefund: refund.totalRefund.toString(), + stripeRefundId, + stripeRefundError, + }, + }); + + // Email best-effort : tenant + provider + try { + await sendRentalCancelled( + rb.tenant.email, + rb.tenant.firstName, + rb.id, + rb.provider.name, + refund.totalRefund.toString(), + rb.currency, + refund.policyLabel, + cancelledBy, + ); + if (rb.provider.contactEmail && cancelledBy !== "provider") { + await sendRentalCancelled( + rb.provider.contactEmail, + rb.provider.name, + rb.id, + rb.provider.name, + refund.totalRefund.toString(), + rb.currency, + refund.policyLabel, + cancelledBy, + ); + } + } catch (e) { + console.error("[rental.cancel] email send failed:", e instanceof Error ? e.message : e); + } + + return NextResponse.json({ + ok: true, + rentalBookingId: id, + refund: { + itemsRefund: refund.itemsRefund.toNumber(), + depositRefund: refund.depositRefund.toNumber(), + totalRefund: refund.totalRefund.toNumber(), + policy: refund.policy, + policyLabel: refund.policyLabel, + }, + stripeRefundId, + stripeRefundError, + }); +} diff --git a/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx index 2d6fa77..b38622f 100644 --- a/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx +++ b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import { CancelRentalButton } from "@/components/CancelRentalButton"; import { RentalBookingStatus } from "@/generated/prisma/enums"; import { updateBookingStatusAction } from "../../actions"; @@ -14,14 +15,11 @@ export function BookingDecision({ bookingId, status }: { bookingId: string; stat const router = useRouter(); const [pending, startTransition] = useTransition(); const [error, setError] = useState(null); - const [confirmCancel, setConfirmCancel] = useState(false); - function set(next: string) { setError(null); startTransition(async () => { const res = await updateBookingStatusAction(bookingId, next); if (res && res.ok === false) setError(res.error); - setConfirmCancel(false); router.refresh(); }); } @@ -58,37 +56,8 @@ export function BookingDecision({ bookingId, status }: { bookingId: string; stat Marquer retourné ) : null} - {status !== RentalBookingStatus.CANCELLED && status !== RentalBookingStatus.RETURNED ? ( - confirmCancel ? ( -
- Annuler ? - - -
- ) : ( - - ) + {status === RentalBookingStatus.PENDING || status === RentalBookingStatus.CONFIRMED ? ( + ) : null} {error ? {error} : null} diff --git a/src/app/mes-locations/page.tsx b/src/app/mes-locations/page.tsx index 5e3d737..53f7eb6 100644 --- a/src/app/mes-locations/page.tsx +++ b/src/app/mes-locations/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; +import { CancelRentalButton } from "@/components/CancelRentalButton"; import { requireAuth } from "@/lib/authorization"; import { requirePluginOr404 } from "@/lib/plugins/guard"; import { prisma } from "@/lib/prisma"; @@ -134,6 +135,12 @@ export default async function MyRentalsPage({ searchParams }: { searchParams: Se {rb.provider.contactEmail ? ✉ {rb.provider.contactEmail} : null}

) : null} + + {(rb.status === "PENDING" || rb.status === "CONFIRMED") ? ( +
+ +
+ ) : null} ))} diff --git a/src/components/CancelRentalButton.tsx b/src/components/CancelRentalButton.tsx new file mode 100644 index 0000000..963f9d8 --- /dev/null +++ b/src/components/CancelRentalButton.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +type Props = { + rentalBookingId: string; + /** Label adapté au contexte d'appel : « Annuler ma location » côté tenant, etc. */ + label?: string; + /** Affichage compact dans une grille d'actions (pas de margin auto). */ + compact?: boolean; +}; + +export function CancelRentalButton({ + rentalBookingId, + label = "Annuler", + compact = false, +}: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [reason, setReason] = useState(""); + + function submit() { + setError(null); + startTransition(async () => { + const res = await fetch(`/api/rentals/${rentalBookingId}/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setError(json?.error || `Erreur ${res.status}`); + return; + } + setConfirmOpen(false); + router.refresh(); + }); + } + + if (!confirmOpen) { + return ( + + ); + } + + return ( +
+

Confirmer l'annulation

+

+ Le remboursement est calculé selon la politique : 100 % si annulation à plus de 7 jours, + 50 % entre 1 et 7 jours, caution seulement à moins de 24h. +

+