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

Ouvrir le MCD en plein écran


Comment lire ce document

Le module utilise deux formats de table :

  • Format « relationnel » : colonnes SQL classiques, pas de json. Tables project_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>_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, 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 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_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 (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 — 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
id le dossier de financement uuid v7
projectId le projet financé (ligne properties), réservé dès la création uuid v4

Depuis BRI-1549, les URLs et les appels à l'outil d'Analyse sont ancrés sur projectId. Un id de dossier envoyé sur une route projet est rejeté en 400 (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 } — plus rejectionComment si status = 'rejected'. Tout le reste est optionnel ou defaulté. isBorrowerStepDeferred est 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, FK project_owner_company.id_view) — la société emprunteuse. Nulle tant que l'étape Emprunteur n'a pas été ouverte. Une société completed ou finalized ferme 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 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é.
  • 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) et canceled → canceled (l'essentiel de l'export). 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). 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 : lots est un tableau requis même en draft (un json sans la clé échoue au parse — mettre []), et le status n'a pas de colonne status_view gé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. Ce status est indépendant de celui du projet : une présentation completed sur un projet encore draft est 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 premier POST …/finalization/complete qui valide cette société — fige banking. Les re-uploads KYB se refusent quand le dossier a quitté finalization : une société déjà finalized ne 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é professionalEmail par les contrats).
  • business : une personne morale. legalEntity porte companyName, siren et legalForm ; legalRepresentative est la personne physique qui la représente et signe pour elle, avec les mêmes champs qu'un individual.
  • title : la fonction dans la société emprunteuse (« Présidente »). Pour un business, legalRepresentative.title porte 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.


⚠️ Changement de modèle. L'ancienne table project_financing_request_document (un json par document) a été supprimée. Un fichier et son rattachement sont maintenant deux choses distinctes : document porte le fichier, project_document_link dit à quoi il est attaché. Ces deux tables sont relationnelles — colonnes SQL classiques, pas de json, 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 un json non contraint. Maintenant c'est betterAuthUserId, avec une FK dure NOT NULL vers better_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.

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 source et kind. 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.

ℹ️ kind n'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éfixe projects/{projectId}/documents/ suppose un projet). Le BO modifie kind et le rattachement sur le même lien (comme le webhook ai-platform) : la pièce ne quitte jamais son projet. Supprimer depuis le BO = la suppression du portail (lien archivé, document soft-deleted sans autre lien vivant, S3 gardé), permis tant que tous les projets qui lisent la pièce sont en draft. Remplacer = supprimer puis recréer un lien de même source, même projet, même rattachement, même kind, même documentRequestId sur le nouveau fichier : la seule écriture BO d'une autre source que back-office. L'index unique project_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ême companyId, (projectId, documentId) porte l'unicité.

La source notary existe 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 webhook ai-platform pose kind et 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é.

⚠️ kind est requis mais sans garde en base : aucune colonne générée, aucun CHECK. Un insert sans kind ré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_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éclarés dans le code, pas stockés en base : chaque controller porteur d'un :projectId liste 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 droits representative actifs sur son projet. À la création d'un projet, le créateur reçoit collaborator, ou apporteur_affaires s'il le déclare — c'est le seul moment où ce rôle s'obtient. representative s'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, acceptedAt et userRoleId doivent ê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 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_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).