All checks were successful
CI / test (pull_request) Successful in 2m40s
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>
218 lines
6.4 KiB
TypeScript
218 lines
6.4 KiB
TypeScript
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(() => {});
|
||
}
|