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>
619 lines
17 KiB
Text
619 lines
17 KiB
Text
generator client {
|
|
provider = "prisma-client"
|
|
output = "../src/generated/prisma"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
enum UserRole {
|
|
OWNER
|
|
CE_MANAGER
|
|
CE_MEMBER
|
|
TOURIST
|
|
ADMIN
|
|
RENTAL_PROVIDER
|
|
}
|
|
|
|
enum CarbetStatus {
|
|
DRAFT
|
|
PUBLISHED
|
|
ARCHIVED
|
|
}
|
|
|
|
enum MediaType {
|
|
PHOTO
|
|
VIDEO
|
|
}
|
|
|
|
enum AvailabilityScope {
|
|
PUBLIC
|
|
CE_ONLY
|
|
}
|
|
|
|
enum AvailabilityBlockReason {
|
|
NONE
|
|
CE_BLOCKED
|
|
WEEKEND_BLOCKED
|
|
}
|
|
|
|
enum BookingStatus {
|
|
PENDING
|
|
CONFIRMED
|
|
CANCELLED
|
|
COMPLETED
|
|
}
|
|
|
|
enum PaymentStatus {
|
|
PENDING
|
|
AUTHORIZED
|
|
SUCCEEDED
|
|
FAILED
|
|
REFUNDED
|
|
}
|
|
|
|
enum SubscriptionStatus {
|
|
TRIAL
|
|
ACTIVE
|
|
PAST_DUE
|
|
CANCELED
|
|
}
|
|
|
|
enum AccessType {
|
|
ROAD_AND_RIVER
|
|
RIVER_ONLY
|
|
}
|
|
|
|
enum TransportMode {
|
|
OWNER_PROVIDES
|
|
SELF_ARRANGE
|
|
PARTNER_PROVIDER
|
|
}
|
|
|
|
model Organization {
|
|
id String @id @default(cuid())
|
|
name String
|
|
slug String @unique
|
|
description String?
|
|
contactEmail String?
|
|
approved Boolean @default(false)
|
|
approvedAt DateTime?
|
|
approvedBy String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
members User[]
|
|
carbetMemberships OrganizationCarbetMembership[]
|
|
rentalProviders RentalProvider[]
|
|
invites OrgInviteToken[]
|
|
|
|
@@index([name])
|
|
@@index([approved])
|
|
}
|
|
|
|
/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER.
|
|
/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN.
|
|
/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire.
|
|
model OrgInviteToken {
|
|
tokenHash String @id
|
|
organizationId String
|
|
email String?
|
|
createdByUserId String?
|
|
expiresAt DateTime
|
|
usedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([organizationId])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
|
|
/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
|
|
/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
|
|
/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication.
|
|
model OrganizationCarbetMembership {
|
|
organizationId String
|
|
carbetId String
|
|
addedByUserId String?
|
|
addedAt DateTime @default(now())
|
|
|
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([organizationId, carbetId])
|
|
@@index([carbetId])
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
passwordHash String
|
|
firstName String
|
|
lastName String
|
|
phone String?
|
|
role UserRole
|
|
organizationId String?
|
|
avatarUrl String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
|
carbets Carbet[] @relation("CarbetOwner")
|
|
bookings Booking[] @relation("BookingTenant")
|
|
reviews Review[] @relation("ReviewAuthor")
|
|
subscriptions Subscription[]
|
|
rentalProviders RentalProvider[]
|
|
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
|
|
|
|
@@index([organizationId])
|
|
@@index([role])
|
|
}
|
|
|
|
model Carbet {
|
|
id String @id @default(cuid())
|
|
ownerId String
|
|
title String
|
|
slug String @unique
|
|
description String
|
|
river String
|
|
latitude Decimal @db.Decimal(9, 6)
|
|
longitude Decimal @db.Decimal(9, 6)
|
|
embarkPoint String
|
|
// Pirogue : obligatoire pour RIVER_ONLY, optionnelle pour ROAD_AND_RIVER
|
|
// (estimation pour ceux qui veulent quand même venir en pirogue).
|
|
pirogueDurationMin Int?
|
|
accessType AccessType @default(ROAD_AND_RIVER)
|
|
// Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste).
|
|
roadAccessNote String?
|
|
capacity Int
|
|
// 4 critères opérationnels dealbreakers (dispo en filtres + badges UI)
|
|
roadAccess RoadAccess?
|
|
electricity Electricity?
|
|
gsmAtCarbet Boolean @default(false)
|
|
gsmExitDistanceKm Decimal? @db.Decimal(4, 2)
|
|
// Prix par nuit pour le carbet entier (toute capacité). En euros.
|
|
nightlyPrice Decimal @db.Decimal(10, 2) @default(0)
|
|
// Contraintes séjour (plugin min-stay). null = pas de contrainte.
|
|
minStayNights Int?
|
|
maxStayNights Int?
|
|
minCapacity Int?
|
|
// Contraintes saisonnières (plugin seasonality). JSON libre, schéma type :
|
|
// { closedInLowWater: bool, closedSeasons: ["WET"|"DRY"|"LOW_WATER"][], note: string }
|
|
seasonalConstraints Json?
|
|
// Plugin pirogue-providers : qui organise le transport ?
|
|
transportMode TransportMode?
|
|
pirogueProviderId String?
|
|
status CarbetStatus @default(DRAFT)
|
|
lastBookedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
|
|
pirogueProvider PirogueProvider? @relation(fields: [pirogueProviderId], references: [id], onDelete: SetNull)
|
|
amenities CarbetAmenity[]
|
|
media Media[]
|
|
availabilities Availability[]
|
|
bookings Booking[]
|
|
reviews Review[]
|
|
subscriptions Subscription[]
|
|
organizations OrganizationCarbetMembership[]
|
|
|
|
@@index([ownerId])
|
|
@@index([status])
|
|
@@index([river])
|
|
@@index([accessType])
|
|
@@index([pirogueProviderId])
|
|
}
|
|
|
|
model PirogueProvider {
|
|
id String @id @default(cuid())
|
|
name String
|
|
contactEmail String?
|
|
contactPhone String?
|
|
rivers String[] @default([])
|
|
pricingNote String?
|
|
description String?
|
|
active Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
carbets Carbet[]
|
|
|
|
@@index([active])
|
|
}
|
|
|
|
model Amenity {
|
|
id String @id @default(cuid())
|
|
key String @unique
|
|
label String
|
|
description String?
|
|
createdAt DateTime @default(now())
|
|
|
|
carbets CarbetAmenity[]
|
|
}
|
|
|
|
model CarbetAmenity {
|
|
carbetId String
|
|
amenityId String
|
|
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
|
amenity Amenity @relation(fields: [amenityId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([carbetId, amenityId])
|
|
@@index([amenityId])
|
|
}
|
|
|
|
model Media {
|
|
id String @id @default(cuid())
|
|
carbetId String
|
|
type MediaType
|
|
s3Key String
|
|
s3Url String
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([carbetId, sortOrder])
|
|
}
|
|
|
|
model Availability {
|
|
id String @id @default(cuid())
|
|
carbetId String
|
|
startDate DateTime
|
|
endDate DateTime
|
|
scope AvailabilityScope @default(PUBLIC)
|
|
blockReason AvailabilityBlockReason @default(NONE)
|
|
isAvailable Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([carbetId])
|
|
@@index([scope, blockReason])
|
|
@@index([startDate, endDate])
|
|
}
|
|
|
|
model Booking {
|
|
id String @id @default(cuid())
|
|
carbetId String
|
|
tenantId String
|
|
startDate DateTime
|
|
endDate DateTime
|
|
guestCount Int
|
|
status BookingStatus @default(PENDING)
|
|
amount Decimal @db.Decimal(10, 2)
|
|
currency String @default("EUR")
|
|
paymentStatus PaymentStatus @default(PENDING)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
|
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
|
|
|
review Review?
|
|
rentalBookings RentalBooking[]
|
|
|
|
@@index([carbetId])
|
|
@@index([tenantId])
|
|
@@index([status, paymentStatus])
|
|
@@index([startDate, endDate])
|
|
}
|
|
|
|
model Subscription {
|
|
id String @id @default(cuid())
|
|
ownerId String
|
|
carbetId String
|
|
provider String
|
|
providerSubId String? @unique
|
|
status SubscriptionStatus @default(TRIAL)
|
|
startedAt DateTime @default(now())
|
|
renewedAt DateTime?
|
|
canceledAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([ownerId])
|
|
@@index([carbetId])
|
|
@@index([status])
|
|
}
|
|
|
|
model Review {
|
|
id String @id @default(cuid())
|
|
bookingId String @unique
|
|
carbetId String
|
|
authorId String
|
|
rating Int
|
|
comment String?
|
|
hostResponse String?
|
|
hostRespondedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
|
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
|
author User @relation("ReviewAuthor", fields: [authorId], references: [id], onDelete: Restrict)
|
|
|
|
@@index([carbetId])
|
|
@@index([authorId])
|
|
}
|
|
|
|
model Plugin {
|
|
key String @id
|
|
name String
|
|
description String
|
|
category String
|
|
version String @default("0.1.0")
|
|
enabled Boolean @default(false)
|
|
config Json @default("{}")
|
|
migrationsApplied String[] @default([])
|
|
installedAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
lastEnabledAt DateTime?
|
|
lastDisabledAt DateTime?
|
|
|
|
@@index([category])
|
|
@@index([enabled])
|
|
}
|
|
|
|
model ContentPage {
|
|
slug String
|
|
lang String @default("fr")
|
|
title String
|
|
body String
|
|
// 'general' (about, faq, ...) ou 'legal' (cgv, mentions, ...)
|
|
category String @default("general")
|
|
published Boolean @default(true)
|
|
lastEditedBy String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@id([slug, lang])
|
|
@@index([slug])
|
|
@@index([category])
|
|
@@index([published])
|
|
}
|
|
|
|
model AuditLog {
|
|
id String @id @default(cuid())
|
|
scope String
|
|
event String
|
|
target String?
|
|
actorEmail String?
|
|
details Json @default("{}")
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([scope])
|
|
@@index([event])
|
|
@@index([actorEmail])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
model Setting {
|
|
key String @id
|
|
value Json @default("{}")
|
|
updatedAt DateTime @updatedAt
|
|
updatedBy String?
|
|
}
|
|
|
|
model Translation {
|
|
key String
|
|
lang String
|
|
value String
|
|
updatedAt DateTime @updatedAt
|
|
updatedBy String?
|
|
|
|
@@id([key, lang])
|
|
@@index([lang])
|
|
}
|
|
|
|
model PasswordResetToken {
|
|
tokenHash String @id
|
|
userId String
|
|
expiresAt DateTime
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
model Favorite {
|
|
userId String
|
|
carbetId String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@id([userId, carbetId])
|
|
@@index([userId])
|
|
@@index([carbetId])
|
|
}
|
|
|
|
enum RoadAccess {
|
|
NONE
|
|
DRY_SEASON_ONLY
|
|
ALL_YEAR
|
|
}
|
|
|
|
enum Electricity {
|
|
NONE
|
|
SOLAR
|
|
GENERATOR_READY
|
|
EDF
|
|
}
|
|
|
|
enum RentalCategory {
|
|
SLEEP
|
|
NAVIGATION
|
|
FISHING
|
|
COOKING
|
|
SAFETY
|
|
}
|
|
|
|
enum RentalBookingStatus {
|
|
PENDING
|
|
CONFIRMED
|
|
HANDED_OVER
|
|
RETURNED
|
|
CANCELLED
|
|
}
|
|
|
|
model RentalProvider {
|
|
id String @id @default(cuid())
|
|
name String
|
|
isSystemD Boolean @default(false)
|
|
managedByUserId String?
|
|
/// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER
|
|
/// membre de l'org peut gérer items et réservations en plus du manager nominal.
|
|
organizationId String?
|
|
contactEmail String?
|
|
contactPhone String?
|
|
rivers String[] @default([])
|
|
description String?
|
|
commissionPct Decimal @db.Decimal(5, 2) @default(0)
|
|
active Boolean @default(true)
|
|
approved Boolean @default(false)
|
|
approvedAt DateTime?
|
|
approvedBy String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
|
|
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
|
|
category RentalCategory
|
|
name String
|
|
description String?
|
|
imageUrl String?
|
|
pricePerDay Decimal @db.Decimal(8, 2)
|
|
pricePerWeek Decimal? @db.Decimal(8, 2)
|
|
deposit Decimal @db.Decimal(8, 2) @default(0)
|
|
totalQty Int @default(1)
|
|
withMotor Boolean @default(false)
|
|
fuelIncluded Boolean @default(false)
|
|
requiresLicense Boolean @default(false)
|
|
active Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
|
availabilities RentalItemAvailability[]
|
|
lines RentalLine[]
|
|
media RentalItemMedia[]
|
|
|
|
@@index([providerId])
|
|
@@index([category, active])
|
|
}
|
|
|
|
model RentalItemMedia {
|
|
id String @id @default(cuid())
|
|
itemId String
|
|
type MediaType
|
|
s3Key String
|
|
s3Url String
|
|
sortOrder Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
|
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([itemId, sortOrder])
|
|
}
|
|
|
|
model RentalItemAvailability {
|
|
id String @id @default(cuid())
|
|
itemId String
|
|
startDate DateTime
|
|
endDate DateTime
|
|
qty Int
|
|
reason String
|
|
rentalBookingId String?
|
|
createdAt DateTime @default(now())
|
|
|
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([itemId, startDate, endDate])
|
|
@@index([rentalBookingId])
|
|
}
|
|
|
|
model RentalBooking {
|
|
id String @id @default(cuid())
|
|
bookingId String?
|
|
tenantId String
|
|
providerId String
|
|
startDate DateTime
|
|
endDate DateTime
|
|
status RentalBookingStatus @default(PENDING)
|
|
paymentStatus PaymentStatus @default(PENDING)
|
|
itemsTotal Decimal @db.Decimal(10, 2)
|
|
depositTotal Decimal @db.Decimal(10, 2)
|
|
commissionAmount Decimal @db.Decimal(10, 2) @default(0)
|
|
amount Decimal @db.Decimal(10, 2)
|
|
currency String @default("EUR")
|
|
stripeSessionId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
|
tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
|
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
|
lines RentalLine[]
|
|
|
|
@@index([tenantId, status])
|
|
@@index([providerId, status])
|
|
@@index([bookingId])
|
|
@@index([startDate, endDate])
|
|
}
|
|
|
|
model RentalLine {
|
|
id String @id @default(cuid())
|
|
rentalBookingId String
|
|
itemId String
|
|
qty Int
|
|
pricePerDay Decimal @db.Decimal(8, 2)
|
|
deposit Decimal @db.Decimal(8, 2) @default(0)
|
|
lineTotal Decimal @db.Decimal(10, 2)
|
|
|
|
rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
|
|
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
|
|
|
@@index([rentalBookingId])
|
|
}
|