feat(rental): Sprint M — refonds + annulations Stripe
All checks were successful
CI / test (pull_request) Successful in 2m37s
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:
parent
7a12848b5b
commit
c564028ca9
7 changed files with 503 additions and 34 deletions
193
src/app/api/rentals/[id]/cancel/route.ts
Normal file
193
src/app/api/rentals/[id]/cancel/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue