karbe/src/lib/payouts.ts
Ubuntu 5be62f012f
All checks were successful
CI / test (pull_request) Successful in 2m40s
feat(rental): Sprint O — reversements prestataires (payouts)
Marketplace encaisse centralisé sur System D → besoin de tracer les
virements mensuels aux prestataires tiers. Migration appliquée prod.

Schema :
- Modèle RentalPayoutMark { id, providerId, periodMonth, amount,
  reference, paidAt, paidByEmail }. Unique (providerId, periodMonth)
  → 1 mark = 1 mois = 1 virement par provider.

Lib src/lib/payouts.ts :
- monthKey(d) → 1er du mois minuit UTC (clé de période).
- listProviderPayouts({monthsBack=6}) → grid provider × mois avec
  bookingsCount + grossAmount (itemsTotal) + commission + netAmount
  (gross-commission) + statut paid via RentalPayoutMark. Exclut
  System D (commission 0%, géré par l'asso). Statut « payé » lu
  depuis les marks. Tri : mois desc puis providerName.
- createPayoutMark (idempotent via findUnique avant insert) +
  deletePayoutMark.

Politique : net dû = itemsTotal - commissionAmount (depositTotal
hors flux, collecté par le provider auprès du client). Politique
documentée dans le commentaire en tête de payouts.ts.

/admin/payouts/page.tsx :
- 3 KPIs (À payer / Déjà payé / Mois affichés).
- Une section par mois (6 derniers), tableau provider × CA brut +
  commission + net dû + statut.
- MarkPaidForm : bouton « Marquer payé » → form inline (amount
  pré-rempli avec net dû, reference optionnelle) → action
  markPayoutPaidAction. Statut payé montre amount + ref + bouton
  « Annuler marquage ».

Server actions :
- markPayoutPaidAction (admin only, idempotent, audit
  admin.payouts/payout.mark + payout.already_marked) → envoie
  sendPayoutSent au contactEmail du provider (best-effort).
- unmarkPayoutPaidAction → delete + audit payout.unmark.

Email sendPayoutSent : notification au provider quand un virement est
marqué payé. Inclut amount + reference + lien dashboard.

Sidebar admin gagne entrée « Reversements » sous Activité.

Tests vitest tests/lib/payouts.test.ts (4 cas) : monthKey
normalisation UTC + idempotence + janvier sans bug, formatMonth fr-FR.
Total : 74/74 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 02:59:16 +00:00

218 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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