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>
73 lines
2.6 KiB
TypeScript
73 lines
2.6 KiB
TypeScript
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,
|
|
};
|
|
}
|