Aller au contenu

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"
    }

Ouvrir le MCD en plein écran


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>_view sont générées automatiquement depuis le json (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 le json (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 (betterAuthUserId UNIQUE) ; un owner peut porter plusieurs projets (1-N vers project_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 en in-analysis quand 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 états finalization/completed sont la cible de la phase 2 (à venir), pas attendus dans l'export Bubble.

documentsStep : objet { status, expectedDocuments } (l'étape Documents). statusunavailable/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. statusdraft | 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). statusdraft | 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 finalizationCategorysignedCompanyStatutes | 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 contractuelcontractBudget ({ constructionBudget, interestSequestre: { amount, months } }).

Fraisfees : funding ({ percentage, amount } — pourcentage source + montant calculé), guarantee? (frais de garantie), fiducie? (frais de fiducie).

CollecteoperationCost (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_affaires reste 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 droits representative actifs 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 index lower(email) ne serait pas utilisé par un .where('email', '=', email) Kysely, et le doublon passerait inaperçu au SELECT avant d'échouer à l'INSERT. ⚠️ Le CHECK sur status protè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 _viewsauf 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.