Skip to Content
TCC SFEC TerminalEnvoyer une facture

Envoyer une facture

Une requête de facture est un seul objet JSON encadré en <json>END_JSON — le texte JSON compact suivi immédiatement du marqueur ASCII littéral END_JSON, sans saut de ligne. Le terminal désérialise le JSON dans le contrat ERPInvoice, le valide, le certifie, et répond par une unique trame <json>END_JSON sur le même lien série COM10.

Règles de format qui façonnent la charge utile

  • Les noms de champs sont exactement les propriétés du contrat, en camelCase. Envoyez invoiceId, totalTaxTAmount, unitPrice, etc. — jamais en snake_case.
  • La structure est plate. Le destinataire est un ensemble de champs recipient* de premier niveau, pas un objet imbriqué.
  • Les champs enum portent des valeurs de chaîne précises (voir le catalogue ci-dessous). Une chaîne enum inconnue est rejetée en INVALID_PAYLOAD avant même la validation.
  • Les montants sont des nombres JSON (décimaux). Le taxRate d’une ligne est la seule exception — c’est une chaîne.
  • Taille max. de trame : 1 Mo ; au-delà → MESSAGE_TOO_LARGE.
⚠️

Les valeurs par défaut ne sont pas appliquées de façon fiable par le terminal pour les clés manquantes. Envoyez donc toujours tous les champs marqués Obligatoire, même à 0.

ERPInvoice — champs de premier niveau

Identification

ChampTypeObligatoireDescription
invoiceIdstringOuiVotre numéro de facture. Non vide, max 23 caractères. Sert aussi de clé de doublon (répétition → DUPLICATE_INVOICE).
invoiceTypeenumOui"salesInvoice" ou "creditNote".
invoiceSubjectstringNonObjet / libellé libre.
invoiceDueDatestringNonDate d’échéance (chaîne ; format non validé).
referenceInvoiceIdstringConditionnelObligatoire et non vide quand invoiceType == "creditNote" ; sinon optionnel.
invoiceDatestringNonDate d’émission. Si omise, le terminal l’horodate à la certification (horloge sécurisée).

Destinataire (plat)

ChampTypeObligatoireDescription
recipientTypeenumOui"individual", "business", "government" ou "foreign". Détermine quels champs deviennent obligatoires.
recipientNamestringConditionnelRequis pour business, government, foreign. Optionnel pour individual.
recipientNiustringConditionnelRequis pour business, government. Optionnel (mais validé si présent) pour individual, foreign. S’il est présent, doit faire exactement 16 ou 17 caractères.
recipientAddressstringConditionnelRequis pour business, government, foreign. Optionnel pour individual.
recipientPhonestringConditionnelRequis pour business, government, foreign. Pour individual/business/government, validé contre le motif Congo ^(\+242|242)?0[456]\d{7}$ (ex. +242061234567). Pour foreign, format international E.164 ^\+?[1-9]\d{1,14}$.
recipientEmailstringConditionnelRequis pour business, government, foreign. Si présent, doit correspondre à ^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$.
isRecipientTaxablebooleanOuiLe destinataire est-il assujetti à la taxe.

Montants

Tous de type Double et Obligatoires. Ils sont recoupés par le validateur de cohérence anti-fraude (tolérance ±0.01) — voir « Règles de cohérence des montants ».

ChampTypeObligatoireDescription
subtotalnumberOuiTotal net avant taxe/remise d’en-tête. Doit égaler Σ items[].netAmount.
totalTaxTAmountnumberOuiTVA au taux normal [T] (18%).
totalTaxRAmountnumberOuiTVA au taux réduit [R] (5% ; mettre 0.0 si inutilisé).
totalExemptAmountnumberOuiTotal exonéré (hors des équations de cohérence).
totalTaxAmountnumberOuiTotal de la TVA (totalTaxTAmount + totalTaxRAmount).
discountAmountnumberOuiEscompte — remise d’en-tête soustraite dans l’équation du total.
totalLineDiscountAmountnumberOuiSomme des remises de tous les items (Σ items[].discountAmount) ; informatif, hors équation.
additionalCentTaxnumberOuiTaxe « centimes » additionnelle ajoutée dans l’équation du total.
electronicStampDutynumberOuiDroit de timbre électronique ajouté dans l’équation du total.
totalAmountnumberOuiTotal général. Doit égaler subtotal + totalTaxTAmount + totalTaxRAmount − discountAmount + electronicStampDuty + additionalCentTax.
amountDuenumberOuiMontant à payer (informatif ; hors équation).

discountAmount (Escompte) et totalLineDiscountAmount (Total Remises) sont deux remises distinctes, affichées sur deux lignes séparées de la facture. totalLineDiscountAmount doit être la somme des remises de tous les items (Σ items[].discountAmount), pas celle d’un seul item.

Paiement

ChampTypeObligatoireDescription
currencystringOuiL’une de "XAF", "USD", "EUR".
paymentMethodenumOui"cash", "bank_transfer", "mobile_money", "cheque" ou "card".
paymentReferencestringNonRéférence de paiement (ex. id de transaction).
paymentDatestringNonDate de paiement.
notesstringNonNotes libres.

Vendeur (tous optionnels)

Métadonnées de passage (nullable, défaut null), imprimées/attachées par le terminal : logoImg, sellerBankIban, sellerBankRib, sellerAddress, sellerTaxRegime, cashierName, externalCreditNoteNumber, originalSfecInvoiceNumber, sciet.

Lignes et taxes additionnelles

ChampTypeObligatoireDescription
itemsarray de ERPInvoiceItemOuiAu moins une ligne (les contrôles de cohérence ne sont ignorés que si le tableau est vide, mais une facture vide n’a rien à certifier).
additionalTaxesarray de ERPAdditionalTaxNonTaxes supplémentaires optionnelles. Hors de toute équation de cohérence — seul taxCode non vide est exigé.

ERPInvoiceItem — champs de ligne

ChampTypeObligatoireDescription
designationstringOuiDescription de la ligne. Non vide.
classificationCodestringNonCode de classification fiscale.
typeenumOui"product" ou "service".
unitPricenumberOuiPrix unitaire. Doit être ≥ 0.01.
quantitynumberOuiQuantité. Doit être ≥ 0.001.
subtotalnumberOuiDoit égaler unitPrice * quantity (±0.01).
discountAmountnumberOuiMontant de remise de ligne.
discountTypeenumOui"fixed" ou "percentage". Requis même si discountAmount vaut 0.
netAmountnumberOuiDoit égaler subtotal − discountAmount (±0.01).
taxRatestringOuiTaux de TVA en chaîne décimale, ex. "18" ou "18.00" (aussi "19.25"). Non vide. Non utilisé pour le calcul — le terminal ne recalcule pas la taxe à partir de lui ; taxAmount fait foi.
taxAmountnumberOuiMontant de taxe de la ligne. Alimente le contrôle de cohérence des taxes d’en-tête.
totalAmountnumberOuiDoit égaler netAmount + taxAmount (±0.01).

ERPAdditionalTax — champs de taxe additionnelle

ChampTypeObligatoireDescription
taxCodestringOuiCode de taxe. Non vide (seul champ validé).
taxLabelstringNonLibellé lisible.
taxAmountnumberOuiMontant de taxe (passage ; non recoupé).
taxRatenumberNonTaux numérique (ici un Double, contrairement au taxRate chaîne des lignes).

Catalogue d’enums (chaînes JSON exactes)

Envoyez la chaîne JSON exacte ; toute autre valeur fait échouer le parsing avec INVALID_PAYLOAD (le message porte un préfixe Invalid payload: et liste les valeurs autorisées). La correspondance est sensible à la casse et au style.

Enum (champ)Valeurs JSON acceptées
invoiceTypesalesInvoice, creditNote
recipientTypeindividual, business, government, foreign
paymentMethodcash, bank_transfer, mobile_money, cheque, card
items[].typeproduct, service
items[].discountTypefixed, percentage
⚠️

Les valeurs sont mélangées volontairement (ex. salesInvoice mais bank_transfer) et sensibles à la casse. Un exemple de rejet renvoyé par le terminal :

Invalid payload: Invalid value 'SALE' for InvoiceType. Allowed: [salesInvoice, creditNote]

currency est une chaîne simple validée séparément (XAF, USD, EUR), pas un enum. taxRate (ligne) est une chaîne libre uniquement contrôlée non vide.

Règles de cohérence des montants (anti-fraude, tolérance ±0.01)

Votre charge utile est rejetée en VALIDATION_ERROR sauf si toutes ces égalités tiennent (une différence supérieure à 0.01 en valeur absolue est une violation) :

#Règle
En-tête 1subtotal == Σ items[].netAmount
En-tête 2totalTaxTAmount + totalTaxRAmount == Σ items[].taxAmount
En-tête 3totalAmount == subtotal + totalTaxTAmount + totalTaxRAmount − discountAmount + electronicStampDuty + additionalCentTax
Par ligne asubtotal == unitPrice * quantity
Par ligne bnetAmount == subtotal − discountAmount
Par ligne ctotalAmount == netAmount + taxAmount

totalTaxAmount, amountDue, totalExemptAmount, totalLineDiscountAmount et tout ce qui est dans additionalTaxes ne font pas partie de ces équations — envoyez-les cohérents avec votre comptabilité, mais ils ne provoqueront pas à eux seuls un rejet de cohérence. Note : le contrôle de taxe est renvoyé sous le chemin de champ totalTaxAmount bien qu’il compare totalTaxTAmount + totalTaxRAmount.

Exemple complet — vente à un individual (passe la validation)

Montré en indenté pour la lisibilité ; sur le fil, envoyez-le en JSON compact suivi immédiatement de END_JSON (sans saut de ligne).

{ "invoiceId": "INV-2026-000123", "invoiceType": "salesInvoice", "invoiceSubject": "Retail sale", "invoiceDate": "2026-07-01T10:15:00", "recipientType": "individual", "recipientName": "Jean Dupont", "recipientNiu": "M012024051234567", "recipientAddress": "12 Avenue de l'Independance, Brazzaville", "recipientPhone": "+242061234567", "recipientEmail": "jean.dupont@example.cg", "isRecipientTaxable": false, "subtotal": 20000.00, "totalTaxTAmount": 3600.00, "totalTaxRAmount": 0.00, "totalExemptAmount": 0.00, "totalTaxAmount": 3600.00, "discountAmount": 0.00, "totalLineDiscountAmount": 0.00, "additionalCentTax": 0.00, "electronicStampDuty": 0.00, "totalAmount": 23600.00, "amountDue": 23600.00, "currency": "XAF", "paymentMethod": "cash", "notes": "Thank you for your purchase", "cashierName": "Marie Kula", "sciet": "", "items": [ { "designation": "Laptop stand", "type": "product", "unitPrice": 15000.00, "quantity": 1, "subtotal": 15000.00, "discountAmount": 0.00, "discountType": "percentage", "netAmount": 15000.00, "taxRate": "18", "taxAmount": 2700.00, "totalAmount": 17700.00 }, { "designation": "USB-C cable", "type": "product", "unitPrice": 2500.00, "quantity": 2, "subtotal": 5000.00, "discountAmount": 0.00, "discountType": "percentage", "netAmount": 5000.00, "taxRate": "18", "taxAmount": 900.00, "totalAmount": 5900.00 } ] }

Sur le fil, cela devient {"invoiceId":"INV-2026-000123",...}END_JSON.

Vérification de cohérence : sous-totaux de lignes 15000 = 15000×1, 5000 = 2500×2 ; nets = subtotal − 0 ; totaux de lignes 17700 = 15000+2700, 5900 = 5000+900 ; en-tête subtotal 20000 = 15000+5000 ; taxe d’en-tête 3600 = 2700+900 ; totalAmount 23600 = 20000+3600+0−0+0+0.

Avoir (creditNote) avec referenceInvoiceId

Un avoir a la même forme mais fixe invoiceType: "creditNote" et doit porter un referenceInvoiceId non vide pointant vers la facture d’origine. Les champs destinataire optionnels sont omis ici (autorisé pour individual). Les montants doivent rester positifs (unitPrice ≥ 0.01, quantity ≥ 0.001), même pour un avoir.

{ "invoiceId": "CN-2026-000045", "invoiceType": "creditNote", "referenceInvoiceId": "INV-2026-000042", "recipientType": "individual", "recipientName": "Jean Dupont", "isRecipientTaxable": false, "subtotal": 2500.00, "totalTaxTAmount": 450.00, "totalTaxRAmount": 0.00, "totalExemptAmount": 0.00, "totalTaxAmount": 450.00, "discountAmount": 0.00, "totalLineDiscountAmount": 0.00, "additionalCentTax": 0.00, "electronicStampDuty": 0.00, "totalAmount": 2950.00, "amountDue": 2950.00, "currency": "XAF", "paymentMethod": "cash", "items": [ { "designation": "Refund - USB-C cable", "type": "product", "unitPrice": 2500.00, "quantity": 1, "subtotal": 2500.00, "discountAmount": 0.00, "discountType": "percentage", "netAmount": 2500.00, "taxRate": "18", "taxAmount": 450.00, "totalAmount": 2950.00 } ] }

Sérialiser et envoyer (encadrement END_JSON)

Sérialisez la facture en JSON compact, ajoutez le marqueur END_JSON, écrivez sur COM10, puis lisez la réponse jusqu’à voir END_JSON et retirez-le.

import json import serial # pip install pyserial DELIM = b"END_JSON" def send_invoice(port: str, invoice: dict, timeout: float = 35.0) -> str: payload = json.dumps(invoice, separators=(",", ":")).encode("utf-8") # compact with serial.Serial( port, baudrate=115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=timeout, ) as ser: ser.write(payload + DELIM) # un objet JSON + END_JSON, sans saut de ligne ser.flush() buffer = bytearray() while DELIM not in buffer: # accumuler jusqu'à l'arrivée du marqueur chunk = ser.read(64) if not chunk: raise TimeoutError("No response frame before timeout") buffer += chunk frame, _, _ = buffer.partition(DELIM) return frame.decode("utf-8") # le JSON avant END_JSON

Rejets courants liés à cette charge utile : INVALID_PAYLOAD (JSON malformé ou chaîne enum inconnue), VALIDATION_ERROR (échec de champ/format/cohérence, avec un tableau errors par champ), DUPLICATE_INVOICE (invoiceId répété), MESSAGE_TOO_LARGE (trame > 1 Mo). Les formes de réponse sont détaillées dans les pages suivantes.


Étape suivante → Lire la réponse