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>
95 lines
3.1 KiB
TypeScript
95 lines
3.1 KiB
TypeScript
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");
|
|
});
|
|
});
|