De l’API au PDF : l’architecture de Froment Software
Une visite du monorepo, des contrats Effect, du modèle SQLite et de la chaîne documentaire vérifiable.
- Effect
- SQLite
- documents
Cette application ne sépare pas seulement une interface web d'un serveur. Elle sépare les contrats, les textes, la préparation documentaire, les effets métier et leur exécution. Cette structure devient surtout visible quand un devis signé doit rester relié à une commande, une facture et leurs PDF.
Cet article décrit le code présent dans le dépôt en août 2026. Il distingue les garanties effectives des intentions possibles.
Table des matières
- Cinq packages, cinq responsabilités
- Les contrats avant les gestionnaires
- Des couches Effect explicites
- SQLite et Drizzle comme dernier rempart
- Du devis à la facture
- Signer un devis sans état intermédiaire
- Les snapshots figent le document
- Typst compile dans un espace isolé
- Émettre une facture avant de produire son PDF
- Une chaîne d'intégrité fondée sur SHA-256
- Ce que cette architecture garantit réellement
Cinq packages, cinq responsabilités
Le workspace pnpm inclut chaque répertoire sous packages/*. Il contient cinq packages applicatifs. Voir pnpm-workspace.yaml et les fichiers packages/*/package.json.
flowchart LR contracts["@froment/contracts\nSchémas et HttpApi"] l10n["@froment/l10n\nTextes localisés"] documents["@froment/documents\nEntrées Typst"] api["@froment/api\nMétier et persistance"] web["@froment/web\nAngular"] web --> contracts web --> l10n api --> contracts api --> documents api --> l10n documents --> contracts documents --> l10n
@froment/contracts porte les schémas Effect, les erreurs typées et les groupes HttpApi. @froment/web réutilise ces schémas pour décoder les réponses HTTP. Voir packages/contracts/src/api.ts, packages/web/src/app/shared/api-outcome.ts et packages/web/src/app/back-office/quotes-api.ts.
@froment/documents ne lance pas Typst. Il transforme les snapshots métier en données de document validées. @froment/api gère le processus Typst, SQLite et les transactions. Voir packages/documents/src/document-input.ts et packages/api/src/documents/document-renderer.ts.
@froment/l10n centralise notamment les libellés des documents. Les documents produits utilisent actuellement les textes français et le format monétaire fr-FR. Voir packages/l10n/src/document-text.ts et packages/documents/src/document-input.ts.
Les contrats avant les gestionnaires
Les contrats ne sont pas de simples types TypeScript. QuoteCreateRequest, InvoiceRenderSnapshot et les erreurs métier sont des valeurs Schema. Les contraintes couvrent les longueurs, les ULID, les états, les dates et les totaux. Voir packages/contracts/src/quotes/contracts.ts, packages/contracts/src/invoices/contracts.ts et packages/contracts/src/documents/lines.ts.
Les groupes HttpApi associent chaque route à son entrée, sa sortie et ses erreurs. Ils ajoutent aussi l'authentification, les permissions et les limites de débit. Voir packages/contracts/src/quotes/api.ts, packages/contracts/src/quote-links/api.ts et packages/contracts/src/invoices/api.ts.
Le serveur relie ensuite ces contrats aux gestionnaires avec HttpApiBuilder.group. Il publie aussi les spécifications OpenAPI française et anglaise. Voir packages/api/src/quotes/handlers.ts, packages/api/src/invoices/handlers.ts et packages/api/src/server.ts.
Cette séparation donne trois validations concrètes :
- l'entrée HTTP est décodée selon le contrat.
- le service manipule des valeurs métier déjà contraintes.
- le client Angular redécode les réponses reçues.
Les calculs monétaires font partie du contrat. Une quantité utilise des millièmes, un prix utilise des centimes et un taux utilise des points de base. Les filtres recalculent chaque ligne et les totaux du document avec des entiers et bigint. Voir packages/contracts/src/documents/lines.ts et packages/api/src/documents/calculation.ts.
Des couches Effect explicites
Les services utilisent Context.Service et leurs implémentations utilisent Layer.effect. Les dépendances apparaissent donc dans la composition du programme, pas dans un conteneur global implicite.
flowchart TB runtime["NodeRuntime"] --> server["ServerLive"] server --> routes["HttpApiBuilder + handlers"] routes --> services["ServicesLive"] services --> quoteServices["Quotes / QuoteLinks / Orders / Invoices"] services --> pdfJobs["InvoicePdfJobs + worker"] quoteServices --> artifacts["DocumentArtifacts"] pdfJobs --> artifacts artifacts --> renderer["DocumentRenderer"] quoteServices --> database["Database"] artifacts --> database pdfJobs --> database services --> audit["Audit"] renderer --> config["RuntimeConfiguration"] database --> config
main.ts assemble d'abord le noyau devis, facture, commande et rendu. Il ajoute ensuite les artifacts et le worker. Enfin, il fournit la base, la configuration, l'authentification, l'audit et l'observabilité. Voir packages/api/src/main.ts.
Cette composition ne transforme pas SQLite en service asynchrone. better-sqlite3 reste synchrone. Effect encadre cependant l'acquisition, la libération, les erreurs, la configuration, les tâches répétées et la durée de vie des fibres. Voir packages/api/src/database/database.ts et packages/api/src/invoices/pdf-jobs.ts.
SQLite et Drizzle comme dernier rempart
Drizzle décrit les tables, index, clés étrangères et contraintes CHECK. Le code métier utilise ensuite l'instance Drizzle et l'accès SQL direct fourni par better-sqlite3. Voir packages/api/src/database/schema.ts et packages/api/src/database/database.ts.
erDiagram
CLIENTS ||--o{ QUOTES : receives
QUOTES ||--|{ QUOTE_REVISIONS : has
QUOTE_REVISIONS ||--|{ QUOTE_LINES : contains
QUOTE_REVISIONS ||--o{ QUOTE_LINKS : exposes
QUOTE_REVISIONS ||--o| QUOTE_SIGNATURES : signed_as
QUOTES ||--o| ORDERS : becomes
QUOTE_SIGNATURES ||--o| ORDERS : confirms
ORDERS ||--o| INVOICES : billed_by
INVOICES ||--|{ INVOICE_REVISIONS : has
INVOICE_REVISIONS ||--|{ INVOICE_LINES : contains
INVOICE_REVISIONS ||--o| INVOICE_PDF_JOBS : queues
QUOTE_REVISIONS ||--o{ DOCUMENT_ARTIFACTS : stores
INVOICE_REVISIONS ||--o{ DOCUMENT_ARTIFACTS : stores
ORDERS ||--o{ DOCUMENT_ARTIFACTS : storesLes contraintes répètent volontairement plusieurs règles des schémas. SQLite contrôle par exemple les états, l'unicité des versions, les références, les montants et la relation entre un artifact et son propriétaire. Voir packages/api/src/database/schema.ts.
À l'ouverture, la base active WAL, les clés étrangères, un délai d'attente et synchronous = FULL. Les écritures métier critiques utilisent des transactions immediate. Voir packages/api/src/database/database.ts, packages/api/src/quotes/quotes.ts, packages/api/src/quote-links/service.ts et packages/api/src/invoices/invoices.ts.
Les migrations ont aussi une protection. Avant leur application, le code vérifie les SHA-256 des artifacts existants. Il refuse également une migration déjà enregistrée dont le hash a changé. Voir packages/api/src/database/database.ts.
Du devis à la facture
Le cycle principal est une suite de documents liés, pas une mutation continue du même enregistrement.
stateDiagram-v2 [*] --> QuoteDraft: create quote QuoteDraft --> QuoteDraft: create revision QuoteDraft --> QuoteSent: PDF exists and send QuoteSent --> QuoteAccepted: valid typed signature QuoteSent --> QuoteExpired: expiry observed QuoteExpired --> QuoteDraft: create revision QuoteDraft --> QuoteCancelled: cancel QuoteSent --> QuoteCancelled: cancel QuoteExpired --> QuoteCancelled: cancel QuoteAccepted --> OrderConfirmed: same transaction OrderConfirmed --> InvoiceDraft: create invoice InvoiceDraft --> InvoiceDraft: create revision InvoiceDraft --> InvoiceIssued: allocate number and issue InvoiceIssued --> InvoicePaid: mark paid InvoiceIssued --> InvoiceVoid: void
Le schéma déclare aussi l'état de devis rejected. Aucun gestionnaire examiné ne crée cette transition. Le diagramme montre donc seulement les transitions implémentées. Voir packages/contracts/src/quotes/contracts.ts, packages/api/src/quotes/quotes.ts, packages/api/src/quotes/quote-expiration.ts et packages/api/src/quote-links/service.ts.
Une nouvelle révision de devis est autorisée en état draft ou expired. Elle remet le devis en draft et incrémente sa version. Une nouvelle révision de facture est limitée à l'état draft. Les deux opérations vérifient expectedVersion. Voir packages/api/src/quotes/quotes.ts et packages/api/src/invoices/invoices.ts.
Les références suivent les formes DE-AAAA-NNNNNN, CO-AAAA-NNNNNN et FA-AAAA-NNNNNN. Un compteur SQLite sépare le type et l'année métier. Voir packages/contracts/src/business/contracts.ts et packages/api/src/business/business-references.ts.
Signer un devis sans état intermédiaire
Un devis ne peut être envoyé que depuis draft, avec la version attendue et un artifact PDF existant. Le serveur crée alors un jeton aléatoire. La base ne conserve que son HMAC. Voir packages/api/src/quote-links/service.ts.
Le jeton public est placé dans le fragment # de l'URL. Le navigateur le transmet ensuite dans le corps des requêtes publiques. Voir packages/api/src/quote-links/service.ts et packages/web/src/app/pages/public-quote/public-quote.ts.
sequenceDiagram actor Client participant Web as Angular public quote participant API as QuoteLinkHandlers participant Service as QuoteLinks participant DB as SQLite Client->>Web: Enter name, typed signature, consent Web->>API: POST signature with token API->>API: Apply request limit and collect request context API->>Service: accept(payload, IP, user-agent) Service->>DB: BEGIN IMMEDIATE Service->>DB: Load link, current revision, snapshot and PDF Service->>Service: Validate link, state, version and PDF digest Service->>DB: Insert audit event Service->>Service: Build evidence and SHA-256 Service->>DB: Consume link and accept quote Service->>DB: Insert signature and confirmed order DB-->>Service: COMMIT Service-->>Web: QuoteAcceptanceResult
La requête exige un nom non vide, un consentement littéralement égal à true et une signature typée non vide. Chaque texte est limité à 160 caractères. Voir packages/contracts/src/quotes/contracts.ts.
Dans une seule transaction, le service consomme le lien, accepte le devis, écrit l'audit, stocke la preuve et crée la commande confirmée. Les index uniques empêchent plusieurs signatures ou commandes pour le même devis. Voir packages/api/src/quote-links/service.ts et packages/api/src/database/schema.ts.
La preuve JSON contient le snapshot, les identifiants liés, l'horodatage, le contexte et les empreintes du snapshot et du PDF. L'enregistrement ajoute l'empreinte de cette preuve. Le code ne présente pas cette preuve comme une signature qualifiée eIDAS. Voir packages/api/src/quote-links/service.ts et packages/l10n/src/translations.ts.
Les snapshots figent le document
Chaque révision de devis stocke un render_snapshot JSON. Il contient notamment l'émetteur, le client, les lignes calculées, les totaux, la référence, la version et la version du template. Voir packages/contracts/src/quotes/contracts.ts et packages/api/src/quotes/quotes.ts.
Une facture initiale reprend le client, le titre et les lignes du snapshot du devis accepté. Elle ajoute ses dates et conditions de paiement. Chaque révision de facture possède ensuite son propre snapshot. Voir packages/api/src/invoices/invoices.ts.
La commande ne duplique pas un JSON supplémentaire. Orders.getSnapshot reconstruit un OrderRenderSnapshot depuis le snapshot de la révision acceptée et les données de commande. Voir packages/api/src/orders/orders.ts.
Ce choix évite qu'une modification ultérieure du client ou de l'émetteur change une ancienne révision. Il conserve aussi les données nécessaires au rendu sans refaire les calculs depuis des tables courantes.
Typst compile dans un espace isolé
@froment/documents prépare une structure unique pour devis, commande et facture. La facture ajoute les références de commande et de devis, les dates et les mentions légales. Voir packages/documents/src/document-input.ts.
Le template document.typ lit seulement input/document.json, puis délègue la mise en page à shared.typ. Le rendu utilise une page A4, les polices Cousine et Liberation Mono, un tableau de lignes et un bloc de totaux. Voir packages/documents/templates/document.typ et packages/documents/templates/shared.typ.
Pour chaque compilation, l'API crée un répertoire temporaire avec des sous-répertoires d'entrée, de sortie et de templates. Elle copie deux fichiers Typst et écrit le JSON avec le mode 0600. Voir packages/api/src/documents/document-renderer.ts.
Le processus Typst reçoit un PATH vide, un chemin de packages local et SOURCE_DATE_EPOCH=0. L'option --creation-timestamp 0 stabilise aussi les métadonnées temporelles. Un sémaphore limite la concurrence. Le code vérifie enfin l'en-tête %PDF- et supprime le répertoire temporaire. Voir packages/api/src/documents/document-renderer.ts et packages/api/src/runtime-config.ts.
Émettre une facture avant de produire son PDF
L'émission de facture sépare la décision métier du travail de compilation. La transaction attribue le numéro, crée une révision finale, passe la facture à issued et ajoute un job pending. Voir packages/api/src/invoices/invoices.ts.
sequenceDiagram actor User participant API as invoiceIssue handler participant Invoice as Invoices.issue participant DB as SQLite participant Jobs as InvoicePdfJobs participant Artifact as DocumentArtifacts participant Typst as Typst process User->>API: POST issue with expectedVersion API->>Invoice: issue(invoiceId, payload, actor) Invoice->>DB: BEGIN IMMEDIATE Invoice->>DB: Allocate invoice number Invoice->>DB: Insert final snapshot and pending job Invoice->>DB: Mark invoice issued DB-->>Invoice: COMMIT Invoice-->>API: InvoiceIssueResult API->>Jobs: runPending immediately Jobs->>DB: Claim job as processing Jobs->>Artifact: Render final revision Artifact->>Typst: Compile snapshot Typst-->>Artifact: PDF bytes Artifact->>DB: Store PDF and SHA-256 Jobs->>DB: Mark job ready API-->>User: Issued invoice
Le gestionnaire tente le job immédiatement après l'émission. Un échec de cette tentative ne transforme pas l'émission réussie en erreur HTTP. Voir packages/api/src/invoices/issue.ts.
Un worker en arrière-plan reprend les jobs pending et failed. Au démarrage, il marque aussi les jobs restés processing comme failed. Il les relance ensuite selon l'intervalle et la concurrence configurés. Voir packages/api/src/invoices/pdf-jobs.ts, packages/api/src/main.ts et packages/api/src/runtime-config.ts.
Le claim atomique incrémente attempts. Après le rendu, le job devient ready ou failed avec le code stable pdf.render_failed. Des triggers de migration contrôlent aussi la cohérence entre job, facture, révision, version et numéro. Voir packages/api/src/invoices/pdf-jobs.ts et packages/api/drizzle/20260822180000_stable_trigger_codes/migration.sql.
Le PDF de commande suit un autre rythme. Le back-office peut le produire explicitement. Le portail client le produit à la première demande s'il manque, puis le relit depuis les artifacts. Voir packages/api/src/orders/handlers.ts et packages/api/src/client-portal/handlers.ts.
Une chaîne d'intégrité fondée sur SHA-256
Un artifact stocke le contenu PDF, sa taille, son type MIME et son SHA-256. Une contrainte vérifie la taille, le type blob, le format de l'empreinte et le propriétaire correspondant au type de document. Voir packages/api/src/database/schema.ts.
flowchart LR snapshot["Snapshot JSON validé"] --> typst["Entrée Typst préparée"] typst --> pdf["Octets PDF"] pdf --> digest["SHA-256 du PDF"] pdf --> artifact["document_artifacts.content"] digest --> artifactHash["document_artifacts.sha256"] artifact --> verify["Recalcul avant lecture"] artifactHash --> verify verify --> download["Téléchargement"] snapshot --> snapshotHash["SHA-256 du texte JSON"] pdf --> signaturePdfHash["SHA-256 vérifié à l'acceptation"] snapshotHash --> evidence["Preuve de signature JSON"] signaturePdfHash --> evidence evidence --> evidenceHash["SHA-256 de la preuve"]
Le service recalcule l'empreinte avant chaque lecture d'artifact par les routes documentaires et le portail. Une divergence produit document.artifact.digest_mismatch. Voir packages/api/src/documents/artifact-integrity.ts, packages/api/src/documents/document-artifacts.ts et packages/api/src/client-portal/client-portal.ts.
Lors de la signature, le service vérifie d'abord l'artifact PDF. Il calcule ensuite les empreintes du texte JSON du snapshot, du PDF et de la preuve complète. Voir packages/api/src/quote-links/service.ts.
Cette chaîne détecte une modification. Elle ne constitue pas une signature cryptographique asymétrique de l'artifact. Le dépôt stocke une empreinte avec le contenu, puis la recalcule.
Ce que cette architecture garantit réellement
Le code établit les propriétés suivantes :
- les frontières HTTP et documentaires utilisent des schémas exécutables.
- chaque révision conserve les données nécessaires à son rendu.
- l'acceptation et la création de commande sont atomiques.
- l'émission de facture survit à un échec temporaire du rendu PDF.
- un artifact PDF est contrôlé par SHA-256 avant sa lecture.
- SQLite répète les invariants essentiels avec des contraintes, index et triggers.
- les services et leurs dépendances sont assemblés par des couches Effect.
Le code ne prétend pas fournir une signature eIDAS qualifiée. Il ne signe pas non plus les PDF avec une clé privée. Sa garantie actuelle est plus précise : une preuve horodatée relie un snapshot validé, un PDF vérifié, une acceptation et une commande atomique.