feat(rental): Sprint M — refonds + annulations Stripe
All checks were successful
CI / test (pull_request) Successful in 2m37s

Politique de remboursement v1 :
- > 7 jours du début → FULL (location + caution)
- 1 à 7 jours → PARTIAL_50 (50% location + caution intégrale)
- < 24h ou passé → DEPOSIT_ONLY (caution seulement, pas de remboursement
  sur la location)

src/lib/rental-refund.ts (NEW) : computeRentalRefund({startDate,
itemsTotal, depositTotal}) → { itemsRefund, depositRefund, totalRefund,
policy, policyLabel }. Arrondi au centime, support de Decimal.

POST /api/rentals/[id]/cancel :
- Auth multi-rôle : tenant de la booking, RENTAL_PROVIDER nominal ou
  CE_MANAGER de l'org du provider, ADMIN. Détecte `cancelledBy` pour
  adapter l'email.
- Refuse si status ∉ {PENDING, CONFIRMED} (HANDED_OVER → non
  annulable, contacter Karbé).
- Calcule le refund selon la politique.
- Stripe refund best-effort si paymentStatus=SUCCEEDED + stripeSessionId
  existante + isStripeConfigured + totalRefund > 0. Retrieve session →
  payment_intent → refunds.create. Échec Stripe = audit-logged mais
  le flip status continue (l'asso pourra rembourser manuellement).
- Transaction : update RentalBooking (CANCELLED + paymentStatus
  REFUNDED si SUCCEEDED sinon FAILED) + delete RentalItemAvailability
  (libère stock).
- Audit log rental.cancel avec montants, policy, cancelledBy,
  stripeRefundId, stripeRefundError.
- Email best-effort : sendRentalCancelled à tenant + provider (sauf si
  provider est le canceller).

src/components/CancelRentalButton.tsx : composant client confirm dialog
inline avec textarea motif (max 500 chars). Branché sur :
- /mes-locations : « Annuler ma location » sur résa PENDING/CONFIRMED
- BookingDecision (utilisé par /espace-prestataire/reservations ET
  /espace-ce/materiel/reservations) : remplace l'ancienne mini-confirm
  qui flippait juste le status, désormais via la vraie API refund

sendRentalCancelled email : adapté selon cancelledBy ("Vous avez annulé"
/ "<Provider> a annulé" / "L'équipe Karbé a annulé").

tests/lib/rental-refund.test.ts : 8 cas (FULL @ 10+ et 7j, PARTIAL_50,
DEPOSIT_ONLY < 24h et passé, arrondi centime, zéro caution, policyLabel).
Total projet : 70/70 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-03 02:17:58 +00:00
parent 7a12848b5b
commit c564028ca9
7 changed files with 503 additions and 34 deletions

View file

@ -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,
});
}

View file

@ -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<string | null>(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é
</button>
) : null}
{status !== RentalBookingStatus.CANCELLED && status !== RentalBookingStatus.RETURNED ? (
confirmCancel ? (
<div className="flex items-center gap-1.5 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Annuler ?</span>
<button
type="button"
onClick={() => set(RentalBookingStatus.CANCELLED)}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmCancel(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Non
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmCancel(true)}
disabled={pending}
className={`${btnBase} border border-rose-300 bg-white text-rose-700 hover:bg-rose-50`}
>
Annuler
</button>
)
{status === RentalBookingStatus.PENDING || status === RentalBookingStatus.CONFIRMED ? (
<CancelRentalButton rentalBookingId={bookingId} label="Annuler" />
) : null}
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
</div>

View file

@ -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 ? <span> {rb.provider.contactEmail}</span> : null}
</p>
) : null}
{(rb.status === "PENDING" || rb.status === "CONFIRMED") ? (
<div className="mt-3 flex justify-end">
<CancelRentalButton rentalBookingId={rb.id} label="Annuler ma location" />
</div>
) : null}
</li>
))}
</ul>

View file

@ -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<string | null>(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 (
<button
type="button"
onClick={() => setConfirmOpen(true)}
className={
"rounded-md border border-rose-200 px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50 " +
(compact ? "" : "")
}
>
{label}
</button>
);
}
return (
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm">
<p className="font-semibold text-rose-900">Confirmer l&apos;annulation</p>
<p className="mt-1 text-xs text-rose-800">
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.
</p>
<label className="mt-2 block">
<span className="text-xs text-rose-900">Motif (optionnel)</span>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={500}
rows={2}
className="mt-1 w-full rounded border border-rose-200 bg-white px-2 py-1 text-xs"
placeholder="Ex. changement de date, indisponibilité…"
/>
</label>
{error ? <p className="mt-2 text-xs text-rose-700">{error}</p> : null}
<div className="mt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setConfirmOpen(false);
setError(null);
}}
disabled={pending}
className="rounded-md border border-zinc-300 bg-white px-3 py-1 text-xs text-zinc-700 hover:bg-zinc-50"
>
Garder la résa
</button>
<button
type="button"
onClick={submit}
disabled={pending}
className="rounded-md bg-rose-600 px-3 py-1 text-xs font-semibold text-white hover:bg-rose-700 disabled:opacity-60"
>
{pending ? "Annulation…" : "Confirmer"}
</button>
</div>
</div>
);
}

View file

@ -355,6 +355,38 @@ export async function sendRentalRequestedProvider(
});
}
export async function sendRentalCancelled(
to: string,
firstName: string,
rentalBookingId: string,
providerName: string,
refundAmount: string,
currency: string,
policyLabel: string,
cancelledBy: "tenant" | "provider" | "admin",
): Promise<void> {
const actor =
cancelledBy === "tenant"
? "Vous avez annulé"
: cancelledBy === "provider"
? `${providerName} a annulé`
: "L'équipe Karbé a annulé";
await sendEmail({
to,
subject: `Location annulée — ${providerName}`,
html: wrap(
"Location annulée",
`<p>Bonjour ${firstName},</p>
<p>${actor} votre location auprès de <strong>${providerName}</strong>.</p>
<p><strong>Politique appliquée :</strong> ${policyLabel}</p>
<p><strong>Remboursement :</strong> ${Number(refundAmount).toFixed(2)} ${currency}</p>
<p>Si un paiement avait é reçu, le remboursement est traité par Stripe sous 3-5 jours ouvrés.</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 sendRentalConfirmed(
to: string,
firstName: string,

73
src/lib/rental-refund.ts Normal file
View file

@ -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 é 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,
};
}

View file

@ -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");
});
});