Skip to content

feat(quotes): import retour signé client + hash texte d'intégrité#3

Merged
Seeyko merged 1 commit into
mainfrom
feat/import-signed-quote
May 3, 2026
Merged

feat(quotes): import retour signé client + hash texte d'intégrité#3
Seeyko merged 1 commit into
mainfrom
feat/import-signed-quote

Conversation

@Seeyko

@Seeyko Seeyko commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Contexte

C'est exactement le workflow que Tom décrivait : « je l'envoie au client, il signe, je l'importe de nouveau dans FAKT en tant que ‘client a signé ce PDF', et FAKT continue automatiquement la suite du process. »

Implémente la V0.2 actée dans docs/roadmap-post-v0.1.md §2 (Option B import retour, écartée Option A portail SaaS pour rester offline-first).

Plan superpowers complet : docs/superpowers/plans/2026-05-03-import-signed-quote.md

Workflow utilisateur

  1. Tom émet un devis (status sent).
  2. Auto : FAKT calcule le SHA-256 du texte normalisé du PDF officiel et le persiste (quotes.original_text_hash). Idempotent — re-run sur les vieux devis sans hash.
  3. Tom envoie le PDF par email comme aujourd'hui (DocuSign / Adobe / impression+scan côté client).
  4. Le client renvoie le PDF signé.
  5. Tom clique « Importer signature client » sur le détail du devis → file picker + email/nom du signataire.
  6. FAKT vérifie le hash :
    • Match → archive PDF, append signature event chaîné, transition sent → signed, débloque « Créer une facture ».
    • Mismatch → modal d'avertissement avec les deux hashes tronqués (8…8). Tom peut confirmer (annotation manuscrite légitime) ou annuler (altération suspecte). Force OK : l'audit consigne la divergence (docHashBefore=expected, docHashAfter=actual).

Architecture

Côté Rust (logique cryptographique)

  • pdf/text_hash.rs — extraction texte via pdf-extract 0.7 + normalisation déterministe (whitespace folded, line endings unifiés, BOM stripped, trim) + SHA-256
  • commands/pdf_hash.rs — commande Tauri compute_pdf_text_hash
  • commands/audit_chain.rs — commande Tauri compute_signature_event_self_hash (réutilise crypto::audit::SignatureEvent::compute_self_hash_hex — pas de réimplémentation TS du format de hash chaîné)

Côté DB

  • Migration 0005_quote_original_text_hash.sqlALTER TABLE quotes ADD COLUMN original_text_hash TEXT
  • Schema Drizzle SQLite + Postgres
  • Embedded migrations régénérées
  • Test helpers DDL mis à jour

Côté sidecar

  • POST /api/quotes/:id/original-text-hash — endpoint dédié (le PATCH /quotes/:id est limité à status=draft, on a besoin de set sur sent). Idempotent : re-écriture identique OK, valeur différente refusée (422). Refusé sur signed/invoiced (PDF figé).
  • Validation Zod : ^[0-9a-f]{64}$

Côté frontend

  • quote-text-hash.ts — helper persistQuoteOriginalTextHash (best-effort, log sans bloquer)
  • import-signed-quote.ts — orchestrateur verifyImportedPdfHash + commitImportSignedQuote (storeSignedPdf, append event chaîné via Rust, activity event, transition status)
  • ImportSignedModal.tsx — modal 3 phases (form / verifying / mismatch / committing)
  • Auto-persist via useEffect dans Detail.tsx — couvre les 3 cas : « Marquer envoyé » manuel, « Créer et émettre » direct, vieux devis émis avant cette feature

Validation

  • 1045+ tests verts (+15 nouveaux)
    • 5 tests sidecar original-text-hash (set, idempotent, valeur différente refusée, devis signé refusé, hash mal formé)
    • 3 tests DB round-trip originalTextHash
    • 3 tests UI Detail (bouton visible avec hash, désactivé sans hash, caché sur draft)
    • 7 tests Rust normalize_text (whitespace, line endings, BOM, trim, idempotence, unicode, invalid PDF)
    • 2 tests Rust compute_signature_event_self_hash (déterminisme, sensibilité aux champs)
  • typecheck zéro warning sur 13 packages
  • lint Biome zéro warning sur 516 fichiers
  • cargo check OK (nouvelle dep pdf-extract = "0.7.12", pure Rust, ~200ko compilé, pas de FFI)

Test plan

  • bun install (nouvelle dep pdf-extract côté Cargo.toml seulement)
  • bun run typecheck — zéro warning attendu
  • bun run lint — zéro warning attendu
  • bun run test — 1045+ tests
  • cd apps/desktop/src-tauri && cargo test text_hash — tests unitaires Rust
  • Smoke desktop happy path :
    • Créer devis → émettre (Marquer envoyé) → ouvrir le détail → vérifier original_text_hash set en DB
    • Re-télécharger le PDF du devis émis
    • L'utiliser tel quel (sans modification) comme « PDF retourné signé » → cliquer Importer → vérifier match direct → status passe signed
  • Smoke desktop mismatch :
    • Avec un autre PDF (n'importe quel autre devis) → vérifier modal d'avertissement
    • Confirmer l'import malgré divergence → vérifier audit event note les deux hashes différents
  • Compatibilité ascendante :
    • Vieux devis créé avant cette feature → ouvrir → l'auto-persist hash se déclenche au render PDF → bouton Importer s'active
    • Si pas de re-rendu (vieux devis sans issuedAt) → bouton désactivé avec tooltip explicatif

⚠️ Note merge avec PR #2

Cette PR est branchée depuis main et utilise le numéro 0005 pour la migration. Si la PR #2 (audit-trail + clauses) est mergée d'abord, il faudra rebase et :

  1. Renommer 0005_quote_original_text_hash.sql0006_quote_original_text_hash.sql
  2. Réordonner meta/_journal.json (idx 6 au lieu de 5)
  3. Re-exécuter bun packages/api-server/scripts/generate-migrations.ts
  4. Merge naturel sur Quote interface (shared) — les deux ajouts coexistent.

Le code métier est indépendant : pas de conflit fonctionnel, juste numérotation.

Hors scope (intentionnel)

  • Détection auto champs PDF (modèle ONNX DocuSeal-style) — V0.4+, gain marginal vs effort.
  • PDF scan-only (image-only)pdf-extract retourne string vide → mismatch garanti → l'utilisateur force, divergence consignée. Documenté dans le plan (R5).
  • Re-hashing après upgrade Typst — risque R1 du plan : si une nouvelle version de Typst extrait du texte différemment, le hash existant ne matchera plus. Pour V0.2, OK : on accepte que les vieux devis échouent et l'utilisateur force. Procédure de re-hashing à introduire en V0.3.

Workflow V0.2 acté dans `docs/roadmap-post-v0.1.md` §2 (Option B).

À l'émission d'un devis, FAKT calcule maintenant le SHA-256 du texte
normalisé du PDF officiel (via `pdf-extract` Rust + commande Tauri
`compute_pdf_text_hash`) et le persiste sur `quotes.original_text_hash`.

À l'import retour, un nouveau bouton « Importer signature client » sur
les devis `sent` ouvre un modal :
- Sélection du PDF retourné par le client (file picker)
- Champ email/nom du signataire
- Vérification du hash texte du PDF importé contre celui stocké
- Si match → store_signed_pdf + signature event chaîné + activity event
  `quote_signed_by_client_imported` + transition sent → signed
- Si mismatch → modal de confirmation forcée affichant les deux hashes
  tronqués (8…8) avec avertissement (annotation/altération). Force OK :
  l'audit event consigne la divergence.

La chaîne d'audit reste cryptographiquement intègre : le calcul de
`previousEventHash` passe par la commande Rust
`compute_signature_event_self_hash` (pas de réimplémentation TS).

Plan : docs/superpowers/plans/2026-05-03-import-signed-quote.md

Validation :
- 1045+ tests verts (sidecar original-text-hash, DB round-trip, UI bouton,
  Rust normalize_text + audit chain hash)
- typecheck zéro warning sur 13 packages
- lint Biome zéro warning sur 516 fichiers
- cargo check OK (nouvelle dep `pdf-extract = "0.7"`)

Note merge : conflit attendu avec PR #2 (clauses) sur :
- `0005_*.sql` — renommer en `0006_quote_original_text_hash.sql` au rebase
- `_journal.json` + `migrations-embedded.ts` — régénérer
- `Quote` interface (shared) — merge naturel

Signed-off-by: Tom Andrieu <andrieutom30@gmail.com>
@Seeyko Seeyko force-pushed the feat/import-signed-quote branch from 31d4695 to a67646e Compare May 3, 2026 22:23
@Seeyko Seeyko merged commit 9ee4c48 into main May 3, 2026
5 of 6 checks passed
@Seeyko Seeyko deleted the feat/import-signed-quote branch May 3, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant