Espace Financement — Schéma de données (pour la migration)¶
Document de référence pour le mapping des données Bubble → nouvel EF. Le détail de chaque table et de chaque champ — son rôle et son but — est ci-dessous, accompagné des diagrammes (modèle de données, machine à états, matrice des rôles). 💡 Un diagramme trop dense ? Le lien « Ouvrir en plein écran » sous chacun l'affiche en grand et zoomable.
Vue d'ensemble (MCD)¶
erDiagram
better_auth_user ||--|| project_financing_request_user : "1-1"
project_financing_request_user ||--o{ project_financing_request : "1-N"
project_financing_request_user ||--o{ project_financing_request_user_role : "N projets"
project_financing_request ||--o{ project_financing_request_user_role : "N utilisateurs N:N"
project_financing_request ||--o{ project_financing_request_user_invitation : "1-N invitations"
project_financing_request ||--|| project_financing_request_presentation : "1-1"
project_owner_company ||--o{ project_financing_request : "1-N (borrower)"
project_owner_company ||--o{ project_owner_company_representative : "1-N"
project_financing_request_user |o--o{ project_owner_company_representative : "signataire associe (projectUserId)"
project_financing_request ||--o{ project_financing_request_cautionnaire : "1-N"
project_owner_company_representative |o--o{ project_financing_request_cautionnaire : "0-N"
project_financing_request ||--o{ project_financing_request_question : "1-N"
project_financing_request ||--o{ project_financing_request_document_request : "1-N"
better_auth_user ||--o{ document : "1-N (deposant)"
document ||--o{ project_document_link : "1-N rattachements"
project_financing_request |o--o{ project_document_link : "0-N (FK nullable)"
project_owner_company |o--o{ project_document_link : "0-N (FK nullable - entité décrite)"
project_owner_company_representative |o--o{ project_document_link : "0-N (FK nullable - entité décrite)"
project_financing_request_document_request |o--o{ project_document_link : "0-N (source=pdp-analysis)"
project_financing_request ||--o{ project_financing_request_offer : "1-N (versions)"
project_financing_request |o--|| properties : "1-1 par valeur (pas de FK) - la ligne n'existe qu'une fois finance"
better_auth_user {
text id PK "EXTERNE - Better Auth"
}
project_financing_request_user {
uuid id PK "table plate"
text betterAuthUserId FK "UK + FK better_auth_user"
}
project_financing_request {
jsonb json "id, projectId, createdByUserId, status, isBorrowerStepDeferred (REQUIS), reanalysisComment, notary{}, investorBenefits, createdAt, updatedAt (+ champs par variant)"
uuid id_view PK
uuid projectId_view UK "id du projet finance, reserve des la creation - 1 projet = 1 dossier"
uuid createdByUserId_view FK
text status_view "draft,in-analysis,blocked,offer,finalization,signature,completed,rejected,canceled"
uuid companyId FK "colonne plate, hors json - societe emprunteuse"
}
project_financing_request_presentation {
jsonb json "id, createdByUserId, projectId, status, name, category, fundingAmountRequested, fundsNeededByDate, description, address, coordinates{latitude,longitude}, localizationDescription, totalSurface, mainImageKey, secondaryImageKeys[], lots[], createdAt, updatedAt"
uuid id_view PK
uuid projectId_view UK
}
project_owner_company {
jsonb json "id, status, company{}, representatives[], banking{}, createdAt, updatedAt - PLUS de projectId"
uuid id_view PK
}
project_owner_company_representative {
uuid id PK "table plate"
uuid companyId FK
jsonb identity "individual ou business"
uuid projectUserId FK "nullable - signataire associe"
timestamptz archivedAt "nullable - representant sorti"
}
project_financing_request_cautionnaire {
uuid id PK "table plate"
uuid projectId FK "id du dossier"
uuid representativeId FK "nullable - exclusif avec identity"
jsonb identity "nullable - exclusif avec representativeId"
}
document {
uuid id PK "table PLATE - pas de json"
text s3Key UK "cle S3 - UNIQUE non partiel"
text filename
text mimeType "enum 9 valeurs - non contraint en SQL"
int sizeBytes
text betterAuthUserId FK "better_auth_user - le deposant"
text createdByType "pdp_user,bricks_admin,notary"
text legacySource "nullable - tracer l origine Bubble"
timestamptz deletedAt "nullable - soft delete du fichier"
}
project_document_link {
uuid id PK "table PLATE - pas de json"
uuid documentId FK "document - ON DELETE CASCADE"
uuid companyId FK "nullable - entité décrite"
uuid projectId FK "nullable - dossier de dépôt"
uuid representativeId FK "nullable - entité décrite"
text kind "nullable - 35 valeurs - null en onboarding"
text source "pdp-onboarding,pdp-analysis,pdp-finalization,notary,back-office"
uuid documentRequestId FK "nullable - seulement si source=pdp-analysis"
timestamptz archivedAt "nullable - detacher sans supprimer"
timestamptz validatedAt "nullable - jamais avec refusedAt"
timestamptz refusedAt "nullable"
text comment "nullable - avec commentedAt ou aucun des deux"
}
project_financing_request_question {
jsonb json "id, projectId, text, askedAt, answer{text,answeredAt}, createdAt, updatedAt"
uuid id_view PK
uuid projectId_view FK
}
project_financing_request_document_request {
jsonb json "id, projectId, label, description, kind (REQUIS), requestedAt, createdAt, updatedAt"
uuid id_view PK
uuid projectId_view FK
}
project_financing_request_offer {
jsonb json "id, financingRequestId, version, amount{}, durationInMonths{}, interestRate{}, contractBudget{}, fees{}, operationCost, ownerContribution, ownerTransferAmount, createdAt, updatedAt"
uuid id_view PK
uuid financingRequestId_view FK
int version_view "unique (financingRequestId, version)"
}
properties {
uuid id PK "EXTERNE - legacy - vaut le projectId reserve sur le dossier"
}
project_financing_request_user_role {
uuid id PK
uuid projectId FK
uuid createdByUserId FK
text role "representative,collaborator,apporteur_affaires"
timestamptz deletedAt "soft delete - unique partiel sur les droits actifs"
}
project_financing_request_user_invitation {
uuid id PK
uuid projectId FK
text email "minuscules - unique partiel si status=pending"
text tokenHash UK
text status "pending,accepted"
uuid userRoleId FK "renseigne a l acceptation"
}
Comment lire ce document¶
Le module utilise deux formats de table :
- Format « relationnel » : colonnes SQL classiques, pas de
json. Tablesproject_financing_request_user,document,project_document_link,project_owner_company_representative,project_financing_request_cautionnaire,project_financing_request_user_role,project_financing_request_user_invitation. - Format « JSONB + colonnes générées » (toutes les autres) :
- une colonne
json(jsonb) contient tous les champs métier ; - des colonnes
<champ>_viewsont générées automatiquement depuis lejson(GENERATED ALWAYS AS (json->>'…') STORED). Elles servent de clé primaire / clé étrangère / index. On ne les écrit jamais directement.
colonne _view |
type SQL | dérivée de |
|---|---|---|
id_view |
uuid |
json.id → PRIMARY KEY |
projectId_view, createdByUserId_view |
uuid |
json.… → FK |
status_view |
text |
json.… → index |
createdAt_view, updatedAt_view |
timestamptz |
json.… |
⚠️ Pour la migration : on peuple le
json(un objet complet), jamais les colonnes_view(qui se calculent seules). Les ids et les dates sont des strings dans lejson(dates au format ISO).
Légende : ✅ existe en base · 🔶 à créer · 🌐 donnée d'origine externe (autre système).
1. project_financing_request_user ✅ — format plat¶
Rôle : l'identité « porteur de projet » d'un utilisateur dans l'EF. Fait le pont entre le compte d'authentification (Better Auth) et le métier — toutes les demandes de financement sont rattachées à un owner.
| colonne | type | rôle |
|---|---|---|
id |
uuid |
clé primaire de l'owner |
betterAuthUserId |
text |
🌐 référence le compte d'auth (better_auth_user.id). UNIQUE : un compte = un seul owner. La ligne owner est créée à la 1ʳᵉ interaction du user. ⚠️ la colonne SQL est text, mais le schéma applicatif exige un uuid : une valeur non-uuid passe l'insert puis casse la lecture |
createdAt, updatedAt |
timestamptz |
dates |
Le user lui-même (nom, email…) vit dans
better_auth_user(externe). Ici on ne stocke que le lien. Un compte correspond à un seul owner (betterAuthUserIdUNIQUE) ; un owner peut porter plusieurs projets (1-N versproject_financing_request).
2. project_financing_request ✅ — l'entité racine¶
Rôle : l'entité pivot du parcours. Elle porte l'état d'avancement de la demande (machine à états) et deux checklists de documents. Toutes les autres tables (présentation, emprunteur, documents, questions) s'y rattachent par projectId — attention, dans ces tables enfants ce champ contient l'id du dossier, pas le projectId décrit ci-dessous.
⚠️ Deux identifiants sur cette table, à ne pas confondre :
champ ce qu'il désigne forme idle dossier de financement uuid v7 projectIdle projet financé (ligne properties), réservé dès la créationuuid v4 Depuis BRI-1549, les URLs et les appels à l'outil d'Analyse sont ancrés sur
projectId. Unidde dossier envoyé sur une route projet est rejeté en400(la version de l'uuid ne correspond pas).
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid v7 | identifiant du dossier de financement |
projectId |
uuid v4 | identifiant du projet financé, réservé dès la création. Devient l'id de la ligne properties si le dossier aboutit — cette ligne peut ne jamais exister. Index unique : un projet ne porte qu'un dossier |
createdByUserId |
uuid | FK vers l'owner (le porteur) |
status |
enum | l'état du dossier (voir ci-dessous) |
isBorrowerStepDeferred |
boolean | REQUIS sur tous les statuts — l'étape Emprunteur a été passée pour plus tard |
reanalysisComment |
string? | commentaire du porteur au moment d'une re-soumission |
notary |
objet? | { studyName, address, email, phone } — le notaire du dossier (les 4 champs requis si l'objet est présent) |
investorBenefits |
string? | argumentaire investisseurs |
documentsStep |
objet | présent uniquement en draft — l'étape Documents : { status, expectedDocuments }. A une valeur par défaut : absent à l'écriture, présent après lecture |
missingDocumentsFromAnalysis |
objet | présent uniquement en blocked — les documents réclamés par l'analyse. Idem, defaulté à {} |
comment |
string? | blocked uniquement — le message de l'analyse expliquant le blocage ; canceled uniquement — le motif d'abandon, facultatif |
rejectionComment |
string | rejected uniquement — REQUIS et non vide |
canceledAt |
date ISO | présent uniquement en canceled — la date d'abandon |
createdAt, updatedAt |
date | dates |
⚠️ Le minimum pour qu'un insert passe le parse, quel que soit le statut :
{ id, projectId, createdByUserId, status, isBorrowerStepDeferred, createdAt, updatedAt }— plusrejectionCommentsistatus = 'rejected'. Tout le reste est optionnel ou defaulté.isBorrowerStepDeferredest le piège : requis, sans valeur par défaut, et facile à oublier.
Colonnes hors json — plates, écrites à part : chaque transition de statut reconstruit le json champ par champ, un champ de plus s'y perdrait.
companyId(uuid, nullable, FKproject_owner_company.id_view) — la société emprunteuse. Nulle tant que l'étape Emprunteur n'a pas été ouverte. Une sociétécompletedoufinalizedferme l'étape Emprunteur de chaque dossier qui la pointe.
Le champ status — l'état du dossier dans le parcours (machine à états) :
stateDiagram-v2
direction LR
[*] --> draft
draft --> in_analysis : POST /submit (presentation + documents completes) + ping Analyse
in_analysis --> blocked : Analyse demande des complements
blocked --> in_analysis : porteur repond / re-analyse
in_analysis --> offer : Analyse accepte (offre poussee)
in_analysis --> rejected : Analyse refuse
blocked --> rejected : Analyse refuse
in_analysis --> canceled : Analyse abandonne le dossier
blocked --> canceled : Analyse abandonne le dossier
offer --> finalization : porteur signe l'offre (docs signes + identite verifiee)
finalization --> signature : POST finalization/complete (banque + pieces KYB)
signature --> completed : signatures recueillies
offer --> rejected : offre refusee / expiree
offer --> canceled : Analyse abandonne le dossier
finalization --> canceled : Analyse abandonne le dossier
completed --> [*]
rejected --> [*]
canceled --> [*]
note right of draft
PHASE 1 - pilotee par les sous-entites
soumission = presentation + documents completes
emprunteur (project owner company) facultatif : remplissable jusqu'a l'acceptation de l'offre
chaque etape = formulaire draft to completed
end note
note right of offer
PHASE 2 - pilotee par le macro status
l'etape (offre / finalisation) = le status du projet
le DETAIL (signature, KYC) vient de l'externe
end note
Ouvrir la machine à états en plein écran
draft: le porteur remplit son dossier (phase 1).in-analysis: dossier soumis, en cours d'instruction.blocked: l'analyse réclame des compléments (questions ou documents) ; retourne enin-analysisquand le porteur répond.offer: l'analyse a accepté, l'offre est poussée au porteur (phase 2).finalization: le porteur a signé l'offre et finalise son dossier avant l'ouverture de la collecte (informations bancaires, KYB, notaire).completed: dossier finalisé, collecte ouverte.rejected: refusé.canceled: abandonné — l'analyse ferme un dossier que personne ne poursuit (POST …/cancel). Terminal et irréversible : toutes les étapes se verrouillent, le porteur garde la lecture.
⚠️ Pour la migration : les dossiers Bubble historiques ne portent que les états de la phase 1 (
draft,in-analysis,blocked,canceled, et un équivalent « accepté »/« refusé »). Mapper « accepté » →offer(l'offre devient disponible) etcanceled→canceled(l'essentiel de l'export). Les étatsfinalization/completedsont la cible de la phase 2 (à venir), pas attendus dans l'export Bubble.
documentsStep : objet { status, expectedDocuments } (l'étape Documents). status ∈ unavailable/draft/completed — possédé par l'étape (s'ouvre quand la présentation est complétée, ne re-verrouille jamais).
documentsStep.expectedDocuments : objet { <clé document>: boolean }. 9 clés techniques fixes — ce sont les identifiants exacts à écrire :
| clé technique | document |
|---|---|
presentationDocument |
présentation du projet |
unilateralPurchasePromise |
promesse unilatérale d'achat |
equityProof |
preuve de fonds propres |
subdivisionPermit |
permis d'aménager |
buildingPermit |
permis de construire |
operationForecast |
prévisionnel d'opération |
companyKbis |
KBIS de la société |
worksQuote |
devis de travaux |
expertValuationReport |
avis de valeur |
Chaque valeur est un booléen optionnel : une clé absente est tolérée par le parse, elle n'a simplement pas encore été cochée.
⚠️ Sémantique du booléen inversée : true = document encore attendu (non fourni) ; false = fourni/coché par le porteur. Tous à true à la création.
missingDocumentsFromAnalysis : objet { <nom>: boolean }. 🌐 Rempli par le système d'analyse (les noms sont libres, affichés tels quels). Même convention de booléen inversé.
À venir (ticket dédié) : le notaire (1 seul par projet, objet unique — pas une collection) vivra ici.
3. project_financing_request_presentation ✅ — 1 par projet¶
Rôle : la fiche projet (le « pitch ») remplie par le porteur — ce que verront les investisseurs : nom, type d'opération, montant, localisation, images, découpage en lots. C'est l'étape 1 du funnel.
status ∈ draft | completed (en completed, tous les champs deviennent requis sauf coordinates).
⚠️ Deux pièges pour l'import :
lotsest un tableau requis même endraft(unjsonsans la clé échoue au parse — mettre[]), et lestatusn'a pas de colonnestatus_viewgénérée, contrairement au dossier. La ligne naît avec le projet, seedée des champs du formulaire de création (name,category,fundingAmountRequested,fundsNeededByDate). Les projets antérieurs à ce seed la reçoivent vide, au premier GET. Cestatusest indépendant de celui du projet : une présentationcompletedsur un projet encoredraftest l'état normal avant soumission à l'analyse.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de la présentation |
createdByUserId |
uuid | FK owner |
projectId |
uuid | FK vers l'id du dossier (malgré son nom) — UNIQUE : 1 présentation par dossier |
status |
enum | draft (auto-save) ou completed (figé) |
name |
string | nom du projet |
category |
enum | type d'opération : marchandDeBiens (achat-revente), locatif (investissement locatif), exploitation, promotion (promotion immobilière) |
fundingAmountRequested |
number | montant de financement demandé, en centimes |
fundsNeededByDate |
date | date à laquelle le porteur a besoin des fonds |
description |
string | présentation rédigée du projet |
address |
{ street, city, postcode?, country } (miroir zod de Property.address) |
adresse postale exacte du bien |
coordinates |
objet | { latitude, longitude } — géolocalisation pour la carte (🌐 géocodé côté front). ⚠️ noms de clés exacts, pas lat/lng |
localizationDescription |
string | description libre du quartier/emplacement (texte marketing), distincte de l'adresse |
totalSurface |
number | surface totale de l'opération |
mainImageKey |
string | clé S3 de l'image principale (🌐 le fichier est sur S3, on stocke la clé) |
secondaryImageKeys |
string[] | clés S3 des autres images |
lots |
objet[] requis | découpage de l'opération en lots : { id, name, surface?, imageKey? } |
createdAt, updatedAt |
date | dates |
4. project_owner_company ✅ — 1-N dossiers¶
Rôle : la société emprunteuse appartenant au porteur (ProjectOwnerCompany). Ses représentants légaux vivent dans leur propre table (§4.1). Une société porte plusieurs dossiers : le lien vit sur project_financing_request.companyId, pas ici. L'App PDP crée encore une société par dossier ; rattacher une société existante à un nouveau dossier n'est pas un flux. C'est l'étape « Emprunteur » du funnel. L'emprunteur est toujours une société (jamais un particulier — un siren est requis pour valider). ⚠️ Aucune unicité sur le siren : rien en base n'empêche deux fiches pour la même société — la déduplication est à la charge de l'import.
status ∈ draft | completed | finalized.
draft/completed: phase emprunteur (identité société ; représentants en §4.1)finalized: posé par le premierPOST …/finalization/completequi valide cette société — figebanking. Les re-uploads KYB se refusent quand le dossier a quittéfinalization: une société déjàfinalizedne gèle pas les pièces d'un autre dossier.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de la société |
status |
enum | draft, completed ou finalized |
company |
objet | l'entreprise emprunteuse (voir détail ↓) |
banking |
objet? | coordonnées bancaires collecte — absent en draft, partiel et optionnel en completed, iban + bic requis au passage finalized uniquement ; leiNumber, leiObtainedAt optionnels ; bankName, accountHolder, label optionnels, additifs, écrits uniquement par le BO (PATCH /administration/project-owner-company/:id/banking). Rempli progressivement via PATCH …/finalization ; requis complet au passage finalized |
createdAt, updatedAt |
date | dates |
company (objet) — 🌐 prérempli via Pappers (search /suggestions à la saisie — porte déjà forme_juridique + capital ; puis details /entreprise post-sélection pour greffe + représentants ; le front remplit le formulaire ; l'API stocke tel quel) :
companyName, siren, legalForm (enum : SAS, SASU, SARL, EURL, SCI, SA, SNC), shareCapital (Cents), headquartersAddress, postalCode, city, country, rcsCity.
Tous requis en completed (y compris rcsCity, utilisé dans les contrats).
🌐 legalForm ← forme_juridique (premier segment avant la virgule ; laissé vide si hors des 7 valeurs de l'enum), shareCapital ← capital, rcsCity ← greffe (la ville du RCS est la commune du greffe d'immatriculation ; saisie manuelle si Pappers ne le remplit pas).
banking (objet, optionnel en completed, requis en finalized) — saisi sur l'écran collecte (PATCH …/finalization) : iban, bic (obligatoires à la validation), leiNumber, leiObtainedAt (optionnels) ; bankName, accountHolder, label (optionnels, écrits par le BO seulement, recopiés par complete). KYC / vérification d'identité Lemonway : côté front sur l'écran collecte ; branchement API en cours.
Bridge (open banking) pour synchroniser le compte bancaire : hors scope actuel — on stocke IBAN/BIC saisis manuellement.
4.1 project_owner_company_representative ✅ — format relationnel¶
Rôle : les représentants légaux d'une société — les personnes qui signent pour elle. Rattachés à la société, pas au dossier : un représentant reste valable sur tous les dossiers de la société.
| colonne | type | rôle |
|---|---|---|
id |
uuid |
clé primaire, générée par l'App PDP (uuidv7). Projet Analyse la renvoie dans pdpRepresentativeId pour désigner un signataire |
companyId |
uuid |
FK project_owner_company.id_view |
identity |
jsonb |
l'identité de la personne (voir détail ↓) |
projectUserId |
uuid nullable |
FK project_financing_request_user.id — le membre désigné comme signataire. NULL quand le signataire est invité par email |
archivedAt |
timestamptz nullable |
représentant sorti : la ligne reste lisible sur les dossiers passés |
createdAt, updatedAt |
timestamptz |
dates ; la lecture trie sur createdAt, id |
identity — union sur type — project-owner-company-representative.schema.ts :
individual: une personne physique. Champs, tous optionnels :firstName,lastName,birthDate,birthPlace,postalAddress(objet{ street, city, postcode?, country }),residenceCountry,email(exposéprofessionalEmailpar les contrats).business: une personne morale.legalEntityportecompanyName,sirenetlegalForm;legalRepresentativeest la personne physique qui la représente et signe pour elle, avec les mêmes champs qu'unindividual.title: la fonction dans la société emprunteuse (« Présidente »). Pour unbusiness,legalRepresentative.titleporte la fonction de ce représentant légal dans la société représentante.
Les contrats EF et BO n'exposent que la personne physique : identity pour un individual, identity.legalRepresentative pour un business. Le save App PDP et le PUT admin écrivent sur cette personne, jamais sur type ni legalEntity.
Champs additifs écrits par le PUT admin de la fiche Société : title, birthCountry (alpha-2 minuscule), nationality (tableau alpha-2). Le save App PDP ne les réécrit pas.
Réservés à l'import Bubble, écrits par aucun flux aujourd'hui : phone, gender et la variante business.
🌐 post-sélection Pappers : firstName / lastName / birthDate / birthPlace préremplis depuis les représentants physiques (personne_morale !== true) ; postalAddress et email restent saisie manuelle. residenceCountry est déduit du pays de l'adresse postale à sa sélection, quand le nom Mapbox correspond à un code ISO.
Le save App PDP envoie la liste entière : un id inconnu est inséré, un id connu réécrit ses champs App PDP, un représentant absent est archivé, jamais supprimé.
À la complétion : au moins un représentant actif, chacun complet ; un business exige sa personne morale et son représentant légal complets. projectUserId reste optionnel — un signataire est soit associé (projectUserId rempli), soit invité à son email, lequel est requis dans les deux cas.
🔁 À la complétion, chaque signataire associé est promu representative sur le dossier ; chaque signataire invité déclenche une invitation portant ce rôle. Voir project-owner-company.md.
Celui qui remplit l'étape Emprunteur n'est pas forcément signataire : rien n'oblige un représentant à le désigner. Le lien vers un membre se lit sur
projectUserId.
4.2 project_financing_request_cautionnaire — format relationnel, aucun flux ne l'écrit encore¶
Rôle : les cautions personnelles d'un dossier. Une caution est soit un représentant de la société, soit un tiers sans accès au dossier.
| colonne | type | rôle |
|---|---|---|
id |
uuid |
clé primaire |
projectId |
uuid |
FK project_financing_request.id_view — l'id du dossier |
representativeId |
uuid nullable |
FK project_owner_company_representative.id — la caution est un représentant |
identity |
jsonb nullable |
la caution est un tiers : même schéma que l'identité d'un représentant (union individual / business) ; title n'y est pas écrit |
createdAt, updatedAt |
timestamptz |
dates |
Contrainte : CHECK (num_nonnulls(representativeId, identity) = 1) — exactement l'un des deux.
5. document + project_document_link ✅ — format plat (pas de json)¶
⚠️ Changement de modèle. L'ancienne table
project_financing_request_document(unjsonpar document) a été supprimée. Un fichier et son rattachement sont maintenant deux choses distinctes :documentporte le fichier,project_document_linkdit à quoi il est attaché. Ces deux tables sont relationnelles — colonnes SQL classiques, pas dejson, pas de_view.Le but : un fichier survit au dossier qui l'a vu naître, et peut être rattaché à un dossier ou à une société.
5a. document — le fichier¶
Rôle : la métadonnée d'un fichier. Le binaire est sur S3 ; ici on stocke la clé et de quoi l'identifier. 1 ligne par fichier, quel que soit son usage.
| colonne | type | requis | rôle |
|---|---|---|---|
id |
uuid | oui (PK) | identifiant du fichier (généré v7) |
s3Key |
text (≤1024) | oui | 🌐 clé de l'objet S3. UNIQUE non partiel — même soft-deleted, la clé reste prise |
filename |
text | oui | nom du fichier (ex-name) |
mimeType |
text | oui | set fermé de 9 valeurs (pdf, jpeg, png, doc/docx, xls/xlsx, ppt/pptx). Aucun CHECK SQL : garanti par zod seulement |
sizeBytes |
integer | oui | taille en octets (ex-size, int4 → plafond ~2,1 Go) |
betterAuthUserId |
text | oui | FK vers better_auth_user — qui a déposé le fichier |
createdByType |
text | oui | pdp_user | bricks_admin | notary (CHECK SQL) |
legacySource |
text | non | pour la migration : tracer la provenance d'un fichier importé. Aucun code applicatif ne l'écrit |
deletedAt |
timestamptz | non | soft-delete du fichier lui-même |
createdAt, updatedAt |
timestamptz | oui | DEFAULT now() |
⚠️ Le déposant change de nature. L'ancienne table portait
createdByUserId(→project_financing_request_user) dans unjsonnon contraint. Maintenant c'estbetterAuthUserId, avec une FK dureNOT NULLversbetter_auth_user. Un fichier dont on ne sait pas résoudre le compte Better Auth ne peut pas être inséré. C'est le point le plus contraignant de la migration documents.
5b. project_document_link — le rattachement¶
Rôle : dit à quoi un fichier est attaché, à quel titre, et où il en est de sa revue. N lignes possibles par document (un fichier peut être rattaché plusieurs fois, successivement).
| colonne | type | requis | rôle |
|---|---|---|---|
id |
uuid | oui (PK) | identifiant du rattachement (généré v7) |
documentId |
uuid | oui | FK document — ON DELETE CASCADE |
companyId |
uuid | non | FK project_owner_company — société que la pièce décrit, toutes sources confondues |
projectId |
uuid | oui | FK project_financing_request — dossier de dépôt, toutes sources confondues |
representativeId |
uuid | non | FK project_owner_company_representative (id, pas id_view) — entité que la pièce décrit |
kind |
text | non | la nature métier du document — 35 valeurs (voir ci-dessous). null en onboarding : le porteur dépose à l'aveugle |
source |
text | oui | d'où vient le dépôt : pdp-onboarding | pdp-analysis | pdp-finalization | notary | back-office |
documentRequestId |
uuid | non | FK project_financing_request_document_request — uniquement si source = 'pdp-analysis' |
archivedAt |
timestamptz | non | détacher = archiver le lien. Le fichier reste |
validatedAt |
timestamptz | non | validé par l'analyse |
refusedAt |
timestamptz | non | refusé par l'analyse |
comment |
text | non | commentaire du porteur attaché au rattachement |
commentedAt |
timestamptz | non | date du commentaire |
createdAt |
timestamptz | oui | DEFAULT now(). ⚠️ pas de updatedAt sur cette table |
Les 8 contraintes que tout insert doit respecter :
| contrainte | règle |
|---|---|
| société ou représentant | num_nonnulls("companyId","representativeId") <= 1 — une pièce décrit au plus l’un des deux |
| portée dossier | pdp-onboarding et pdp-analysis exigent un projectId |
| portée finalisation | pdp-finalization exige un companyId |
| portée demande | documentRequestId non null ⇒ projectId non null |
| source ⊂ enum | source IN (les 5 valeurs) |
| demande ⇒ analyse | documentRequestId non null ⇒ source = 'pdp-analysis' |
| revue exclusive | num_nonnulls("validatedAt","refusedAt") <= 1 — jamais validé et refusé |
| commentaire | ("comment" IS NULL) = ("commentedAt" IS NULL) — les deux, ou aucun |
| unicité du lien vivant | UNIQUE (projectId, documentId) et (companyId, documentId), chacun WHERE archivedAt IS NULL |
⚠️ Unicité du lien vivant : un même fichier ne peut pas être attaché deux fois en même temps au même dossier, quels que soient
sourceetkind. Comme l'index ne couvre que les lignes non archivées, on peut en revanche empiler autant de liens archivés qu'on veut — c'est ce qui rend le ré-attachement possible après un détachement. Pour la migration : deux lignes Bubble pointant le même fichier sur le même dossier doivent être dédupliquées, ou l'une des deux archivée.ℹ️
kindn'a aucun CHECK SQL : les 35 valeurs ne sont garanties que par zod. Un insert direct avec une valeur hors enum réussit, puis casse la lecture.
Les 35 valeurs de kind (source : projectDocumentKind.zod.ts) :
kbis, signedCompanyStatutes, beneficialOwnersDeclaration, groupStructure, inpiExtract, decisionMinutes, nonConvictionDeclaration, identityDocument, rib, financialStatements, businessPlan, equityProof, loanDeed, notaryFeesProof, realEstateFeesProof, gfaForecast, resaleAgreement, salePromise, unilateralPurchasePromise, landPurchaseAgreement, propertyDiagnostics, expertValuationReport, rentalValuation, lease, buildingPermit, subdivisionPermit, administrativePermitFinal, worksQuote, additionalQuote, supportContract, structuringServiceContract, gfaInsuranceContract, jointGuarantee, operationNote, other.
Combinaisons écrites par le portail et le BO (le type applicatif les contraint plus que le SQL) :
source |
kind |
documentRequestId |
quand |
|---|---|---|---|
pdp-onboarding |
vide au dépôt, posé par le classifieur ou l'ops | interdit | phase 1 (draft) — dépôt à l'aveugle, la nature du fichier vient plus tard (IA ou ops) |
pdp-analysis |
requis | requis | réponse à une demande de l'analyse (blocked). Le kind est celui de la demande |
pdp-finalization |
requis | interdit | pièces KYB de collecte — le portail garde une pièce vivante par catégorie |
back-office |
requis | interdit | dépôt par l'ops depuis le BO Projets : toujours une pièce de projet (projectId, rattachée au projet, à la société ou à un représentant) ; depuis la fiche Société, un lien { projectId, companyId } par projet choisi sur le même document, jamais de lien sans projet — voir Documents admin. Le document porte alors createdByType = 'bricks_admin' et l'id better_auth_user de l'admin |
Une pièce déposée depuis la fiche Société a sa clé S3 sous
companies/{companyId}/documents/(le préfixeprojects/{projectId}/documents/suppose un projet). Le BO modifiekindet le rattachement sur le même lien (comme le webhookai-platform) : la pièce ne quitte jamais son projet. Supprimer depuis le BO = la suppression du portail (lien archivé,documentsoft-deleted sans autre lien vivant, S3 gardé), permis tant que tous les projets qui lisent la pièce sont endraft. Remplacer = supprimer puis recréer un lien de mêmesource, même projet, même rattachement, mêmekind, mêmedocumentRequestIdsur le nouveau fichier : la seule écriture BO d'une autresourcequeback-office. L'index uniqueproject_document_link_company_idx(companyId, documentId)est supprimé (V202610051100) : un fichier sur N projets de la société a N liens vivants avec le mêmecompanyId,(projectId, documentId)porte l'unicité.La source
notaryexiste en base et dans l'enum, mais aucun code ne l'écrit aujourd'hui : elle est posée pour son flux à venir. À clarifier avec l'équipe si la migration Bubble doit en produire.Entité décrite : une pièce appartient toujours à son dossier (
projectId, obligatoire) et peut en plus décrire la société (companyId) ou un représentant (representativeId) ; elle n'est visible que dans ce dossier, finalisation comprise. Un autre dossier de la même société ne la voit ni ne la supprime : rattacher une société existante recrée des liens dans le nouveau dossier (BRI-2438). Le webhookai-platformposekindet cette entité sur toute pièce du dossier (onboarding comme analyse), seulement quand ils sont vides : il ajoute, ne retire jamais. À la suppression, le lien est archivé ; le document et son fichier S3 ne partent que s'il ne reste aucun lien vivant.
6. project_financing_request_question ✅ — 🌐 poussé par l'analyse¶
Rôle : les questions textuelles posées par le système d'analyse sur un dossier, avec la réponse du porteur. 1 ligne par question.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de la question |
projectId |
uuid | FK vers l'id du dossier (malgré son nom) |
text |
string | 🌐 le texte de la question (posée par l'analyse). Chaque question passe le dossier en blocked |
askedAt |
date | date à laquelle la question a été posée |
answer |
objet? | { text, answeredAt } — la réponse du porteur (null au départ) |
createdAt, updatedAt |
date | dates |
7. project_financing_request_document_request ✅ — 🌐 poussé par l'analyse¶
Rôle : les documents nominativement réclamés par l'analyse (une entité par demande — à distinguer de missingDocumentsFromAnalysis qui est une simple checklist sur le projet). 1 ligne par document demandé.
⚠️
kindest requis mais sans garde en base : aucune colonne générée, aucun CHECK. Un insert sanskindréussit, puis toute lecture du dossier renvoie 500 (le parse zod rejette la ligne). À contrôler côté script d'import.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de la demande |
projectId |
uuid | FK vers l'id du dossier (malgré son nom) |
label |
string | 🌐 intitulé du document demandé (par l'analyse). Chaque demande passe le dossier en blocked |
description |
string? | 🌐 précision libre sur ce qui est attendu |
kind |
enum | 🌐 REQUIS — la nature du document demandé, parmi les 35 valeurs. Le document déposé en réponse hérite de ce kind |
requestedAt |
date | date de la demande |
createdAt, updatedAt |
date | dates |
8. project_financing_request_offer¶
Rôle : l'offre de financement proposée au porteur après l'analyse (montant, taux, durée + le détail des frais et séquestres). 🌐 Les données sont poussées par l'analyse/le comité — on les reçoit, stocke et expose. Seule exception : le montant des frais de collecte, calculé par le back à la réception (voir Frais ↓). Le KYC ne vit pas ici (il concerne la société emprunteuse → project_owner_company).
Champs du json (à plat, montants en centimes) :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de l'offre (une ligne par version) |
financingRequestId |
uuid | FK vers l'id du dossier (pas le projectId) — colonne générée financingRequestId_view |
version |
int ≥ 1 | numéro de version (voir Versioning ↓) |
acceptedAt |
date? | date d'acceptation de l'offre par le porteur |
amount, durationInMonths, interestRate |
objets | les conditions du prêt (voir détail ↓) |
contractBudget, fees |
objets | budget contractuel et frais (voir détail ↓) |
operationCost, ownerContribution, ownerTransferAmount |
cents | les montants de la collecte (voir détail ↓) |
createdAt, updatedAt |
date | dates |
Vocabulaire aligné sur properties (contractSpecifications / companyFees). Aucun flux n'alimente encore le projet financé depuis l'offre.
Conditions du prêt :
amount ({ target, minimum? } — target = la collecte visée, affichée « Montant » et « Total à collecter » ; minimum = le seuil de réussite), durationInMonths ({ nominal, prorogation? }), interestRate ({ nominal, prorogation? } — taux prorogé quand la durée est prolongée).
Budget contractuel — contractBudget ({ constructionBudget?, interestSequestre?: { amount, months } }). Absent = pas de budget travaux bloqué / pas de séquestre d'intérêts.
Frais — fees : funding ({ percentage, amount }), guarantee? (frais de garantie), fiducie? (frais de fiducie), other? (autres frais Bricks : frais fixes HT, frais in fine). L'analyse ne pousse que funding.percentage ; le back calcule funding.amount = percentage × amount.target à la réception et le stocke — un seul endroit calcule ce montant.
Collecte — operationCost (coût de l'opération), ownerContribution (apport du porteur), ownerTransferAmount (montant net reversé au porteur, calculé par l'analyse).
Versioning : l'analyse peut pousser plusieurs offres successives (correction, renégociation). Chaque envoi crée une nouvelle ligne (append-only, historique immuable) — on n'écrase jamais. La version est poussée par l'analyse dans le payload (le back ne la dérive pas). Le porteur voit l'offre acceptée si elle existe, sinon la plus haute version (ORDER BY version DESC). Une fois l'offre acceptée, toute nouvelle version est refusée (409 offer-already-accepted) : le porteur reste engagé sur ce qu'il a accepté. Unique (financingRequestId, version) = garde-fou d'intégrité (une version ne peut jamais être écrite deux fois). Relation project_financing_request → offer = 1-N.
9. project_financing_request_user_role ✅ — la source d'autorisation¶
Rôle : le lien entre un utilisateur, un projet et un rôle. Relation N:N : un user peut être sur plusieurs projets, un projet peut avoir plusieurs utilisateurs.
Colonnes relationnelles, pas de json : le format ne porte pas d'union discriminée et ne bouge pas — le relationnel est plus adapté (review Denis).
C'est la seule source d'autorisation — le guard ne lit plus project_financing_request."createdByUserId_view", qui ne désigne désormais que le créateur du projet.
Matrice des rôles — 3 rôles, 2 axes de droits (wording aligné sur les maquettes) :
flowchart TB
subgraph LEGENDE[" "]
direction LR
T["3 roles - 2 axes = 6 cases figees par le produit"]
end
subgraph ROLES["Roles (stockes en base, sur le lien personne-projet)"]
direction TB
R1["representative<br/>(Representant legal)"]
R2["collaborator<br/>(Collaborateur)"]
R3["apporteur_affaires<br/>(Apporteur d'affaires)"]
end
subgraph AXE1["AXE 1 - Perimetre dans le parcours"]
direction TB
P1["Prefinancement + Postfinancement"]
P2["Prefinancement + Postfinancement"]
P3["Prefinancement jusqu'a l'analyse<br/>(offre + finalisation exclues)"]
end
subgraph AXE2["AXE 2 - Signature"]
direction TB
S1["Peut signer ✅"]
S2["Ne signe pas ❌"]
S3["Ne signe pas ❌"]
end
R1 --> P1 --> S1
R2 --> P2 --> S2
R3 --> P3 --> S3
style R1 fill:#d4edda,stroke:#28a745
style R2 fill:#fff3cd,stroke:#ffc107
style R3 fill:#e2e3e5,stroke:#6c757d
style S1 fill:#d4edda,stroke:#28a745
style S2 fill:#f8d7da,stroke:#dc3545
style S3 fill:#f8d7da,stroke:#dc3545
style LEGENDE fill:none,stroke:none
Ouvrir la matrice en plein écran
Colonnes (table relationnelle, pas de json) :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant du membre |
projectId |
uuid | FK vers l'id du dossier |
projectUserId |
uuid | FK project_financing_request_user — la personne qui porte le rôle |
role |
enum | representative, collaborator, apporteur_affaires — matrice complète dans le README du module |
deletedAt |
date, NULL |
soft delete — la ligne reste pour l'audit, tous les reads l'excluent |
createdAt, updatedAt |
date | dates |
Unicité : index partiel (projectId, projectUserId) WHERE deletedAt IS NULL — un user a un seul rôle actif par projet, mais une personne retirée peut être ré-invitée. Comme ce n'est pas une contrainte nommée, ON CONFLICT ON CONSTRAINT est inutilisable ; passer par ON CONFLICT (cols) WHERE predicate.
Pas d'index sur projectUserId : il ne servirait que la purge de project_financing_request_user, et cette table n'est pas la seule enfant. Les index de FK sont posés par BRI-1808, dans la migration qui exécute la purge.
Le nom
apporteur_affairesreste en français : concept contractuel FR (naming-conventions §7). Ne pas le traduire. Les droits (accès pré/post-financement, signature) sont déclarés dans le code, pas stockés en base : chaque controller porteur d'un:projectIdliste ses rôles via@AllowedProjectRoles(...), et le guard refuse ce qui n'y figure pas — décorateur absent = 403 pour tout le monde. Migration appliquée : chaque owner de projet a reçu des droitsrepresentativeactifs sur son projet. À la création d'un projet, le créateur reçoitcollaborator, ouapporteur_affairess'il le déclare — c'est le seul moment où ce rôle s'obtient.representatives'obtient à l'étape Emprunteur, par désignation comme signataire (§4.1).
10. project_financing_request_user_invitation ✅ — faire entrer quelqu'un sur un projet¶
Rôle : une invitation en attente d'acceptation. À l'acceptation, elle crée la ligne de droits correspondante et passe en accepted — dans la même transaction.
Colonnes (table relationnelle, pas de json) :
⚠️ Sur une table plate, un champ « optionnel » est une colonne
NULL, pas une clé absente :firstName,lastName,acceptedAtetuserRoleIddoivent être fournis àNULL, jamais omis.
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de l'invitation |
projectId |
uuid | FK vers l'id du dossier |
role |
enum | rôle attribué à l'acceptation (mêmes valeurs que la table de droits) |
email |
text | destinataire, normalisé en minuscules |
firstName, lastName |
text? | saisis par l'inviteur, affichage seul — better-auth réécrit l'identité à l'inscription |
invitedByUserId |
uuid | qui invite |
tokenHash |
text | jamais le token en clair. UNIQUE : un hash ne peut pas être partagé par deux invitations |
status |
enum | pending, accepted — protégé par un CHECK en base |
acceptedAt |
date? | horodatage de l'acceptation |
userRoleId |
uuid? | la ligne de droits créée à l'acceptation |
createdAt, updatedAt |
date | dates |
Unicité : index partiel (projectId, email) WHERE status = 'pending' — une seule invitation en attente par email et par projet, mais on peut ré-inviter après acceptation.
⚠️ L'email est mis en minuscules à l'écriture (zod
.toLowerCase()), pas par un index d'expression : un indexlower(email)ne serait pas utilisé par un.where('email', '=', email)Kysely, et le doublon passerait inaperçu auSELECTavant d'échouer à l'INSERT. ⚠️ LeCHECKsurstatusprotège l'index partiel, qui dépend de la chaîne exacte'pending': une valeur mal casée sortirait du prédicat et désactiverait silencieusement la garantie d'unicité.
Récapitulatif pour le mapping¶
| Table | État | Clé d'unicité métier |
|---|---|---|
project_financing_request_user |
✅ | 1 par compte Better Auth |
project_financing_request |
✅ | la racine — projectId unique |
project_financing_request_presentation |
✅ | 1 par projet |
project_owner_company |
✅ | N dossiers par société (le lien est sur project_financing_request.companyId) |
project_owner_company_representative |
✅ | N par société (relationnelle) |
project_financing_request_cautionnaire |
— | N par dossier (représentant ou tiers, relationnelle) |
document |
✅ | N (s3Key unique, non partiel) — table plate |
project_document_link |
✅ | 1 lien vivant par (dossier, document) — table plate |
project_financing_request_question |
✅ | N par dossier (poussé par analyse) |
project_financing_request_document_request |
✅ | N par dossier (poussé par analyse) — kind requis |
project_financing_request_offer |
✅ | N par dossier (versions, unique financingRequestId+version) |
project_financing_request_user_role |
✅ | N par projet (1 rôle actif par user/projet) |
project_financing_request_user_invitation |
✅ | N par projet (1 seule pending par email) |
Rappels migration :
- Chaque dossier doit porter un projectId (uuid v4) dans son json : la colonne est NOT NULL et unique. Pour un dossier déjà financé, reprendre l'id de sa ligne properties ; sinon générer un uuid v4 qui ne sera jamais réutilisé.
- Peupler le json, jamais les _view — sauf project_financing_request_user_role, project_financing_request_user_invitation, project_owner_company_representative et project_financing_request_cautionnaire, relationnelles (colonnes classiques, pas de json).
- Ids et dates = strings (dates ISO) dans le json.
- Ordre d'insertion imposé par les FK : better_auth_user → project_financing_request_user → project_owner_company → project_owner_company_representative → project_financing_request (avec companyId) → tables enfants du dossier, dont project_financing_request_cautionnaire.
- Données externes (identité, société Pappers, fichiers S3, offre, KYC, banque) = non migrées comme tables EF ; on stocke des références.
- isBorrowerStepDeferred est requis sur les 7 statuts du dossier, sans valeur par défaut. Un json sans ce booléen échoue au parse (500 à la lecture).
- rejectionComment est requis sur un dossier rejected, non vide.
- kind est requis sur une demande de document — et rien ne le garde en base. Un import qui l'oublie casse la lecture du dossier.
- Documents : deux insertions par fichier (document puis project_document_link), dans cet ordre. Le betterAuthUserId du fichier est une FK dure : un fichier sans compte Better Auth résoluble ne peut pas entrer. Utiliser document.legacySource pour tracer la provenance.
- Un seul lien vivant par (dossier, fichier) : dédupliquer en amont, ou archiver les doublons (archivedAt).
- Les tables document, project_document_link, project_financing_request_user_role et ..._user_invitation sont plates : colonnes classiques, pas de json, et un champ nullable doit être présent à NULL (pas absent).