feat(rental): Sprint O — reversements prestataires
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
eee052b2a8
9 changed files with 713 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
126
src/app/admin/payouts/_components/MarkPaidForm.tsx
Normal file
126
src/app/admin/payouts/_components/MarkPaidForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/app/admin/payouts/actions.ts
Normal file
96
src/app/admin/payouts/actions.ts
Normal 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");
|
||||
}
|
||||
155
src/app/admin/payouts/page.tsx
Normal file
155
src/app/admin/payouts/page.tsx
Normal 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'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 dû</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 été 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
218
src/lib/payouts.ts
Normal 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
37
tests/lib/payouts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue