feat(rental): Sprint O — reversements prestataires
All checks were successful
CI / test (push) Successful in 2m24s

This commit is contained in:
tarzzan 2026-06-03 02:59:49 +00:00
commit eee052b2a8
9 changed files with 713 additions and 0 deletions

View file

@ -0,0 +1,28 @@
-- Sprint O : reversements prestataires.
-- RentalPayoutMark trace les virements bancaires manuels effectués par System D
-- vers les RentalProvider tiers (le marketplace encaisse centralisé, redistribue
-- hors plateforme une fois par mois). Unique (provider, mois) pour empêcher
-- les marquages en doublon.
CREATE TABLE "RentalPayoutMark" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"periodMonth" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(10, 2) NOT NULL,
"reference" TEXT,
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"paidByEmail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalPayoutMark_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "RentalPayoutMark_providerId_periodMonth_key"
ON "RentalPayoutMark"("providerId", "periodMonth");
CREATE INDEX "RentalPayoutMark_periodMonth_idx"
ON "RentalPayoutMark"("periodMonth");
ALTER TABLE "RentalPayoutMark"
ADD CONSTRAINT "RentalPayoutMark_providerId_fkey"
FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -488,12 +488,35 @@ model RentalProvider {
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
items RentalItem[]
rentalBookings RentalBooking[]
payoutMarks RentalPayoutMark[]
@@index([active, approved])
@@index([managedByUserId])
@@index([organizationId])
}
/// Trace les reversements bancaires manuels (System D paie le provider hors plateforme).
/// La période est représentée par le mois (1er du mois minuit UTC) ; unique par
/// (provider, période) pour empêcher de marquer 2 fois le même mois.
model RentalPayoutMark {
id String @id @default(cuid())
providerId String
/// 1er du mois minuit UTC — sert de clé de période.
periodMonth DateTime
/// Montant effectivement viré au provider, en euros.
amount Decimal @db.Decimal(10, 2)
/// Référence de virement (optionnelle, à coller depuis la banque).
reference String?
paidAt DateTime @default(now())
paidByEmail String?
createdAt DateTime @default(now())
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
@@unique([providerId, periodMonth])
@@index([periodMonth])
}
model RentalItem {
id String @id @default(cuid())
providerId String

View file

@ -0,0 +1,126 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { ProviderPayout } from "@/lib/payouts";
type Props = {
payout: ProviderPayout;
markAction: (
providerId: string,
periodMonthISO: string,
fd: FormData,
) => Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }>;
unmarkAction: (providerId: string, periodMonthISO: string) => Promise<void>;
};
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export function MarkPaidForm({ payout, markAction, unmarkAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [opened, setOpened] = useState(false);
const [error, setError] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
startTransition(async () => {
const res = await markAction(payout.providerId, payout.periodMonth.toISOString(), fd);
if (!res.ok) {
setError(res.error);
return;
}
setOpened(false);
router.refresh();
});
}
function onUnmark() {
startTransition(async () => {
await unmarkAction(payout.providerId, payout.periodMonth.toISOString());
router.refresh();
});
}
if (payout.paid) {
return (
<div className="flex flex-col items-end gap-1 text-right">
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Payé {fmtEur(payout.paid.amount)}
</span>
{payout.paid.reference ? (
<span className="font-mono text-[10px] text-zinc-500">Ref : {payout.paid.reference}</span>
) : null}
<button
type="button"
onClick={onUnmark}
disabled={pending}
className="text-[10px] text-zinc-500 hover:text-rose-700"
>
Annuler marquage
</button>
</div>
);
}
if (payout.netAmount <= 0) {
return <span className="text-[11px] text-zinc-400"></span>;
}
if (!opened) {
return (
<button
type="button"
onClick={() => setOpened(true)}
className="rounded-md bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700"
>
Marquer payé
</button>
);
}
return (
<form action={onSubmit} className="flex flex-col items-end gap-1 rounded-md border border-emerald-200 bg-emerald-50/50 p-2">
<input
type="number"
name="amount"
step="0.01"
min={0}
defaultValue={payout.netAmount.toFixed(2)}
className="w-24 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
required
/>
<input
type="text"
name="reference"
placeholder="Réf. virement"
maxLength={100}
className="w-32 rounded border border-zinc-300 px-1.5 py-0.5 text-[11px]"
/>
{error ? <span className="text-[10px] text-rose-700">{error}</span> : null}
<div className="flex gap-1">
<button
type="button"
onClick={() => {
setOpened(false);
setError(null);
}}
disabled={pending}
className="text-[10px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
<button
type="submit"
disabled={pending}
className="rounded bg-emerald-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-emerald-700"
>
{pending ? "…" : "Confirmer"}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { requireRole } from "@/lib/authorization";
import {
createPayoutMark,
deletePayoutMark,
} from "@/lib/payouts";
import { prisma } from "@/lib/prisma";
import { sendPayoutSent } from "@/lib/email";
export async function markPayoutPaidAction(
providerId: string,
periodMonthISO: string,
fd: FormData,
): Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }> {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const amount = Number(fd.get("amount") ?? 0);
const reference = ((fd.get("reference") as string | null) ?? "").trim() || null;
if (!Number.isFinite(amount) || amount < 0) {
return { ok: false, error: "Montant invalide." };
}
const periodMonth = new Date(periodMonthISO);
if (Number.isNaN(periodMonth.getTime())) {
return { ok: false, error: "Période invalide." };
}
const res = await createPayoutMark({
providerId,
periodMonth,
amount,
reference,
paidByEmail: actor,
});
if (!res.ok) return res;
await recordAudit({
scope: "admin.payouts",
event: res.alreadyExists ? "payout.already_marked" : "payout.mark",
target: providerId,
actorEmail: actor,
details: {
periodMonth: periodMonth.toISOString().slice(0, 7),
amount,
reference,
},
});
// Notif provider best-effort (n'envoie que si on a un contactEmail)
if (!res.alreadyExists) {
try {
const provider = await prisma.rentalProvider.findUnique({
where: { id: providerId },
select: { name: true, contactEmail: true },
});
if (provider?.contactEmail) {
await sendPayoutSent(
provider.contactEmail,
provider.name,
periodMonth,
amount.toFixed(2),
reference,
);
}
} catch (e) {
console.error("[payouts] email send failed:", e instanceof Error ? e.message : e);
}
}
revalidatePath("/admin/payouts");
return { ok: true, alreadyExists: res.alreadyExists };
}
export async function unmarkPayoutPaidAction(providerId: string, periodMonthISO: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const periodMonth = new Date(periodMonthISO);
if (Number.isNaN(periodMonth.getTime())) return;
await deletePayoutMark(providerId, periodMonth);
await recordAudit({
scope: "admin.payouts",
event: "payout.unmark",
target: providerId,
actorEmail: actor,
details: { periodMonth: periodMonth.toISOString().slice(0, 7) },
});
revalidatePath("/admin/payouts");
}

View file

@ -0,0 +1,155 @@
import Link from "next/link";
import { formatMonth, listProviderPayouts } from "@/lib/payouts";
import { markPayoutPaidAction, unmarkPayoutPaidAction } from "./actions";
import { MarkPaidForm } from "./_components/MarkPaidForm";
export const dynamic = "force-dynamic";
export const metadata = { title: "Reversements prestataires — Karbé admin" };
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export default async function PayoutsAdminPage() {
const payouts = await listProviderPayouts({ monthsBack: 6 });
// Group by month
const byMonth = new Map<number, typeof payouts>();
for (const p of payouts) {
const k = p.periodMonth.getTime();
if (!byMonth.has(k)) byMonth.set(k, []);
byMonth.get(k)!.push(p);
}
// Globals
const totalDue = payouts
.filter((p) => !p.paid && p.netAmount > 0)
.reduce((s, p) => s + p.netAmount, 0);
const totalPaid = payouts
.filter((p) => p.paid)
.reduce((s, p) => s + (p.paid!.amount), 0);
return (
<div className="mx-auto max-w-6xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Reversements prestataires</h1>
<p className="mt-1 text-sm text-zinc-500">
Le marketplace encaisse centralisé sur System D ; voici les montants à reverser à chaque
prestataire pour les locations matériel des 6 derniers mois. System D n&apos;apparaît pas
dans la liste (commission 0 %).
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<KpiCard label="À payer" value={fmtEur(totalDue)} highlight />
<KpiCard label="Déjà payé" value={fmtEur(totalPaid)} />
<KpiCard label="Mois affichés" value={`${byMonth.size}`} />
</section>
{Array.from(byMonth.entries())
.sort((a, b) => b[0] - a[0])
.map(([periodTs, rows]) => {
const period = new Date(periodTs);
const monthDue = rows
.filter((r) => !r.paid && r.netAmount > 0)
.reduce((s, r) => s + r.netAmount, 0);
return (
<section
key={periodTs}
className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm"
>
<header className="flex items-baseline justify-between border-b border-zinc-100 px-4 py-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
{formatMonth(period)}
</h2>
<span className="text-xs text-zinc-500">
Reste à payer ce mois :{" "}
<span className="font-mono font-semibold text-zinc-900">{fmtEur(monthDue)}</span>
</span>
</header>
<table className="w-full text-sm">
<thead className="border-b border-zinc-100 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-3 py-1.5 text-left font-semibold">Prestataire</th>
<th className="px-3 py-1.5 text-right font-semibold">Résa</th>
<th className="px-3 py-1.5 text-right font-semibold">CA brut</th>
<th className="px-3 py-1.5 text-right font-semibold">Commission</th>
<th className="px-3 py-1.5 text-right font-semibold">Net </th>
<th className="px-3 py-1.5 text-right font-semibold">Statut</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows
.sort((a, b) => b.netAmount - a.netAmount)
.map((p) => (
<tr key={`${p.providerId}-${periodTs}`} className="hover:bg-zinc-50">
<td className="px-3 py-1.5">
<Link
href={`/admin/rental-providers/${p.providerId}`}
className="text-zinc-900 hover:underline"
>
{p.providerName}
</Link>
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{p.bookingsCount}
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{fmtEur(p.grossAmount)}
</td>
<td className="px-3 py-1.5 text-right font-mono text-zinc-700">
{fmtEur(p.commission)}
</td>
<td className="px-3 py-1.5 text-right font-mono font-semibold text-zinc-900">
{fmtEur(p.netAmount)}
</td>
<td className="px-3 py-1.5">
<div className="flex justify-end">
<MarkPaidForm
payout={p}
markAction={markPayoutPaidAction}
unmarkAction={unmarkPayoutPaidAction}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
})}
</div>
);
}
function KpiCard({
label,
value,
highlight,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={
"rounded-lg border bg-white px-4 py-3 shadow-sm " +
(highlight ? "border-emerald-300 bg-emerald-50/40" : "border-zinc-200")
}
>
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div
className={
"mt-1 text-2xl font-semibold font-mono " +
(highlight ? "text-emerald-700" : "text-zinc-900")
}
>
{value}
</div>
</div>
);
}

View file

@ -128,6 +128,7 @@ const GROUPS: NavGroup[] = [
items: [
{ href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
{ href: "/admin/rentals", label: "Locations matériel", icon: ICONS.bookings },
{ href: "/admin/payouts", label: "Reversements", icon: ICONS.bookings },
{ href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
],
},

View file

@ -411,6 +411,35 @@ export async function sendRentalConfirmed(
});
}
export async function sendPayoutSent(
to: string,
providerName: string,
periodMonth: Date,
amount: string,
reference: string | null,
): Promise<void> {
const monthLabel = periodMonth.toLocaleDateString("fr-FR", {
timeZone: "UTC",
year: "numeric",
month: "long",
});
await sendEmail({
to,
subject: `Reversement Karbé — ${monthLabel}`,
html: wrap(
`Reversement ${monthLabel}`,
`<p>Bonjour ${providerName},</p>
<p>Le reversement de vos locations matériel pour <strong>${monthLabel}</strong> a é effectué :</p>
<ul>
<li>Montant : <strong>${Number(amount).toFixed(2)} EUR</strong></li>
${reference ? `<li>Référence virement : <code>${reference}</code></li>` : ""}
</ul>
<p>Vérifiez votre compte bancaire dans les 1 à 3 jours ouvrés. En cas de question, répondez à cet email.</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;">Voir mes réservations</a></p>`,
),
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,

218
src/lib/payouts.ts Normal file
View file

@ -0,0 +1,218 @@
import "server-only";
import { Prisma } from "@/generated/prisma/client";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
/**
* Politique de reversement v1 :
* Pour chaque RentalBooking CONFIRMED/HANDED_OVER/RETURNED créée pendant le mois M,
* le provider reçoit (itemsTotal + depositTotal - commissionAmount).
* Le marketplace garde la commission (commissionAmount) et la caution est restituée
* via le provider qui la collecte au remise (hors flux Stripe).
*
* En pratique : net du au provider = itemsTotal - commissionAmount.
* La caution n'est PAS comptée dans le reversement (le provider la collecte
* directement auprès du client).
*/
const COUNTED_STATUSES: RentalBookingStatus[] = [
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
];
export type ProviderPayout = {
providerId: string;
providerName: string;
isSystemD: boolean;
/** 1er du mois minuit UTC. */
periodMonth: Date;
bookingsCount: number;
/** Sum itemsTotal pour le provider × mois. */
grossAmount: number;
/** Sum commissionAmount. */
commission: number;
/** Net dû au provider = gross - commission. */
netAmount: number;
/** Mark déjà enregistrée si payé. */
paid: {
paidAt: Date;
amount: number;
reference: string | null;
paidByEmail: string | null;
} | null;
};
/**
* 1er jour du mois en UTC pour normalisation.
*/
export function monthKey(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}
export function formatMonth(d: Date): string {
return d.toLocaleDateString("fr-FR", {
timeZone: "UTC",
year: "numeric",
month: "long",
});
}
/**
* Calcule les reversements à effectuer sur les `monthsBack` derniers mois.
* - Exclut System D (commission 0 % et c'est l'asso qui gère).
* - Renvoie tous les providers actifs, même ceux à 0 (pour visibilité).
* - Inclut le statut payé/non payé depuis RentalPayoutMark.
*/
export async function listProviderPayouts(opts: {
monthsBack?: number;
} = {}): Promise<ProviderPayout[]> {
const monthsBack = opts.monthsBack ?? 6;
const now = new Date();
const earliest = monthKey(
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (monthsBack - 1), 1)),
);
const [providers, bookings, marks] = await Promise.all([
prisma.rentalProvider.findMany({
where: { isSystemD: false },
select: { id: true, name: true, isSystemD: true },
}),
prisma.rentalBooking.findMany({
where: {
status: { in: COUNTED_STATUSES },
createdAt: { gte: earliest },
provider: { isSystemD: false },
},
select: {
providerId: true,
createdAt: true,
itemsTotal: true,
commissionAmount: true,
},
}),
prisma.rentalPayoutMark.findMany({
where: { periodMonth: { gte: earliest } },
}),
]);
const result: ProviderPayout[] = [];
const monthsList: Date[] = [];
for (let i = 0; i < monthsBack; i++) {
monthsList.push(
monthKey(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1))),
);
}
// Init grid provider × month avec zéros
type Cell = {
bookingsCount: number;
grossAmount: Prisma.Decimal;
commission: Prisma.Decimal;
};
const grid = new Map<string, Cell>();
const cellKey = (providerId: string, periodTs: number) => `${providerId}:${periodTs}`;
for (const p of providers) {
for (const m of monthsList) {
grid.set(cellKey(p.id, m.getTime()), {
bookingsCount: 0,
grossAmount: new Prisma.Decimal(0),
commission: new Prisma.Decimal(0),
});
}
}
// Aggrège les bookings
for (const b of bookings) {
const period = monthKey(b.createdAt);
const k = cellKey(b.providerId, period.getTime());
const cell = grid.get(k);
if (!cell) continue;
cell.bookingsCount++;
cell.grossAmount = cell.grossAmount.add(b.itemsTotal);
cell.commission = cell.commission.add(b.commissionAmount);
}
// Index des marks
const markIndex = new Map<string, (typeof marks)[number]>();
for (const m of marks) {
markIndex.set(cellKey(m.providerId, m.periodMonth.getTime()), m);
}
// Produit le résultat
for (const p of providers) {
for (const m of monthsList) {
const k = cellKey(p.id, m.getTime());
const cell = grid.get(k)!;
const net = cell.grossAmount.sub(cell.commission);
const mark = markIndex.get(k);
result.push({
providerId: p.id,
providerName: p.name,
isSystemD: p.isSystemD,
periodMonth: m,
bookingsCount: cell.bookingsCount,
grossAmount: cell.grossAmount.toDecimalPlaces(2).toNumber(),
commission: cell.commission.toDecimalPlaces(2).toNumber(),
netAmount: net.toDecimalPlaces(2).toNumber(),
paid: mark
? {
paidAt: mark.paidAt,
amount: Number(mark.amount),
reference: mark.reference,
paidByEmail: mark.paidByEmail,
}
: null,
});
}
}
// Tri : mois décroissant puis provider
return result.sort(
(a, b) =>
b.periodMonth.getTime() - a.periodMonth.getTime() ||
a.providerName.localeCompare(b.providerName, "fr"),
);
}
/**
* Crée un RentalPayoutMark (idempotent via unique constraint provider+period).
*/
export async function createPayoutMark(opts: {
providerId: string;
periodMonth: Date;
amount: number;
reference?: string | null;
paidByEmail: string | null;
}): Promise<{ ok: true; alreadyExists: boolean } | { ok: false; error: string }> {
const period = monthKey(opts.periodMonth);
const existing = await prisma.rentalPayoutMark.findUnique({
where: { providerId_periodMonth: { providerId: opts.providerId, periodMonth: period } },
select: { id: true },
});
if (existing) return { ok: true, alreadyExists: true };
await prisma.rentalPayoutMark.create({
data: {
providerId: opts.providerId,
periodMonth: period,
amount: new Prisma.Decimal(opts.amount),
reference: opts.reference ?? null,
paidByEmail: opts.paidByEmail,
},
});
return { ok: true, alreadyExists: false };
}
export async function deletePayoutMark(
providerId: string,
periodMonth: Date,
): Promise<void> {
const period = monthKey(periodMonth);
await prisma.rentalPayoutMark
.delete({
where: { providerId_periodMonth: { providerId, periodMonth: period } },
})
.catch(() => {});
}

37
tests/lib/payouts.test.ts Normal file
View file

@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("server-only", () => ({}));
// payouts.ts importe prisma qui jette si DATABASE_URL absent — mock le module entier.
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const { monthKey, formatMonth } = await import("@/lib/payouts");
describe("monthKey", () => {
it("normalise à minuit UTC du 1er du mois", () => {
const k = monthKey(new Date("2026-03-15T14:30:00Z"));
expect(k.getUTCFullYear()).toBe(2026);
expect(k.getUTCMonth()).toBe(2); // mars = 2 (0-indexed)
expect(k.getUTCDate()).toBe(1);
expect(k.getUTCHours()).toBe(0);
expect(k.getUTCMinutes()).toBe(0);
});
it("idempotent (mêmes inputs → même sortie)", () => {
const a = monthKey(new Date("2026-06-30T23:59:59Z"));
const b = monthKey(new Date("2026-06-01T00:00:00Z"));
expect(a.getTime()).toBe(b.getTime());
});
it("traverse janvier sans bug", () => {
const k = monthKey(new Date("2026-01-15T10:00:00Z"));
expect(k.toISOString().slice(0, 10)).toBe("2026-01-01");
});
});
describe("formatMonth", () => {
it("rend un libellé fr-FR lisible", () => {
const label = formatMonth(new Date(Date.UTC(2026, 5, 1)));
expect(label).toContain("juin");
expect(label).toContain("2026");
});
});