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