docs/70-systemes-a-part/99-technique.md —
ce document se lit aussi dans le dépôt, sans serveur.
Les systèmes à part — le côté technique
Ces deux systèmes ont en commun d'être ouverts à des gens qui ne sont pas nos employés. Tout ce qui suit découle de là.
Certi'Clé — les fichiers
| Rôle | Fichier |
|---|---|
| Le site public | public/certicle/index.php — son propre point d'entrée, hors de l'API |
| Écran ERP | public/erp/certicle.php + public/erp/assets/js/pages/certicle-2.js |
| API ERP | src/Http/Controllers/CerticleController.php |
| L'oracle | src/Certicle/VerificationService.php |
| Ce qu'un statut autorise | src/Certicle/StatusPolicy.php |
| Les moyens de paiement | src/Certicle/PaymentPolicy.php |
| Émission et scellement | src/Certicle/CertificateService.php |
| Le PDF | src/Certicle/CertificatePdf.php |
| Normalisation des saisies | src/Certicle/FieldNormalizer.php |
| L'index des factures | src/Certicle/InvoiceIndexer.php |
| Lecture boutique | src/Certicle/CerticleWooRepository.php |
| Données Darsy | src/Certicle/CerticleRepository.php |
Le site public ne passe pas par les routes de l'API. Il a son propre contrôleur frontal, sa propre politique de sécurité de contenu, son propre journal (storage/logs/certicle.log).
La seule route API est GET /v1/certicle/verifications, réservée à l'administration.
APP_DEBUG n'a aucun effet en production sur cette page : une trace d'exception y serait lisible par n'importe quel visiteur.
Les tables de Certi'Clé
| Table | Contenu |
|---|---|
certicle_factures | L'index : numéro formaté, normalisé, en chiffres, date |
certicle_verifications | Les tentatives, en empreintes |
certicle_certificats | Les certificats émis, leur jeton, leur contenu scellé |
certicle_blocages | Les IP et factures bloquées |
certicle_sequence | Le compteur de numérotation |
Migrations 028_certicle.sql et 029_certicle_demandeur.sql.
Le poivre
Dérivé de ENCRYPT_KEY par HKDF. Le plancher de 32 octets est là pour refuser le cas qui comptait : la chaîne vide.
Le piège des notes de commande
Les notes n'ont pas suivi HPOS. Elles sont restées dans wp_comments, et leur comment_post_ID porte un identifiant de commande qui n'a plus systématiquement de ligne dans wp_posts — les commandes créées par Darsy depuis le 23/08/2026 n'en ont plus du tout.
CerticleWooRepository en tient compte ; un code qui joindrait naïvement wp_posts perdrait ces commandes.
Ce qu'il ne faut pas faire avec Certi'Clé
- Détailler l'échec champ par champ. C'est l'oracle, et c'est la fin du service.
- Différencier « facture inconnue » de « éléments faux ». Même message, toujours.
- Écrire dans la base de la boutique depuis ce service. Il est prévu pour tourner sous un compte MySQL limité au
SELECT. - Réémettre un certificat à l'identique.
Application Pro — les fichiers
| Rôle | Fichier |
|---|---|
| Admin des pros | src/Http/Controllers/ProAppController.php |
| L'API de l'application | src/Http/Controllers/ProShopController.php |
| Connexion | src/Http/Controllers/ProAuthController.php |
| Catalogue | src/Http/Controllers/ProCatalogController.php |
| Encaissement | src/Http/Controllers/ProPaymentController.php |
| La commande | src/Pro/ProOrderBuilder.php |
| L'encaissement | src/Pro/ProPaymentService.php |
| Cookies | src/Pro/ProAuthCookies.php |
| Refus de session | src/Pro/ProSessionRepository.php |
| Mot de passe WordPress | src/Pro/WpPasswordVerifier.php |
| Garde de route | src/Http/Middleware/ProAuthMiddleware.php |
| Droits | src/Repository/ProAppUserRepository.php |
| Justificatifs | src/Repository/ProDocumentRepository.php |
Deux familles de routes, à ne pas confondre
/pro/* | L'application. Derrière ProAuthMiddleware, jamais derrière l'authentification du personnel. |
/v1/pro-app/*, /v1/pro-catalog/* | L'administration. Administrateur. |
/v1/pro-payments | Module finance. |
POST /pro/auth/login est limité en fréquence ; tout le reste de /pro/* exige une session valide, qui impose aussi le CSRF.
Le CSRF est obligatoire ici, pas optionnel
L'application et l'API sont sur des domaines différents. Le cookie de session doit donc être SameSite=None; Secure pour voyager — ce qui retire le filet dont les cookies de l'ERP profitent.
La protection par double soumission (cookie cles_csrf ↔ en-tête X-CSRF-Token) est donc la seule qui reste.
La liste de refus, et pourquoi c'en est une
Rien n'est écrit à la connexion. Une session ordinaire ne laisse aucune trace, le volume suit le nombre de déconnexions plutôt que celui des connexions, et sur une base vide la vérification est un index qui ne trouve rien.
On révoque le jeton, pas le pro. Révoquer par pro fermerait toutes ses sessions, y compris celle du téléphone qu'il vient d'ouvrir. Le jti est tiré au hasard à chaque jeton.
Les tables de l'application Pro
| Table | Contenu |
|---|---|
pro_app_users | Une ligne par compte activé : enabled, blocked, credit_limit |
pro_catalog_items | Le catalogue et ses règles de reproduction |
pro_order_documents | Les justificatifs déposés |
pro_session_revocations | Les jetons refusés avant échéance |
Migrations 022, 023, 024, 035.
La couche de droits vit dans la base Darsy. L'identité vient de la boutique et se recompose dans le contrôleur : pas de jointure entre les deux bases.
Ce qu'il ne faut pas faire avec l'application Pro
- Créer une commande Pro par SQL. Elle doit passer par l'API du plugin, sans quoi la facture n'est pas émise.
- Supposer qu'un
client_proest activé. Il ne l'est que si Darsy le dit. - Marquer payé par SQL. WooCommerce garde sa propre copie en cache.
- Poser une route
/pro/*sans$proAuthMiddleware. Elle répondrait à n'importe qui, etpro_user_idvaudrait0dans le contrôleur — un identifiant qui ne désigne personne et que les dépôts acceptent sans broncher.RoutesProProtegeesTestle refuse depuis le 25/09/2026, avec une liste d'exceptions nommées : la connexion, qui délivre la session et ne peut donc pas en exiger une. - Croire que le contrôle CSRF couvre tout
/pro/*. Il ne s'applique que lorsque l'authentification arrive par cookie — et le vrai client, la PWA, n'utilise pas le cookie. Un jeton porteur n'est pas envoyé automatiquement par le navigateur : il ne porte pas le risque que le CSRF adresse. Le garde le dit lui-même ($fromCookie && !in_array(…SAFE_METHODS…)), et c'est correct. Ce qui protège le chemin du jeton, c'est la Content-Security-Policy de l'application.
> Cette page disait « ne pas retirer le contrôle CSRF de /pro/* », ce qui > se lisait comme « il y en a un partout ». On pouvait en déduire que la PWA > envoyait un en-tête CSRF, et bâtir dessus.
Où vit la règle d'encours
Dans src/Pro/EncoursPro.php, et nulle part ailleurs. Deux chemins en ont besoin : l'écran qui annonce au pro ce qu'il lui reste, et le contrôle qui refuse la commande. Recopier le calcul aurait garanti qu'ils divergent un jour.
L'arithmétique y est statique et sans dépendance — elle se teste sans base, ce qui n'était pas le cas quand elle vivait en méthode privée derrière deux dépôts et l'API de la boutique.
Le PDF d'une facture client vient de la boutique
Darsy n'imprime pas les factures client : WooCommerce les émet, et le plugin expose GET /wp-json/darsy/v1/invoice/{id}?order_key=…&type=invoice. src/Woo/CustomerInvoicePdf.php est le seul endroit qui sait construire cette URL.
order_key fait l'autorisation. Le point de terminaison n'est pas authentifié — c'est la même clé que le lien « voir ma facture » d'un e-mail WooCommerce. La fiche client de l'ERP la donne au navigateur de l'opérateur pour son bouton « Facture » ; la PWA du pro, elle, ne la reçoit jamais.
type=invoice même pour un devis, et ce n'est pas une faute. La boutique n'a aucun document quote : le thème rend l'invoice et le retitre. Demander quote ferait répondre 404 et le devis cesserait de partir. EnTeteSocieteTest garde cette ligne — et vérifie aussi que personne ne rebâtit l'URL ailleurs.
Le User-Agent n'est pas décoratif. L'appel se faisait parfile_get_contents, qui n'envoie aucun User-Agent — et le.htaccessde la boutique refuse en 403 toute requête vers/wp-json/dont le User-Agent est vide ou ressemble à celui d'un script. Sans lui, aucune facture ne part. Ce n'est pas le WAF d'OVH, contrairement à ce que le code a longtemps dit. Mesuré le 25/09/2026 : User-Agent vide, la page d'accueil répond 200 et/wp-json/répond 403 — un pare-feu global n'aurait pas fait la différence. La règle est dans le.htaccessde la boutique, bornée à trois chemins. Détail danssrc/Woo/WooApiService.php.
Le relevé mensuel
| Route | Ce qu'elle rend |
|---|---|
GET /v1/pro-payments/pro-month | La liste des factures du mois, sans la clé de commande |
GET /v1/pro-payments/pro-month/zip | Les PDF en archive |
Les deux acceptent woo_user_id ou email — la fiche client ne connaît que l'adresse. Le pont est ProOrderRepository::findProIdByEmail(), qui porte le même contrôle de rôle que le reste et ne rend qu'un entier : findProUserForAuth () ferait le travail mais rendrait aussi l'empreinte du mot de passe.
Le mois se filtre par intervalle, pas par DATE_FORMAT. L'ancienne façon, encore utilisée par listDeferredOrders(), applique une fonction à la colonne : aucun index ne peut servir, et c'est du MySQL, donc intestable sur le banc SQLite. ReleveMensuelProTest écrit les bornes — premier instant, dernier instant, et le premier du mois suivant qui doit rester dehors.
POST /pro/orders/preview
Le panier passé dans le même constructeur que la création de commande, sans rien écrire et sans appeler la boutique. C'est le seul moyen de vérifier un panier sans dupliquer les règles.
Elle répond 200 même quand le panier est refusé : un refus n'est pas une requête en échec, c'est une réponse à afficher. Le client lève une exception sur tout code hors 2xx, et un dépassement d'encours remonterait alors comme « erreur » au lieu de « voilà ce qu'il te reste ». Le verdict est dans le corps.
Les tests
tests/Unit/CerticleNormalizerTest.php, tests/Unit/CerticleTimelineTest.php, tests/Unit/PoivreCerticleTest.php (le poivre refuse une clé vide).
tests/Unit/EncoursProTest.php — la limite exacte passe, un centime au-dessus refuse, l'absence de plafond ne coupe rien, et le refus porte ses trois chiffres.
tests/Unit/RoutesProProtegeesTest.php — toute route /pro/* passe par le garde, la liste des exceptions n'a pas d'entrée morte, et ce qui est public porte une limite de débit.
tests/Unit/ProOrderBuilderTest.php — les plafonds de quantité et l'estimation du TTC dont dépend le contrôle d'encours.
tests/Unit/ReleveMensuelProTest.php — les bornes du mois (le premier du mois suivant reste dehors), les payées qui comptent, le rattachement par méta, le pont e-mail → pro qui refuse un compte sans le rôle, la commande payée par carte qui n'est pas une créance, et le repli postmeta.
tests/Unit/VerdictEncaissementTest.php — la règle de l'encaissement cas par cas, et surtout l'accord entre la liste PHP et celle de la fiche commande, qui est en JavaScript. Les deux listes existent en double, ce qui n'est acceptable que parce que ce test refuse qu'elles divergent.
tests/Unit/ApercuPanierProTest.php — la route d'aperçu jouée sans MySQL : le TTC que la facture portera, le panier vide qui répond quand même sur l'encours, le dépassement en 200 avec ses trois chiffres, la clé qui exige sa carte, un justificatif purgé nommé plutôt qu'ignoré, le plafond par ligne, et une clé retirée du catalogue.
Les trois tables Pro sont dans le schéma SQLite de test ; le côté boutique se dresse à la main, comme dans les tests d'intégration. La route est donc jouable sur la machine de celui qui écrit le code — ce qui n'était pas le cas quand le contrôle d'encours vivait en méthode privée derrière deux dépôts.
Ce que ce banc ne prouve pas. SQLite ne vérifie pas les clés étrangères, et il accepte un marqueur nommé employé deux fois là où MySQL le refuse par un HY093. La suite MySQL reste la seule à en répondre. Voir tests/Support/PdoMysqlVersSqlite.php.