karbe/prisma/schema.prisma
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

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])
}