Erreurs & santé de connexion
Chaque requête envoyée au terminal — un objet JSON par trame, écrit <json>END_JSON — passe par un pipeline fixe. Chaque étape peut rejeter la requête avec un error_code distinct. Cette page est la référence de ces codes, des enveloppes d’erreur, de la sonde de liveness ping→pong, et de la détection d’un port mort avec reconnexion.
Pipeline de traitement (ordre des contrôles)
Le terminal évalue une requête dans cet ordre exact. La première étape qui échoue produit la réponse ; les étapes suivantes ne s’exécutent pas.
| # | Étape | error_code en cas d’échec |
|---|---|---|
| 1 | Garde de taille (message.length <= 1048576) | MESSAGE_TOO_LARGE |
| 2 | Parsing JSON dans le contrat, dont décodage strict des enums | INVALID_PAYLOAD |
| 3 | Validation champ & métier (toutes les règles) | VALIDATION_ERROR |
| 4 | Contrôle de doublon en persistance (invoiceId déjà certifié) | DUPLICATE_INVOICE |
| 5 | Certification / persistance (interne) | PROCESSING_ERROR |
| — | Ensemble des étapes 2–5 encadré par un timeout de 30 s | PROCESSING_TIMEOUT |
La validation (étape 3) collecte et renvoie toutes les erreurs d’un coup ; une seule réponse VALIDATION_ERROR peut contenir plusieurs entrées errors[]. Les échecs de décodage d’enum (étape 2) sont eux signalés un à un en INVALID_PAYLOAD, avant toute validation.
Enveloppes d’erreur
Chaque réponse arrive sur le fil comme un objet JSON terminé par END_JSON. Il existe deux formes d’erreur distinctes. Branchez votre logique sur error_code, jamais sur le texte lisible (certains messages sont en français).
Erreur générique (tous les codes sauf VALIDATION_ERROR) :
{"status":"error","invoice_id":"INV-2026-000123","error_code":"DUPLICATE_INVOICE","error_message":"Invoice already exists with this ID: INV-2026-000123","timestamp":1751362800000,"protocol":"TCP"}END_JSONErreur de validation (VALIDATION_ERROR uniquement — porte un tableau errors[] et n’a pas de error_message) :
{"status":"error","error_code":"VALIDATION_ERROR","invoice_id":"INV-2026-000123","errors":[{"field":"recipientPhone","message":"Phone number must be in valid Congo format (+242XXXXXXXXX or 0XXXXXXXXX)"},{"field":"currency","message":"Currency must be XAF, USD, or EUR"}],"timestamp":1751362800000,"protocol":"TCP"}END_JSONinvoice_id peut être absent. Le terminal ne sérialise pas les nulls : quand l’id n’a pas pu être déterminé (requête trop grande, JSON malformé, enum inconnu, timeout), la clé invoice_id est entièrement omise. Traitez une absence comme « inconnu ». De même, le champ protocol est interne — ignorez-le. Ne faites pas de correspondance sur le texte error_message (mélange de français/anglais) ; branchez sur error_code et, pour la validation, sur errors[].field.
Catalogue des codes d’erreur
error_code | Déclencheur | error_message (exact / modèle) | Action client |
|---|---|---|---|
MESSAGE_TOO_LARGE | Trame > 1048576 caractères (1 Mo). | Message trop volumineux | Corriger la charge utile. Ne pas retenter inchangé. Cause fréquente : un logoImg Base64 volumineux — le retirer/réduire. |
INVALID_PAYLOAD | JSON malformé, vide, ou valeur d’enum inconnue. | Invalid JSON: <detail>, Invalid payload: <detail>, ou Empty payload | Corriger la charge utile. Non retentable tel quel. Vérifier la syntaxe JSON et l’orthographe des enums. |
VALIDATION_ERROR | Une ou plusieurs règles champ/métier/montant ont échoué. | (pas de error_message ; voir errors[]) | Corriger les champs listés, puis renvoyer la requête corrigée. Non retentable tel quel. |
DUPLICATE_INVOICE | Une facture avec le même invoiceId est déjà certifiée. | Invoice already exists with this ID: <invoiceId> | Traiter comme un succès idempotent — le document est déjà certifié. Ne pas renvoyer sous le même id, ne pas allouer un nouveau numéro fiscal. |
PROCESSING_ERROR | Échec interne pendant certification/persistance, ou toute exception non capturée en étapes 2–5. | Erreur de traitement: <detail> ou le texte d’échec du dépôt. | Retentable avec backoff. Si transitoire, réussira au retry ; si la facture a persisté, le retry renvoie DUPLICATE_INVOICE (ce qui confirme le succès). |
PROCESSING_TIMEOUT | Traitement d’une facture > 30000 ms (30 s). | Timeout de traitement de la facture | Retentable avec backoff. La facture peut avoir abouti ou non — un retry renvoie success ou DUPLICATE_INVOICE ; les deux signifient qu’elle est certifiée. |
Décodage strict des enums → INVALID_PAYLOAD
Cinq champs sont des enums décodés strictement. Toute valeur hors de l’ensemble autorisé interrompt le parsing et donne INVALID_PAYLOAD (avec un préfixe Invalid payload:).
| Champ | Valeurs JSON autorisées (exactes, sensibles à la casse) |
|---|---|
invoiceType | salesInvoice, creditNote |
recipientType | individual, business, government, foreign |
paymentMethod | cash, bank_transfer, mobile_money, cheque, card |
items[].type | product, service |
items[].discountType | fixed, percentage |
Exemple — envoyer "recipientType":"corporation" produit :
{"status":"error","error_code":"INVALID_PAYLOAD","error_message":"Invalid payload: Invalid value 'corporation' for RecipientType. Allowed: [individual, business, government, foreign]","timestamp":1751362800000,"protocol":"TCP"}END_JSONRègles de validation (VALIDATION_ERROR)
Toutes les règles sont évaluées ensemble ; chaque violation ajoute une entrée errors[] avec le chemin field et le message exacts.
Identification & lignes
| Chemin de champ | Règle | message exact |
|---|---|---|
invoiceId | Requis, non vide. | Invoice ID is required |
invoiceId | Longueur max 23. | Invoice ID must not exceed 23 characters |
items[i].designation | Requis, non vide. | Designation is required |
items[i].unitPrice | >= 0.01. | Unit price must be greater than 0 |
items[i].quantity | >= 0.001. | Quantity must be greater than 0 |
items[i].taxRate | Requis, chaîne non vide. | Tax rate is required |
additionalTaxes[i].taxCode | Requis, non vide (si additionalTaxes présent). | Tax code is required |
referenceInvoiceId | Requis, non vide quand invoiceType == creditNote. | Reference invoice ID is required for credit notes |
currency | Exactement l’une de XAF, USD, EUR. | Currency must be XAF, USD, or EUR |
Règles destinataire par recipientType
Formats : NIU = exactement 16 ou 17 caractères · Téléphone Congo ^(\+242\|242)?0[456]\d{7}$ · Téléphone international (FOREIGN) ^\+?[1-9]\d{1,14}$ · Email ^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$.
recipientType | Champs requis | Règle NIU | Règle téléphone |
|---|---|---|---|
individual | aucun (tous optionnels, mais validés si présents) | si présent : 16 ou 17 caractères | si présent : format Congo |
business | name, niu, phone, email, address | requis, 16 ou 17 caractères | format Congo |
government | name, niu, phone, email, address | requis, 16 ou 17 caractères | format Congo |
foreign | name, phone, email, address (niu optionnel) | si présent : 16 ou 17 caractères | format international |
Messages notables : Recipient name is required for <label> recipients, NIU is required for <label> recipients, NIU must be exactly 16 or 17 characters, Phone number is required for <label> recipients, Phone number must be in valid Congo format (+242XXXXXXXXX or 0XXXXXXXXX), Phone number must be in valid international format, Email is required for <label> recipients, Invalid email format, Address is required for <label> recipients. (<label> vaut business, government ou foreign.)
Cohérence des montants (anti-fraude, tolérance ±0.01)
field renvoyé | Formule (à ±0.01 près) | Modèle de message |
|---|---|---|
subtotal | subtotal == Σ items[i].netAmount | Subtotal is incoherent: declared <d> but sum of item net amounts is <c> |
totalTaxAmount | totalTaxTAmount + totalTaxRAmount == Σ items[i].taxAmount | Total taxes are incoherent: declared <d> but sum of item taxes is <c> |
totalAmount | totalAmount == subtotal + totalTaxTAmount + totalTaxRAmount − discountAmount + electronicStampDuty + additionalCentTax | Total amount is incoherent: declared <d> but calculated <c> |
items[i].subtotal | subtotal == unitPrice * quantity | Item subtotal is incoherent: <up> * <qty> should be <exp>, not <sub> |
items[i].netAmount | netAmount == subtotal − discountAmount | Item net amount is incoherent: <sub> - <disc> should be <exp>, not <net> |
items[i].totalAmount | totalAmount == netAmount + taxAmount | Item total amount is incoherent: <net> + <tax> should be <exp>, not <total> |
Le chemin renvoyé pour le contrôle de taxe est totalTaxAmount, même si les valeurs comparées sont totalTaxTAmount + totalTaxRAmount. Ces contrôles sont ignorés si items est vide.
DUPLICATE_INVOICE et idempotence
Le contrôle de doublon est fondé uniquement sur invoiceId : si l’id existe déjà, la requête échoue en DUPLICATE_INVOICE et aucun nouveau numéro fiscal n’est frappé.
- Traitez
DUPLICATE_INVOICEcomme un signal de succès idempotent, pas un échec dur. Conservez le numéro fiscal / QR reçu sur la réponse de succès d’origine ; ne réémettez pas et ne renumérotez pas. - C’est ce qui rend le retry-après-échec sûr. Après un
PROCESSING_TIMEOUTou un lien coupé, renvoyez la requête identique avec le mêmeinvoiceId. Vous obtiendrez soitstatus:"success"(non persistée), soitDUPLICATE_INVOICE(persistée) — les deux confirment la certification. N’allouez jamais un nouvelinvoiceIdpour un retry.
DUPLICATE_INVOICE prouve l’existence, pas une certification valide. Si le sous-système fiscal n’était pas prêt à la première arrivée, le terminal peut persister la ligne avec sfec_number/qr_code nuls ; chaque renvoi de cet id retourne alors DUPLICATE_INVOICE indéfiniment. Ne traitez un doublon comme « déjà certifié » que si vous avez réellement reçu un success antérieur portant un sfec_number et un qr_code non vides pour cet id — sinon le document n’est pas certifié et exige une réconciliation manuelle.
Logique de gestion (référence)
def handle_response(resp: dict) -> str:
status = resp.get("status")
if status == "success":
return "certified" # stocker sfec_number + qr_code
code = resp.get("error_code")
if code == "DUPLICATE_INVOICE":
return "already_certified" # succès idempotent : réconcilier, ne PAS renvoyer
if code in ("PROCESSING_ERROR", "PROCESSING_TIMEOUT"):
# transitoire : retenter le MÊME invoiceId avec backoff ;
# un DUPLICATE_INVOICE ultérieur confirme le succès.
return "retry"
if code == "VALIDATION_ERROR":
for e in resp.get("errors", []):
log(f"fix {e['field']}: {e['message']}")
return "fix_and_resend" # non retentable inchangé
if code in ("INVALID_PAYLOAD", "MESSAGE_TOO_LARGE"):
return "fix_payload" # malformé / surdimensionné / mauvais enum
return "unknown_error"Santé de connexion — sonde ping → pong
Sur un lien actif, la liveness est gratuite : chaque facture envoyée revient en trame success ou error, ce qui prouve déjà que le port est vivant. Pour prouver activement qu’un lien inactif (idle) est en vie, écrivez une trame ping ; le pont répond par une trame pong.
{"type":"ping","timestamp":"2026-07-01T10:30:45.123Z","source":"erp"}END_JSON
{"type":"pong","timestamp":"2026-07-01T10:30:45.456Z","source":"middleware"}END_JSONLe timestamp est une chaîne ISO-8601 UTC ; le pont ne la valide pas. source vaut "erp" sur votre ping et "middleware" sur la réponse.
Vous ne recevrez jamais de ping non sollicité et n’avez jamais à en répondre : seule la direction ping → pong initiée par votre logiciel est active.
Détection d’un port mort
Depuis le client, il existe exactement trois signaux indépendants. Une intégration robuste gère les trois.
| # | Signal | Manifestation |
|---|---|---|
| a | Une écriture ou lecture lève une erreur d’E/S | un write(...END_JSON) ou un read lève une exception série/IO → le lien est perdu ; marquer down |
| b | La lecture expire sans trame encadrée | vous avez envoyé une requête mais aucune trame terminée par END_JSON n’est arrivée dans le délai → traiter comme down |
| c | Le port COM disparaît | le pont a été arrêté ou le terminal débranché, si bien que l’ouverture (ou la prochaine lecture) de COM10 échoue → down |
Ne faites pas confiance à un indicateur « open » en cache. Un objet SerialPort/Serial peut se déclarer ouvert alors que le pont ou la liaison avec le terminal a été rompu en silence. Seuls une vraie erreur d’E/S (a), une lecture expirée sans trame END_JSON (b) ou une ouverture ratée (c) révèlent un pair mort.
Reconnexion avec backoff
Quand l’un des signaux (a)/(b)/(c) se déclenche, fermez COM10 et rouvrez-le, puis reprenez. Il n’y a rien à attendre après la réouverture : dès que COM10 est rouvert, vous pouvez envoyer.
sur DOWN :
fermer COM10
delai = 1s
boucle :
essayer : ouvrir COM10 ; sortir
sinon : dormir(delai) ; delai = min(delai * 2, 30s) # plafond de backoff
pour chaque facture non acquittée : # même invoiceId à chaque fois
send_frame(ser, inv) ; enregistrer(read_frame(ser))- Backoff. Réouverture avec backoff exponentiel plafonné + gigue (1 s → 2 s → 4 s → … → 30 s). Gardez le plafond bas pour rétablir la liaison rapidement.
- Si la réouverture échoue en boucle, le port est réellement absent (signal c) : le pont a été arrêté ou le terminal débranché. Redémarrez le pont fourni.
- Ne renvoyez que les factures non acquittées, en réutilisant le même
invoiceId. UnDUPLICATE_INVOICEconfirme qu’elle était déjà certifiée — arrêtez de retenter (récupérez le numéro fiscal / QR depuis lesuccessd’origine que vous avez persisté, le terminal ne les réémet pas).
Étape suivante → Exemples de code