Project Financing Request Module¶
Backend for the Project Financing Request portal — the project-owner-facing interface where borrowers submit their financing requests.
History: Renamed from
espace-fiin May 2026 (8-part migration). HTTP routes, S3 provider, config keys, and TypeScript code all useproject-financing-request. Most DB tables use theproject_financing_request_*prefix; the borrower company table isproject_owner_company(renamed fromespace_fi_spv, BRI-1501). The shared API contract package remains@bricks-common/api-communication-project-financing.
Stack¶
Uses the Kysely + Zod stack (see .cursor/rules/api-conventions.mdc § "Stack Espace Financement"):
| Tool | Purpose |
|---|---|
| Kysely | Query builder (getKysely(), ky_safePgTransaction) |
| Zod | Validation + type derivation |
apiErr / throwApiError |
Structured business errors → HTTP mapping |
Database tables¶
| Table | Content |
|---|---|
project_financing_request |
Financing request (draft → in-analysis → …) linked to a project owner |
project_financing_request_user |
Owner identity, linked to Better Auth user ID |
project_financing_request_presentation |
JSONB document with pitch fields + S3 image keys |
project_owner_company |
Borrower company data (project owner company) |
project_financing_request_offer |
Versioned financing offer (append-only, pushed by Projet Analyse) |
Schemas live in src/__new/lib/kysely/schemas/ (project-financing-request*.schema.ts, project-owner-company.schema.ts).
HTTP endpoints¶
All routes are prefixed project-financing-request/ and guarded by ProjectFinancingRequestUserGuard (Better Auth session + active row on project_financing_request_user_role when :projectId is present).
The guard enforces project access only when projectId is present in the URL. Routes without projectId (e.g. Pappers search) only require a valid Better Auth session and a project_financing_request_user row.
Project presentation¶
| Method | Path | Action |
|---|---|---|
GET |
/project/:projectId/presentation |
Get presentation (returns presigned GET URLs for images); creates an empty one for projects predating the creation seed |
PATCH |
/project/:projectId/presentation/:presentationId |
Save presentation body (sets status back to draft) |
PUT |
/project/:projectId/presentation/:presentationId/complete |
Mark as completed |
Presentation images (S3 presigned upload)¶
| Method | Path | Action |
|---|---|---|
POST |
/project/:projectId/presentation/:presentationId/images/request-upload |
Get presigned PUT URLs for upload |
POST |
/project/:projectId/presentation/:presentationId/images/confirm-upload |
Confirm objects exist + persist keys in JSONB |
DELETE |
/project/:projectId/presentation/:presentationId/images |
Remove image(s) from presentation |
Project owner company (emprunteur)¶
| Method | Path | Action |
|---|---|---|
GET |
/project/:projectId/project-owner-company |
Get or create company record |
PATCH |
/project/:projectId/project-owner-company/:projectOwnerCompanyId |
Update company fields |
PUT |
/project/:projectId/project-owner-company/:projectOwnerCompanyId/complete |
Mark company as completed |
Submit to analysis¶
| Method | Path | Action |
|---|---|---|
POST |
/:projectId/submit |
Submit draft project to analysis (requires presentation + documents completed; company optional) |
MkDocs : phase-one-steps.md.
Financing requests¶
| Method | Path | Action |
|---|---|---|
GET |
/projects |
List financing requests (id, name, status, steps, createdAt, updatedAt) |
POST |
/projects |
Create a draft financing request, its creator's access row and its seeded presentation |
Creation writes three rows in one transaction: the request, the creator's project_financing_request_user_role and the presentation seeded with the body (name, category, fundingAmountRequested, fundsNeededByDate). The creator's role is collaborator unless the body declares apporteur_affaires — creation is the only moment that role can be obtained. representative is rejected (400): it is meant to be granted by designation at the borrower step, which is not implemented yet (BRI-1787). Until it lands, nothing writes that role, and the guard only checks that an access row exists — never its value.
Phase-1 steps (presentation, documents, borrower) are computed per entity. Phase-2 keys (analysis, offer, finalization, signature) are also returned — derived in compute-project-financing-request-steps.ts from macro status (+ borrower company finalized for finalization / signature). The PDP app reads steps from the API as-is via getFunnel, and maps finalization → UI label collect — see phase-one-steps.md.
Project documents¶
| Method | Path | Action |
|---|---|---|
GET |
/project/:projectId/documents |
List files (presigned GET URLs) + onboarding/analysis checklists |
POST |
/project/:projectId/documents/request-upload |
Presigned PUT URLs for file upload |
POST |
/project/:projectId/documents/confirm-upload |
Confirm S3 objects + persist document rows |
POST |
/project/:projectId/documents/:documentId/comment |
Add comment on a file |
DELETE |
/project/:projectId/documents/:documentId/comment |
Remove comment |
DELETE |
/project/:projectId/documents/:documentId |
Soft-delete a file (onboarding / analysis / finalization — guard par documentType) |
PUT |
/project/:projectId/documents/base-document-list/check |
Toggle onboarding checklist item (draft only) |
PUT |
/project/:projectId/documents/analysis-documents/check |
Toggle analysis missing-doc item (blocked only) |
PUT |
/project/:projectId/documents/complete |
Mark documents step completed (draft only) |
POST |
/project/:projectId/documents/validate |
Clear analysis missing docs + notify Projet Analyse (blocked) |
MkDocs : project-documents.md.
Project analysis (owner)¶
| Method | Path | Action |
|---|---|---|
GET |
/project/:projectId/analysis/status |
Analysis UI state (pending or blocked with questions/requests) |
POST |
/project/:projectId/analysis/question/:questionId/answer |
Answer a Projet Analyse question |
POST |
/project/:projectId/analysis/reanalysis |
Request re-analysis from blocked (optional comment) |
MkDocs : project-analysis.md.
Project finalization (collecte)¶
| Method | Path | Action |
|---|---|---|
GET |
/:projectId/finalization |
Banking + 4 KYB documents + notary + investor benefits |
PATCH |
/:projectId/finalization |
Auto-save finalization fields |
POST |
/:projectId/finalization/complete |
Validate KYB + banking → company finalized, open signature step |
MkDocs : project-finalization.md. Document upload uses confirm-upload with context.type = 'finalization' — see project-documents.md.
Internal — Projet Analyse (x-api-key: projet-analyse)¶
| Method | Path | Action |
|---|---|---|
GET |
/internal/projet-analyse/project/:projectId |
Full dossier state: instruction (status, accountManager, questions, requests) + dossier (presentation, borrower, uploaded documents with presigned URLs) + finalization block once the owner validates the finalization step |
POST |
/internal/projet-analyse/project/:projectId/questions |
Push a question → blocked |
POST |
/internal/projet-analyse/project/:projectId/comment |
Push analyst comment → blocked |
POST |
/internal/projet-analyse/project/:projectId/document-requests |
Push a document request → blocked |
PUT |
/internal/projet-analyse/project/:projectId/missing-documents |
Sync analysis missing document names |
PUT |
/internal/projet-analyse/project/:projectId/document/:documentId/refuse |
Mark uploaded file as refused (isRefused on owner API) |
POST |
/internal/projet-analyse/project/:projectId/cancel |
Abandon the dossier → canceled (terminal, optional comment) |
Internal — AI Platform (x-api-key: ai-platform)¶
| Method | Path | Action |
|---|---|---|
POST |
/internal/project-financing-request/:projectId/documents/classifications |
Platform results: sets kind and companyId / representativeId on the dossier's onboarding link, blanks only. Contract and error codes: src/internal-routes/docs/consumer.md |
GET |
/internal/project-financing-request/:projectId/documents |
Onboarding documents with kind (null until classified), companyId / representativeId and fresh presigned URLs. Also readable with the projet-analyse key |
Outbound — document classification request (AI Platform)¶
POST {AI_PLATFORM_API_URL}/classifications (Authorization: Bearer <AI_PLATFORM_API_KEY>), sent by the graphile task project-financing-request-document-classification. The task is enqueued in the submission transaction (draft → in-analysis), again when a deferred borrower company is completed, and on demand from the BO fiche Projet (POST /administration/project-financing-request/:projectFinancingRequestId/documents/classification). One call per dossier with every document that has no kind or no company / representative (business/needs-document-classification.ts; presigned URLs, signed at send time) and a short description per entity. The platform recognises an already analysed document and replays its first result. The company and each representative carry the id the platform sends back. 202 ends the job. ai-platform-unavailable / ai-platform-rate-limited / s3-get-signing-failed retry (5 attempts); the last failure, like any other, alerts #espace-financement-alertes-tech.
Outbound — ping Projet Analyse (M2M, static key)¶
POST {PROJET_ANALYSE_API_URL}/projects/:projectId/updated with { "reason": … }.
Same shared-secret scheme as the inbound routes above, carried in a different header: Authorization: Bearer <PROJET_ANALYSE_API_KEY> — a static key, not an OAuth token. Separate secret from the inbound x-api-key.
reason |
Emitted from | Transition |
|---|---|---|
submitted |
ProjectSubmissionService.submit |
draft → in-analysis |
reanalysis-requested |
ProjectFinancingRequestAnalysisWriteService.requestReanalysis |
blocked → in-analysis |
missing-documents-provided |
ProjectFinancingRequestDocumentService.validateStep |
stays blocked |
finalized |
ProjectFinalizationService.complete |
company → finalized, ping avec bloc finalization renseigné sur le prochain GET interne |
account-manager-assigned |
ProjectFinancingRequestAdministrationService.assignAccountManager |
accountManagerId posé, changé ou retiré — jamais émis sur un dossier draft |
The reason is a hint, never state: Projet Analyse always re-reads the truth from the inbound GET above. A replayed ping carries the same immutable reason, so retries stay idempotent.
Pappers¶
| Method | Path | Action |
|---|---|---|
GET |
/pappers/search |
Company search proxy (SIREN/SIRET lookup) |
GET |
/pappers/company |
Company details proxy by SIREN (siege + representants) |
Onboarding document checklist¶
| Method | Path | Action |
|---|---|---|
PUT |
/project/:projectId/documents/base-document-list/check |
Toggle a checklist item (documentKey, checked) — see Documents |
Body schema : toggleProjectFinancingRequestOnboardingBaseDocumentCheckBodySchema (@bricks-common/api-communication-project-financing).
Semantics of documentsStep.expectedDocuments (JSON on the draft project) :
true= document still pending (not checked off by owner)false= checked off by owner
The toggle inverts the API checked flag when persisting: documentsStep.expectedDocuments[key] = !checked.
Document keys (projectFinancingRequestOnboardingDocumentKeySchema) — 9 items, all default to true on project creation:
presentationDocument, unilateralPurchasePromise, equityProof, subdivisionPermit, buildingPermit, operationForecast, companyKbis, worksQuote, expertValuationReport
steps.documents in GET /projects (compute-project-financing-request-steps.ts) :
- Projet
draft→documentsStep.statuspersisté (unavailablejusqu'à complétion de la présentation, puisdraft/completed) - Projet soumis (
≠ draft) → toujourscompleted(soumission exige l'étape complétée)
La checklist expectedDocuments n'influence pas ce statut — elle guide l'UX uniquement.
| Error | HTTP | Cause |
|---|---|---|
project-not-draft |
409 | Project no longer in draft |
onboarding-expected-document-not-found |
404 | Unknown documentKey |
S3 presigned upload flow¶
The image pipeline uses presigned URLs so the browser uploads directly to S3 without proxying through the API.
Client API S3
│ │ │
├── POST /images/request-upload ──► │
│ (file list: type, size) │ │
│ ├── generateKey (uuid7 + ext) │
│ ├── computePresignedPutUrl ─────►
│ │ │
│◄─── { uploads: [{ presignedUrl, imageKey, expiresAt }] } ───┤
│ │ │
├── PUT presignedUrl (binary) ─────────────────────────────────►
│ │ │
├── POST /images/confirm-upload ──► │
│ (imageKeys + positions) │ │
│ ├── HeadObject (validate exists)─►
│ ├── Transaction: update JSONB │
│ ├── Delete replaced S3 keys ────►
│ │ │
│◄─── presentation with presigned GET URLs ────────────────────┤
Key details¶
- S3 key format:
projects/${projectId}/presentation-images/${uuidv7}.${ext} - Allowed MIME types: Mapped via
PROJECT_PRESENTATION_IMAGE_MIME_TO_EXTfrom@bricks-common/api-communication-project-financing - Browser compatibility: The S3 client disables CRC32 checksum (
requestChecksumCalculation: 'WHEN_REQUIRED') because browsers cannot sendx-amz-checksum-crc32on presigned PUTs - Atomicity:
confirm-uploadusesky_safePgTransactionwith row locking. Any failingHeadObjectaborts the entire confirmation - Reads:
get-project-presentation.view.tssigns a fresh GET URL per stored key and returns{ key, url }pairs. If any signing fails, the entire response fails (prevents index desync between client and server) - Image positions: Presentation supports
mainImageKey,secondaryImageKeys[](indexed), andlots[].imageKey(indexed by lot) - Declarative save: A save sends the gallery as it should be (absent = keep, null = clear). Dropped keys are re-tagged
pending(a lifecycle rule expires them), kept keys are untagged. Keys are never actively deleted — a concurrent save could re-reference a dropped key
Environment¶
| Variable (via Doppler) | Purpose |
|---|---|
PROJECT_FINANCING_REQUEST_AWS_ACCESS_KEY_ID |
AWS credentials for the dedicated S3 bucket |
PROJECT_FINANCING_REQUEST_AWS_SECRET_ACCESS_KEY |
AWS credentials |
PROJECT_FINANCING_REQUEST_S3_BUCKET_NAME |
Target bucket |
PROJECT_FINANCING_REQUEST_S3_PUT_TTL_SEC |
Presigned PUT URL lifetime |
PROJECT_FINANCING_REQUEST_S3_GET_TTL_SEC |
Presigned GET URL lifetime |
Additional Espace Financement portal config (broader API integration):
| Variable | Purpose |
|---|---|
ESPACE_FINANCEMENT_API_KEY |
External API auth key |
ESPACE_FINANCEMENT_API_URL |
External API base URL |
ESPACE_FINANCEMENT_API_KEY_INTERNAL |
Internal API auth key |
ESPACE_FINANCEMENT_ONBOARDING_REDIRECT_URL |
Redirect after onboarding |
S3 provider¶
Located at src/__new/lib/providers/s3/project-financing-request-s3.ts. Built on the generic createS3Service helper which exposes computePresignedPutUrl, computePresignedGetUrl, checkHeadObject, and deleteObject.
File layout¶
project-financing-request/
├── controllers/
│ ├── project-financing-request.controller.ts
│ ├── project-submission.controller.ts
│ ├── project-presentation.controller.ts
│ ├── project-presentation-images.controller.ts
│ ├── project-document.controller.ts
│ ├── project-document-internal.controller.ts
│ ├── project-analysis.controller.ts
│ ├── project-analysis-internal.controller.ts
│ ├── pappers-search.controller.ts
│ └── project-owner-company.controller.ts
├── services/
│ ├── project-creation.service.ts
│ ├── project-presentation.service.ts
│ ├── project-presentation-images.service.ts
│ ├── project-document.service.ts
│ ├── project-analysis-read.service.ts
│ ├── project-analysis-write.service.ts
│ ├── project-analysis.service.ts
│ ├── project-list.service.ts
│ ├── project-editability.service.ts
│ ├── project-owner.service.ts
│ └── project-owner-company.service.ts
├── business/
│ ├── build-initial-presentation.ts
│ ├── build-presentation-draft.ts
│ ├── collect-presentation-image-keys.ts
│ └── compute-project-financing-request-steps.ts
├── mappers/
│ └── build-document-classification-descriptions.ts
├── views/
│ ├── get-project-presentation.view.ts
│ └── get-project-analysis-status.view.ts
├── repositories/
│ ├── project.repository.ts
│ ├── project-presentation.repository.ts
│ ├── project-document.repository.ts
│ ├── project-question.repository.ts
│ ├── project-document-request.repository.ts
│ ├── project-owner.repository.ts
│ ├── project-offer.repository.ts
│ └── project-owner-company.repository.ts
├── guards/
│ └── project-financing-request-user.guard.ts
├── docs/
│ └── … (MkDocs — voir docs/.pages)
└── README.md
Shared API contract¶
Validators and types are in @bricks-common/api-communication-project-financing (a dedicated package, separate from investor api-communication).
Naming reference¶
The rename migrated code progressively. This table clarifies which names are used where:
| Layer | Naming |
|---|---|
| Module folder | project-financing-request/ |
| HTTP route prefix | project-financing-request/ |
| DB tables | project_financing_request_*, project_owner_company |
| TypeScript types/schemas | ProjectFinancingRequest*, ProjectOwnerPgSchema, ProjectOwnerCompanyPgSchema |
| Request augmentation | request.espaceFiProjectOwnerId |
| API contract package | @bricks-common/api-communication-project-financing |
| S3 config key | envConfig.projectFinancingRequest |