feat(rental): Sprint E — emails + plugin toggle + tests
Some checks failed
CI / test (pull_request) Failing after 1m10s
Some checks failed
CI / test (pull_request) Failing after 1m10s
3 nouveaux templates email (best-effort, dry-run sans Resend) : - sendRentalRequestedTenant : récap de demande au locataire (par RB) - sendRentalRequestedProvider : nouvelle demande au prestataire - sendRentalConfirmed : confirmation paiement reçu Branchements : - POST /api/rentals/checkout : envoie tenant + provider après création des RentalBooking (PENDING), catch global pour ne pas bloquer - Webhook Stripe rental-bundle : envoie sendRentalConfirmed à chaque locataire après update CONFIRMED+SUCCEEDED Plugin gear-rental : - Ajout au registry (catégorie business) - layout.tsx /materiel + /espace-prestataire avec requirePluginOr404 - requirePluginOr404 dans /panier et /mes-locations - isPluginEnabled guard dans POST /api/rentals/checkout (404 si off) - SiteHeader masque liens Matériel / Mes locations / Espace prestataire + CartBadge si plugin désactivé - CompleteYourStay renvoie null si plugin désactivé Décision admin → activable depuis /admin/plugins comme tous les autres. Tests vitest (tests/lib/rentals.test.ts, 16 tests) : - diffDays (mêmes dates, 1 nuit, 7 jours, négatif) - parseCart (null/garbage/schéma invalide/valide/format date) - serializeCart (updatedAt, roundtrip) - commission formula (0%, 15%, arrondi centime) - availability arithmetic (totalQty libre, soustractions, plancher 0) 53 tests pass total. Build OK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0723e50189
commit
5607a51980
13 changed files with 313 additions and 8 deletions
|
|
@ -5,6 +5,11 @@ import { auth } from "@/auth";
|
|||
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import {
|
||||
sendRentalRequestedProvider,
|
||||
sendRentalRequestedTenant,
|
||||
} from "@/lib/email";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart";
|
||||
import {
|
||||
|
|
@ -28,6 +33,9 @@ function parseDateOnly(s: string): Date {
|
|||
}
|
||||
|
||||
export async function POST() {
|
||||
if (!(await isPluginEnabled("gear-rental"))) {
|
||||
return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 });
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || !session.user.email) {
|
||||
return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 });
|
||||
|
|
@ -241,6 +249,46 @@ export async function POST() {
|
|||
},
|
||||
});
|
||||
|
||||
// Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail
|
||||
// à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas.
|
||||
try {
|
||||
const fullBookings = await prisma.rentalBooking.findMany({
|
||||
where: { id: { in: rentalBookingIds } },
|
||||
include: {
|
||||
provider: { select: { name: true, contactEmail: true } },
|
||||
lines: { include: { item: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
const tenantName = session.user.name ?? session.user.email!;
|
||||
for (const rb of fullBookings) {
|
||||
const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name }));
|
||||
await sendRentalRequestedTenant(
|
||||
session.user.email!,
|
||||
tenantName,
|
||||
rb.id,
|
||||
rb.provider.name,
|
||||
rb.startDate,
|
||||
rb.endDate,
|
||||
rb.amount.toString(),
|
||||
rb.currency,
|
||||
lineSummary,
|
||||
);
|
||||
if (rb.provider.contactEmail) {
|
||||
await sendRentalRequestedProvider(
|
||||
rb.provider.contactEmail,
|
||||
rb.provider.name,
|
||||
rb.id,
|
||||
tenantName,
|
||||
rb.startDate,
|
||||
rb.endDate,
|
||||
lineSummary,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// Vide le panier
|
||||
jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), {
|
||||
httpOnly: false,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
SubscriptionStatus,
|
||||
} from "@/generated/prisma/enums";
|
||||
import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked";
|
||||
import { sendRentalConfirmed } from "@/lib/email";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe";
|
||||
|
||||
|
|
@ -64,6 +65,28 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
status: RentalBookingStatus.CONFIRMED,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const rentals = await prisma.rentalBooking.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
provider: { select: { name: true } },
|
||||
tenant: { select: { email: true, firstName: true } },
|
||||
},
|
||||
});
|
||||
for (const rb of rentals) {
|
||||
if (!rb.tenant.email) continue;
|
||||
await sendRentalConfirmed(
|
||||
rb.tenant.email,
|
||||
rb.tenant.firstName ?? rb.tenant.email,
|
||||
rb.id,
|
||||
rb.provider.name,
|
||||
rb.startDate,
|
||||
rb.endDate,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ const EMOJI: Record<string, string> = {
|
|||
};
|
||||
|
||||
export async function CompleteYourStay({ river, capacity }: Props) {
|
||||
if (!(await isPluginEnabled("gear-rental"))) return null;
|
||||
const providers = await prisma.rentalProvider.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
|
|
|
|||
6
src/app/espace-prestataire/layout.tsx
Normal file
6
src/app/espace-prestataire/layout.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { requirePluginOr404 } from "@/lib/plugins/guard";
|
||||
|
||||
export default async function ProviderLayout({ children }: { children: React.ReactNode }) {
|
||||
await requirePluginOr404("gear-rental");
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { requirePluginOr404 } from "@/lib/plugins/guard";
|
||||
import { getPublicRentalItem } from "@/lib/rentals-public";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||
}
|
||||
|
||||
export default async function RentalItemDetailPage({ params }: PageProps) {
|
||||
await requirePluginOr404("gear-rental");
|
||||
const { itemId } = await params;
|
||||
const item = await getPublicRentalItem(itemId);
|
||||
if (!item) notFound();
|
||||
|
|
|
|||
6
src/app/materiel/layout.tsx
Normal file
6
src/app/materiel/layout.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { requirePluginOr404 } from "@/lib/plugins/guard";
|
||||
|
||||
export default async function MaterielLayout({ children }: { children: React.ReactNode }) {
|
||||
await requirePluginOr404("gear-rental");
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
import { RentalCategory } from "@/generated/prisma/enums";
|
||||
import { requirePluginOr404 } from "@/lib/plugins/guard";
|
||||
import { isRentalCategory } from "@/lib/rental-category-labels";
|
||||
import {
|
||||
listPublicProviders,
|
||||
|
|
@ -29,6 +30,7 @@ type PageProps = {
|
|||
};
|
||||
|
||||
export default async function MaterialPage({ searchParams }: PageProps) {
|
||||
await requirePluginOr404("gear-rental");
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { requireAuth } from "@/lib/authorization";
|
||||
import { requirePluginOr404 } from "@/lib/plugins/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ const PAYMENT_LABEL: Record<string, string> = {
|
|||
type SearchParams = Promise<{ payment?: string; ids?: string; ok?: string }>;
|
||||
|
||||
export default async function MyRentalsPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
await requirePluginOr404("gear-rental");
|
||||
const session = await requireAuth();
|
||||
const sp = await searchParams;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { requirePluginOr404 } from "@/lib/plugins/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readCartFromCookies } from "@/lib/rental-cart-server";
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ export const dynamic = "force-dynamic";
|
|||
export const metadata = { title: "Mon panier matériel" };
|
||||
|
||||
export default async function CartPage() {
|
||||
await requirePluginOr404("gear-rental");
|
||||
const cart = await readCartFromCookies();
|
||||
|
||||
// Charge les items du panier en bulk pour rendu
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Link from "next/link";
|
|||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { isPluginEnabled } from "@/lib/plugins/server";
|
||||
|
||||
import { CartBadge } from "./CartBadge";
|
||||
import { SignOutButton } from "./SignOutButton";
|
||||
|
|
@ -17,6 +18,7 @@ export async function SiteHeader() {
|
|||
const isAdmin = u?.role === UserRole.ADMIN;
|
||||
const isOwner = u?.role === UserRole.OWNER || isAdmin;
|
||||
const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
|
||||
const rentalEnabled = await isPluginEnabled("gear-rental");
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
|
||||
|
|
@ -35,13 +37,15 @@ export async function SiteHeader() {
|
|||
<Link href="/carbets" className="hover:text-zinc-900">
|
||||
Catalogue
|
||||
</Link>
|
||||
<Link href="/materiel" className="hover:text-zinc-900">
|
||||
Matériel
|
||||
</Link>
|
||||
{rentalEnabled ? (
|
||||
<Link href="/materiel" className="hover:text-zinc-900">
|
||||
Matériel
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<CartBadge />
|
||||
{rentalEnabled ? <CartBadge /> : null}
|
||||
{u ? (
|
||||
<>
|
||||
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
|
|
@ -50,9 +54,11 @@ export async function SiteHeader() {
|
|||
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Mes réservations
|
||||
</Link>
|
||||
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Mes locations
|
||||
</Link>
|
||||
{rentalEnabled ? (
|
||||
<Link href="/mes-locations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Mes locations
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/mon-compte" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Mon compte
|
||||
</Link>
|
||||
|
|
@ -61,7 +67,7 @@ export async function SiteHeader() {
|
|||
Espace hôte
|
||||
</Link>
|
||||
) : null}
|
||||
{isRentalProvider ? (
|
||||
{isRentalProvider && rentalEnabled ? (
|
||||
<Link href="/espace-prestataire" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Espace prestataire
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -224,6 +224,99 @@ export async function sendPasswordReset(
|
|||
});
|
||||
}
|
||||
|
||||
type RentalLineSummary = { qty: number; itemName: string };
|
||||
|
||||
function renderLines(lines: RentalLineSummary[]): string {
|
||||
return lines.map((l) => `<li>${l.qty}× ${l.itemName}</li>`).join("");
|
||||
}
|
||||
|
||||
export async function sendRentalRequestedTenant(
|
||||
to: string,
|
||||
firstName: string,
|
||||
rentalBookingId: string,
|
||||
providerName: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
amount: string,
|
||||
currency: string,
|
||||
lines: RentalLineSummary[],
|
||||
): Promise<void> {
|
||||
const fmt = (d: Date) =>
|
||||
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Demande de location matériel — ${providerName}`,
|
||||
html: wrap(
|
||||
"Votre demande de location est enregistrée",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Votre demande de location auprès de <strong>${providerName}</strong> est bien enregistrée :</p>
|
||||
<ul>
|
||||
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
|
||||
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
|
||||
</ul>
|
||||
<p><strong>Matériel demandé :</strong></p>
|
||||
<ul>${renderLines(lines)}</ul>
|
||||
<p>Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.</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 sendRentalRequestedProvider(
|
||||
to: string,
|
||||
providerName: string,
|
||||
rentalBookingId: string,
|
||||
tenantName: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
lines: RentalLineSummary[],
|
||||
): Promise<void> {
|
||||
const fmt = (d: Date) =>
|
||||
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Nouvelle demande de location — ${tenantName}`,
|
||||
html: wrap(
|
||||
"Nouvelle demande à préparer",
|
||||
`<p>Bonjour ${providerName},</p>
|
||||
<p><strong>${tenantName}</strong> vient de réserver du matériel :</p>
|
||||
<ul>
|
||||
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
|
||||
</ul>
|
||||
<p><strong>Matériel :</strong></p>
|
||||
<ul>${renderLines(lines)}</ul>
|
||||
<p>Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.</p>
|
||||
<p><a href="${SITE_URL}/espace-prestataire/reservations" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mes réservations</a></p>
|
||||
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendRentalConfirmed(
|
||||
to: string,
|
||||
firstName: string,
|
||||
rentalBookingId: string,
|
||||
providerName: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<void> {
|
||||
const fmt = (d: Date) =>
|
||||
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `Location confirmée — ${providerName}`,
|
||||
html: wrap(
|
||||
"Votre location est confirmée",
|
||||
`<p>Bonjour ${firstName},</p>
|
||||
<p>Le paiement de votre location auprès de <strong>${providerName}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est validé.</p>
|
||||
<p>Le prestataire vous contactera pour organiser la remise du matériel sur place.</p>
|
||||
<p><a href="${SITE_URL}/mes-locations" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma location</a></p>
|
||||
<p style="font-size:11px;color:#71717a;">Référence : ${rentalBookingId}</p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingRefunded(
|
||||
to: string,
|
||||
firstName: string,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,14 @@ export const PLUGINS: PluginDescriptor[] = [
|
|||
category: "business",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
key: "gear-rental",
|
||||
name: "Location matériel (sous-marketplace)",
|
||||
description:
|
||||
"Catalogue matériel (hamac, moustiquaire, pirogue, kayak…) loué par System D et prestataires tiers. Inclut panier, checkout Stripe, espace prestataire, recommandations carbet. Si désactivé : /materiel, /espace-prestataire et /mes-locations renvoient 404; liens header masqués.",
|
||||
category: "business",
|
||||
version: "0.1.0",
|
||||
},
|
||||
|
||||
// Contenus / i18n
|
||||
{
|
||||
|
|
|
|||
105
tests/lib/rentals.test.ts
Normal file
105
tests/lib/rentals.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { diffDays, parseCart, serializeCart, EMPTY_CART } from "@/lib/rental-cart";
|
||||
|
||||
describe("diffDays", () => {
|
||||
it("renvoie 0 pour mêmes dates", () => {
|
||||
expect(diffDays("2026-06-01", "2026-06-01")).toBe(0);
|
||||
});
|
||||
it("compte 1 nuit entre J et J+1", () => {
|
||||
expect(diffDays("2026-06-01", "2026-06-02")).toBe(1);
|
||||
});
|
||||
it("compte 7 jours sur une semaine", () => {
|
||||
expect(diffDays("2026-06-01", "2026-06-08")).toBe(7);
|
||||
});
|
||||
it("ne renvoie pas de valeur négative si end < start", () => {
|
||||
expect(diffDays("2026-06-08", "2026-06-01")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCart", () => {
|
||||
it("retourne EMPTY_CART pour null/undefined/garbage", () => {
|
||||
expect(parseCart(null)).toEqual(EMPTY_CART);
|
||||
expect(parseCart(undefined)).toEqual(EMPTY_CART);
|
||||
expect(parseCart("")).toEqual(EMPTY_CART);
|
||||
expect(parseCart("not json")).toEqual(EMPTY_CART);
|
||||
});
|
||||
it("retourne EMPTY_CART quand le schéma est invalide", () => {
|
||||
expect(parseCart(JSON.stringify({ v: 99, items: [] }))).toEqual(EMPTY_CART);
|
||||
expect(parseCart(JSON.stringify({ v: 1, items: "nope" }))).toEqual(EMPTY_CART);
|
||||
});
|
||||
it("accepte un panier valide", () => {
|
||||
const valid = {
|
||||
v: 1,
|
||||
items: [
|
||||
{ itemId: "abc", qty: 2, startDate: "2026-06-01", endDate: "2026-06-03" },
|
||||
],
|
||||
};
|
||||
const out = parseCart(JSON.stringify(valid));
|
||||
expect(out.items).toHaveLength(1);
|
||||
expect(out.items[0].qty).toBe(2);
|
||||
});
|
||||
it("rejette une date au mauvais format", () => {
|
||||
const bad = {
|
||||
v: 1,
|
||||
items: [{ itemId: "abc", qty: 1, startDate: "1/6/2026", endDate: "2026-06-03" }],
|
||||
};
|
||||
expect(parseCart(JSON.stringify(bad))).toEqual(EMPTY_CART);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeCart", () => {
|
||||
it("ajoute un updatedAt ISO", () => {
|
||||
const s = serializeCart({ v: 1, items: [] });
|
||||
const parsed = JSON.parse(s);
|
||||
expect(parsed.v).toBe(1);
|
||||
expect(parsed.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
it("roundtrip parse(serialize(x)) === x sur les items", () => {
|
||||
const cart = {
|
||||
v: 1 as const,
|
||||
items: [
|
||||
{ itemId: "k1", qty: 3, startDate: "2026-07-01", endDate: "2026-07-08" },
|
||||
],
|
||||
};
|
||||
const round = parseCart(serializeCart(cart));
|
||||
expect(round.items).toEqual(cart.items);
|
||||
});
|
||||
});
|
||||
|
||||
// Calcul de commission (snapshot de la logique métier dans l'API checkout).
|
||||
// Ce test sert de garde-fou : si la formule change, faire évoluer aussi
|
||||
// `/api/rentals/checkout` (cf. commissionAmount = itemsTotal * pct / 100).
|
||||
describe("rental commission formula", () => {
|
||||
function commission(itemsTotal: number, pct: number): number {
|
||||
return Math.round((itemsTotal * pct) / 100 * 100) / 100;
|
||||
}
|
||||
|
||||
it("0% commission System D", () => {
|
||||
expect(commission(120, 0)).toBe(0);
|
||||
});
|
||||
it("15% sur 200€ = 30€", () => {
|
||||
expect(commission(200, 15)).toBe(30);
|
||||
});
|
||||
it("arrondit au centime", () => {
|
||||
expect(commission(33.33, 15)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// Disponibilité : la quantité libre = totalQty - somme des qty bloquées
|
||||
// chevauchant la fenêtre. Snapshot de la logique de `getItemAvailability`.
|
||||
describe("rental availability arithmetic", () => {
|
||||
function availableQty(totalQty: number, blockedQtys: number[]): number {
|
||||
const used = blockedQtys.reduce((a, b) => a + b, 0);
|
||||
return Math.max(0, totalQty - used);
|
||||
}
|
||||
it("totalQty quand rien n'est bloqué", () => {
|
||||
expect(availableQty(5, [])).toBe(5);
|
||||
});
|
||||
it("soustrait les blocages", () => {
|
||||
expect(availableQty(5, [2, 1])).toBe(2);
|
||||
});
|
||||
it("ne renvoie jamais de valeur négative", () => {
|
||||
expect(availableQty(3, [5])).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue