diff --git a/prisma/migrations/20260603400000_rental_payout_mark/migration.sql b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql new file mode 100644 index 0000000..cff28de --- /dev/null +++ b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4294008..6ae7e3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/admin/payouts/_components/MarkPaidForm.tsx b/src/app/admin/payouts/_components/MarkPaidForm.tsx new file mode 100644 index 0000000..b2129d1 --- /dev/null +++ b/src/app/admin/payouts/_components/MarkPaidForm.tsx @@ -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; +}; + +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(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 ( +
+ + Payé {fmtEur(payout.paid.amount)} + + {payout.paid.reference ? ( + Ref : {payout.paid.reference} + ) : null} + +
+ ); + } + + if (payout.netAmount <= 0) { + return ; + } + + if (!opened) { + return ( + + ); + } + + return ( +
+ + + {error ? {error} : null} +
+ + +
+
+ ); +} diff --git a/src/app/admin/payouts/actions.ts b/src/app/admin/payouts/actions.ts new file mode 100644 index 0000000..ac92fd2 --- /dev/null +++ b/src/app/admin/payouts/actions.ts @@ -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"); +} diff --git a/src/app/admin/payouts/page.tsx b/src/app/admin/payouts/page.tsx new file mode 100644 index 0000000..0c40c19 --- /dev/null +++ b/src/app/admin/payouts/page.tsx @@ -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(); + 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 ( +
+
+

Reversements prestataires

+

+ 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 %). +

+
+ +
+ + + +
+ + {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 ( +
+
+

+ {formatMonth(period)} +

+ + Reste à payer ce mois :{" "} + {fmtEur(monthDue)} + +
+ + + + + + + + + + + + + {rows + .sort((a, b) => b.netAmount - a.netAmount) + .map((p) => ( + + + + + + + + + ))} + +
PrestataireRésaCA brutCommissionNet dûStatut
+ + {p.providerName} + + + {p.bookingsCount} + + {fmtEur(p.grossAmount)} + + {fmtEur(p.commission)} + + {fmtEur(p.netAmount)} + +
+ +
+
+
+ ); + })} +
+ ); +} + +function KpiCard({ + label, + value, + highlight, +}: { + label: string; + value: string; + highlight?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx index ddd52ad..6eae7d0 100644 --- a/src/components/admin/Sidebar.tsx +++ b/src/components/admin/Sidebar.tsx @@ -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 }, ], }, diff --git a/src/lib/email.ts b/src/lib/email.ts index 894bf1a..7bb7779 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -411,6 +411,35 @@ export async function sendRentalConfirmed( }); } +export async function sendPayoutSent( + to: string, + providerName: string, + periodMonth: Date, + amount: string, + reference: string | null, +): Promise { + const monthLabel = periodMonth.toLocaleDateString("fr-FR", { + timeZone: "UTC", + year: "numeric", + month: "long", + }); + await sendEmail({ + to, + subject: `Reversement Karbé — ${monthLabel}`, + html: wrap( + `Reversement ${monthLabel}`, + `

Bonjour ${providerName},

+

Le reversement de vos locations matériel pour ${monthLabel} a été effectué :

+
    +
  • Montant : ${Number(amount).toFixed(2)} EUR
  • + ${reference ? `
  • Référence virement : ${reference}
  • ` : ""} +
+

Vérifiez votre compte bancaire dans les 1 à 3 jours ouvrés. En cas de question, répondez à cet email.

+

Voir mes réservations

`, + ), + }); +} + export async function sendBookingRefunded( to: string, firstName: string, diff --git a/src/lib/payouts.ts b/src/lib/payouts.ts new file mode 100644 index 0000000..7e63e0a --- /dev/null +++ b/src/lib/payouts.ts @@ -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 { + 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(); + 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(); + 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 { + const period = monthKey(periodMonth); + await prisma.rentalPayoutMark + .delete({ + where: { providerId_periodMonth: { providerId, periodMonth: period } }, + }) + .catch(() => {}); +} diff --git a/tests/lib/payouts.test.ts b/tests/lib/payouts.test.ts new file mode 100644 index 0000000..dfc0cb9 --- /dev/null +++ b/tests/lib/payouts.test.ts @@ -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"); + }); +});