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_owner : "1-1"
project_financing_request_owner ||--o{ project_financing_request : "1-N"
project_financing_request_owner ||--o{ project_financing_request_user_rights : "N projets"
project_financing_request ||--o{ project_financing_request_user_rights : "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_financing_request ||--|| project_owner_company : "1-1 (borrower)"
project_financing_request ||--o{ project_financing_request_document : "1-N"
project_financing_request ||--o{ project_financing_request_question : "1-N"
project_financing_request ||--o{ project_financing_request_document_request : "1-N"
project_financing_request_question |o--o{ project_financing_request_document : "0-N (FK nullable)"
project_financing_request ||--o{ project_financing_request_offer : "1-N (versions)"
better_auth_user {
text id PK "EXTERNE - Better Auth"
}
project_financing_request_owner {
uuid id PK "table plate"
text betterAuthUserId FK "UK + FK better_auth_user"
}
project_financing_request {
jsonb json "id, projectOwnerId, status, reanalysisComment, createdAt, updatedAt (+ champs par variant)"
uuid id_view PK
uuid projectOwnerId_view FK
text status_view "draft,in-analysis,blocked,offer,finalization,completed,rejected"
}
project_financing_request_presentation {
jsonb json "id, projectOwnerId, projectId, status, name, category, fundingAmountRequested, fundsNeededByDate, description, address, coordinates{}, lots[], createdAt, updatedAt"
uuid id_view PK
uuid projectId_view UK
}
project_owner_company {
jsonb json "id, projectId, status, company{}, representatives[], createdAt, updatedAt"
uuid id_view PK
uuid projectId_view UK
}
project_financing_request_document {
jsonb json "id, projectOwnerId, projectId, s3Key, name, mimeType, size, comment{}, financingRequestQuestionId, documentRequestId, refusedAt, createdAt, updatedAt"
uuid id_view PK
uuid projectId_view FK
text s3Key_view UK "cle S3 (fichier sur S3)"
uuid financingRequestQuestionId_view FK "nullable"
}
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, 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, amountToFund, ownerTransferAmount, createdAt, updatedAt"
uuid id_view PK
uuid financingRequestId_view FK
int version_view "unique (financingRequestId, version)"
}
project_financing_request_user_rights {
uuid id PK
uuid projectId FK
uuid projectOwnerId 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 userRightsId FK "renseigne a l acceptation"
}
Comment lire ce document¶
Le module utilise deux formats de table :
- Format « plat » (uniquement
project_financing_request_owner) : colonnes SQL classiques. - 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, projectOwnerId_view |
uuid |
json.… → FK |
status_view, s3Key_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_owner ✅ — 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 |
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.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant du projet |
projectOwnerId |
uuid | FK vers l'owner (le porteur) |
status |
enum | l'état du dossier (voir ci-dessous) |
documentsStep |
objet | présent uniquement en draft — l'étape Documents : { status, expectedDocuments } (statut de l'étape + checklist d'onboarding) |
missingDocumentsFromAnalysis |
objet | présent uniquement en blocked — les documents réclamés par l'analyse |
createdAt, updatedAt |
date | dates |
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
offer --> finalization : porteur signe l'offre (docs signes + identite verifiee)
finalization --> completed : dossier finalise (notaires, banque)
offer --> rejected : offre refusee / expiree
completed --> [*]
rejected --> [*]
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é.
⚠️ Pour la migration : les dossiers Bubble historiques ne portent que les états de la phase 1 (
draft,in-analysis,blocked, et un équivalent « accepté »/« refusé »). Mapper « accepté » →offer(l'offre devient disponible). 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 métier fixes (présentation, promesse d'achat, preuve de fonds propres, permis d'aménager, permis de construire, prévisionnel, KBIS, devis travaux, avis de valeur).
⚠️ 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).
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de la présentation |
projectOwnerId |
uuid | FK owner |
projectId |
uuid | FK projet (UNIQUE : 1 présentation par projet) |
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 |
string | adresse postale exacte du bien |
coordinates |
objet | { lat, lng } — géolocalisation pour la carte (🌐 géocodé côté front) |
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[] | découpage de l'opération en lots : { id, name, surface?, imageKey? } |
createdAt, updatedAt |
date | dates |
4. project_owner_company ✅ — 1 par projet¶
Rôle : la société emprunteuse appartenant au porteur (ProjectOwnerCompany — réutilisable sur plusieurs projets) + ses représentants légaux. C'est l'étape « Emprunteur » du funnel. L'emprunteur est toujours une société (jamais un particulier — un siren est requis pour valider).
status ∈ draft | completed.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de la société |
projectId |
uuid | FK projet (UNIQUE aujourd'hui : 1 société par projet ; passage 1-N prévu) |
status |
enum | draft ou completed |
company |
objet | l'entreprise emprunteuse (voir détail ↓) |
representatives |
objet[] | les représentants légaux (voir détail ↓) |
createdAt, updatedAt |
date | dates |
company (objet) — 🌐 prérempli via Pappers (search /suggestions à la saisie, puis details /entreprise post-sélection ; le front remplit le formulaire ; l'API stocke tel quel) :
companyName, siren, headquartersAddress, postalCode, city, country (tous string ; tous requis en completed).
representatives (tableau d'objets) — les personnes physiques qui représentent/signent pour la société :
id, isCurrentUser (booléen : ce représentant est-il le porteur connecté lui-même ?), projectOwnerId? (lien optionnel vers un owner connu — souvent absent car les représentants sont des tiers), firstName, lastName, birthDate, birthPlace, residenceCountry, professionalEmail.
🌐 post-sélection Pappers : firstName / lastName / birthDate / birthPlace préremplis depuis les representants physiques (personne_morale !== true) ; residenceCountry et professionalEmail restent saisie manuelle.
En completed : tableau non-vide, chaque représentant complet.
À venir (ticket dédié) : infos bancaires + KYB (IBAN, BIC, LEI + date d'obtention) et KYC/vérification d'identité — elles concernent la société emprunteuse, donc elles vivront ici. La synchronisation du compte bancaire passe par Bridge (open banking) : on ne stocke pas les mouvements, seulement le statut/consentement.
5. project_financing_request_document ✅¶
Rôle : un fichier déposé par le porteur (pièce du dossier). 1 ligne par fichier. Le binaire est sur S3 ; ici on stocke la métadonnée + la clé.
Le json est une union discriminée sur documentType (onboarding | analysis | finalization) — voir project-financing-request-document.schema.ts. La colonne générée documentType_view indexe le type ; documentRequestId_view est renseignée uniquement pour analysis.
Champs communs (tous les types) :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant du document |
documentType |
enum | onboarding | analysis | finalization |
projectOwnerId |
uuid | FK owner |
projectId |
uuid | FK projet |
s3Key |
string | 🌐 clé de l'objet S3 (UNIQUE ; préfixe projects/{projectId}/documents/). Vérifiée existante avant insert |
name |
string | nom du fichier |
mimeType |
enum | type du fichier (set fermé : pdf, images, docx, xlsx…) |
size |
number | taille en octets |
comment |
objet? | { value, createdAt } — commentaire libre du porteur attaché au fichier (optionnel) |
refusedAt |
date? | date de refus par Projet Analyse (documents analysis uniquement) |
deletedAt |
date? | soft-delete (audit) — exclus des reads porteur |
createdAt, updatedAt |
date | dates |
Variantes :
documentType |
champs additionnels | usage |
|---|---|---|
onboarding |
— | phase 1 (draft) — upload checklist documents |
analysis |
documentRequestId (uuid, FK project_financing_request_document_request) |
réponse à une demande nominative Projet Analyse (blocked) |
finalization |
finalizationCategory ∈ signedCompanyStatutes | rib | kbis | beneficialOwnersDeclaration |
pièces KYB / signature (phase 2 — endpoints porteur WIP) |
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 projet |
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é.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de la demande |
projectId |
uuid | FK projet |
label |
string | 🌐 intitulé du document demandé (par l'analyse). Chaque demande passe le dossier en blocked |
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) et les contrats à signer. 🌐 Les données sont poussées par l'analyse/le comité — on les reçoit, stocke et expose (on ne calcule rien). Le KYC ne vit pas ici (il concerne la société emprunteuse → project_owner_company).
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de l'offre (une ligne par version) |
projectId |
uuid | FK projet |
version |
int ≥ 1 | numéro de version (voir Versioning ↓) |
offer |
objet | les conditions du prêt (voir détail ↓) |
funding |
objet | le détail financier de l'opération (voir détail ↓) |
documents |
objet[] | les contrats à signer : { id, name, size, status: pending\|signed } |
createdAt, updatedAt |
date | dates |
Structure alignée sur properties (contractSpecifications / companyFees), que l'offre alimente. Montants en centimes.
Conditions du prêt :
amount ({ target, minimum? }), durationInMonths ({ nominal, prorogation? }), interestRate ({ nominal, prorogation? } — taux prorogé quand la durée est prolongée).
Budget contractuel — contractBudget ({ constructionBudget, interestSequestre: { amount, months } }).
Frais — fees : funding ({ percentage, amount } — pourcentage source + montant calculé), guarantee? (frais de garantie), fiducie? (frais de fiducie).
Collecte — operationCost (coût de l'opération), ownerContribution (apport du porteur), amountToFund (total à collecter), ownerTransferAmount (montant net reversé au porteur).
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). La dernière version fait foi (ORDER BY version DESC). 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_rights ✅ — 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."projectOwnerId_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 uniquement<br/>(jusqu'a la signature exclue)"]
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
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant du membre |
projectId |
uuid | FK projet |
projectOwnerId |
uuid | FK owner (l'utilisateur) |
role |
enum | representative (accès complet, seul à pouvoir signer), collaborator (idem sauf signature), apporteur_affaires (préfinancement uniquement) |
invitedByOwnerId |
uuid? | qui a fait entrer cette personne |
deletedAt |
date? | soft delete — la ligne reste pour l'audit, tous les reads l'excluent |
createdAt, updatedAt |
date | dates |
Unicité : index partiel (projectId, projectOwnerId) 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 projectOwnerId : il ne servirait que la purge de project_financing_request_owner, 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érivés du rôle dans le code, pas stockés en base. Migration appliquée : chaque owner de projet a reçu des droitsrepresentativeactifs sur son projet.
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.
Champs du json :
| champ | type | rôle |
|---|---|---|
id |
uuid | identifiant de l'invitation |
projectId |
uuid | FK projet |
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 |
invitedByOwnerId |
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 |
userRightsId |
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_owner |
✅ | 1 par compte Better Auth |
project_financing_request |
✅ | la racine |
project_financing_request_presentation |
✅ | 1 par projet |
project_owner_company |
✅ | 1 par projet |
project_financing_request_document |
✅ | N par projet (s3Key unique) |
project_financing_request_question |
✅ | N par projet (poussé par analyse) |
project_financing_request_document_request |
✅ | N par projet (poussé par analyse) |
project_financing_request_offer |
✅ | N par projet (versions, unique projectId+version) |
project_financing_request_user_rights |
✅ | 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 :
- Peupler le json, jamais les _view — sauf project_financing_request_user_rights et project_financing_request_user_invitation, relationnelles (colonnes classiques, pas de json).
- Ids et dates = strings (dates ISO) dans le json.
- Données externes (identité, société Pappers, fichiers S3, offre, KYC, banque) = non migrées comme tables EF ; on stocke des références.