feat(rental): Sprint M — refonds + annulations Stripe
All checks were successful
CI / test (push) Successful in 2m24s
All checks were successful
CI / test (push) Successful in 2m24s
This commit is contained in:
commit
73d24b70f7
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
100
src/components/CancelRentalButton.tsx
Normal file
100
src/components/CancelRentalButton.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 été 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
73
src/lib/rental-refund.ts
Normal 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 é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,
|
||||
};
|
||||
}
|
||||
95
tests/lib/rental-refund.test.ts
Normal file
95
tests/lib/rental-refund.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue