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(() => {}); }