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
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. +
+ + {error ?{error}
: null} +Bonjour ${firstName},
+${actor} votre location auprès de ${providerName}.
+Politique appliquée : ${policyLabel}
+Remboursement : ${Number(refundAmount).toFixed(2)} ${currency}
+Si un paiement avait été reçu, le remboursement est traité par Stripe sous 3-5 jours ouvrés.
+ +Référence : ${rentalBookingId}
`, + ), + }); +} + export async function sendRentalConfirmed( to: string, firstName: string, diff --git a/src/lib/rental-refund.ts b/src/lib/rental-refund.ts new file mode 100644 index 0000000..4fd77d3 --- /dev/null +++ b/src/lib/rental-refund.ts @@ -0,0 +1,73 @@ +import "server-only"; + +import { Prisma } from "@/generated/prisma/client"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type RefundPolicy = "FULL" | "PARTIAL_50" | "DEPOSIT_ONLY"; + +export type RefundCalculation = { + /** Montant remboursé sur la location (hors caution). */ + itemsRefund: Prisma.Decimal; + /** Montant remboursé sur la caution (rendue intégralement tant que pas HANDED_OVER). */ + depositRefund: Prisma.Decimal; + /** Total remboursé (itemsRefund + depositRefund). */ + totalRefund: Prisma.Decimal; + policy: RefundPolicy; + /** Description lisible de la politique appliquée, à inclure dans l'email. */ + policyLabel: string; +}; + +/** + * Politique de remboursement v1 (simple, paramétrable plus tard) : + * - Annulation > 7 jours avant le début → remboursement intégral (FULL) + * - Annulation entre 1 et 7 jours avant le début → remboursement 50% items + caution intégrale (PARTIAL_50) + * - Annulation < 24h avant le début → seulement la caution est rendue (DEPOSIT_ONLY) + * + * La caution est TOUJOURS rendue tant que le matériel n'a pas été remis + * (`HANDED_OVER`), puisqu'elle ne couvre que les dégâts pendant l'usage. + */ +export function computeRentalRefund(opts: { + startDate: Date; + itemsTotal: string | number | Prisma.Decimal; + depositTotal: string | number | Prisma.Decimal; + now?: Date; +}): RefundCalculation { + const now = opts.now ?? new Date(); + const msUntilStart = opts.startDate.getTime() - now.getTime(); + const daysUntilStart = msUntilStart / DAY_MS; + + const itemsDecimal = new Prisma.Decimal(opts.itemsTotal.toString()); + const depositDecimal = new Prisma.Decimal(opts.depositTotal.toString()); + + let policy: RefundPolicy; + let itemsRefund: Prisma.Decimal; + let policyLabel: string; + + if (daysUntilStart >= 7) { + policy = "FULL"; + itemsRefund = itemsDecimal; + policyLabel = "Annulation > 7 jours : remboursement intégral"; + } else if (daysUntilStart >= 1) { + policy = "PARTIAL_50"; + itemsRefund = itemsDecimal.mul("0.5").toDecimalPlaces(2); + policyLabel = "Annulation entre 1 et 7 jours : 50 % du montant location"; + } else { + policy = "DEPOSIT_ONLY"; + itemsRefund = new Prisma.Decimal(0); + policyLabel = "Annulation tardive : caution rendue, location non remboursée"; + } + + // La caution est toujours rendue tant que pas HANDED_OVER (vérifié côté action + // avant d'appeler ce helper). + const depositRefund = depositDecimal; + const totalRefund = itemsRefund.add(depositRefund).toDecimalPlaces(2); + + return { + itemsRefund: itemsRefund.toDecimalPlaces(2), + depositRefund: depositRefund.toDecimalPlaces(2), + totalRefund, + policy, + policyLabel, + }; +} diff --git a/tests/lib/rental-refund.test.ts b/tests/lib/rental-refund.test.ts new file mode 100644 index 0000000..05260a6 --- /dev/null +++ b/tests/lib/rental-refund.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; + +// `server-only` n'est pas résolu sous vitest — stub minimal. +vi.mock("server-only", () => ({})); + +const { computeRentalRefund } = await import("@/lib/rental-refund"); + +function daysFromNow(d: number): Date { + return new Date(Date.now() + d * 24 * 60 * 60 * 1000); +} + +describe("computeRentalRefund", () => { + it("FULL refund quand annulation à 10+ jours du début", () => { + const r = computeRentalRefund({ + startDate: daysFromNow(10), + itemsTotal: 100, + depositTotal: 50, + }); + expect(r.policy).toBe("FULL"); + expect(r.itemsRefund.toNumber()).toBe(100); + expect(r.depositRefund.toNumber()).toBe(50); + expect(r.totalRefund.toNumber()).toBe(150); + }); + + it("FULL refund pile à 7 jours du début", () => { + const r = computeRentalRefund({ + startDate: daysFromNow(7), + itemsTotal: 200, + depositTotal: 100, + }); + expect(r.policy).toBe("FULL"); + expect(r.totalRefund.toNumber()).toBe(300); + }); + + it("PARTIAL_50 quand annulation entre 1 et 7 jours", () => { + const r = computeRentalRefund({ + startDate: daysFromNow(3), + itemsTotal: 200, + depositTotal: 100, + }); + expect(r.policy).toBe("PARTIAL_50"); + expect(r.itemsRefund.toNumber()).toBe(100); + expect(r.depositRefund.toNumber()).toBe(100); + expect(r.totalRefund.toNumber()).toBe(200); + }); + + it("DEPOSIT_ONLY quand annulation < 24h", () => { + const r = computeRentalRefund({ + startDate: daysFromNow(0.5), + itemsTotal: 200, + depositTotal: 100, + }); + expect(r.policy).toBe("DEPOSIT_ONLY"); + expect(r.itemsRefund.toNumber()).toBe(0); + expect(r.depositRefund.toNumber()).toBe(100); + expect(r.totalRefund.toNumber()).toBe(100); + }); + + it("DEPOSIT_ONLY quand startDate déjà passée", () => { + const r = computeRentalRefund({ + startDate: daysFromNow(-1), + itemsTotal: 200, + depositTotal: 100, + }); + expect(r.policy).toBe("DEPOSIT_ONLY"); + expect(r.itemsRefund.toNumber()).toBe(0); + expect(r.depositRefund.toNumber()).toBe(100); + }); + + it("arrondit au centime pour PARTIAL_50", () => { + const r = computeRentalRefund({ + startDate: daysFromNow(3), + itemsTotal: 33.33, + depositTotal: 0, + }); + expect(r.itemsRefund.toNumber()).toBe(16.67); // 33.33 / 2 = 16.665 → arrondi 16.67 + expect(r.totalRefund.toNumber()).toBe(16.67); + }); + + it("Zéro caution → totalRefund = itemsRefund", () => { + const r = computeRentalRefund({ + startDate: daysFromNow(10), + itemsTotal: 50, + depositTotal: 0, + }); + expect(r.depositRefund.toNumber()).toBe(0); + expect(r.totalRefund.toNumber()).toBe(50); + }); + + it("policyLabel contient un texte lisible pour chaque branche", () => { + expect(computeRentalRefund({ startDate: daysFromNow(10), itemsTotal: 100, depositTotal: 0 }).policyLabel).toContain("intégral"); + expect(computeRentalRefund({ startDate: daysFromNow(3), itemsTotal: 100, depositTotal: 0 }).policyLabel).toContain("50"); + expect(computeRentalRefund({ startDate: daysFromNow(0), itemsTotal: 100, depositTotal: 0 }).policyLabel).toContain("tardive"); + }); +});