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. EnvoyezinvoiceId,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_PAYLOADavant même la validation. - Les montants sont des nombres JSON (décimaux). Le
taxRated’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
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
invoiceId | string | Oui | Votre numéro de facture. Non vide, max 23 caractères. Sert aussi de clé de doublon (répétition → DUPLICATE_INVOICE). |
invoiceType | enum | Oui | "salesInvoice" ou "creditNote". |
invoiceSubject | string | Non | Objet / libellé libre. |
invoiceDueDate | string | Non | Date d’échéance (chaîne ; format non validé). |
referenceInvoiceId | string | Conditionnel | Obligatoire et non vide quand invoiceType == "creditNote" ; sinon optionnel. |
invoiceDate | string | Non | Date d’émission. Si omise, le terminal l’horodate à la certification (horloge sécurisée). |
Destinataire (plat)
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
recipientType | enum | Oui | "individual", "business", "government" ou "foreign". Détermine quels champs deviennent obligatoires. |
recipientName | string | Conditionnel | Requis pour business, government, foreign. Optionnel pour individual. |
recipientNiu | string | Conditionnel | Requis 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. |
recipientAddress | string | Conditionnel | Requis pour business, government, foreign. Optionnel pour individual. |
recipientPhone | string | Conditionnel | Requis 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}$. |
recipientEmail | string | Conditionnel | Requis pour business, government, foreign. Si présent, doit correspondre à ^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$. |
isRecipientTaxable | boolean | Oui | Le 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 ».
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
subtotal | number | Oui | Total net avant taxe/remise d’en-tête. Doit égaler Σ items[].netAmount. |
totalTaxTAmount | number | Oui | TVA au taux normal [T] (18%). |
totalTaxRAmount | number | Oui | TVA au taux réduit [R] (5% ; mettre 0.0 si inutilisé). |
totalExemptAmount | number | Oui | Total exonéré (hors des équations de cohérence). |
totalTaxAmount | number | Oui | Total de la TVA (totalTaxTAmount + totalTaxRAmount). |
discountAmount | number | Oui | Escompte — remise d’en-tête soustraite dans l’équation du total. |
totalLineDiscountAmount | number | Oui | Somme des remises de tous les items (Σ items[].discountAmount) ; informatif, hors équation. |
additionalCentTax | number | Oui | Taxe « centimes » additionnelle ajoutée dans l’équation du total. |
electronicStampDuty | number | Oui | Droit de timbre électronique ajouté dans l’équation du total. |
totalAmount | number | Oui | Total général. Doit égaler subtotal + totalTaxTAmount + totalTaxRAmount − discountAmount + electronicStampDuty + additionalCentTax. |
amountDue | number | Oui | Montant à 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
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
currency | string | Oui | L’une de "XAF", "USD", "EUR". |
paymentMethod | enum | Oui | "cash", "bank_transfer", "mobile_money", "cheque" ou "card". |
paymentReference | string | Non | Référence de paiement (ex. id de transaction). |
paymentDate | string | Non | Date de paiement. |
notes | string | Non | Notes 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
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
items | array de ERPInvoiceItem | Oui | Au 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). |
additionalTaxes | array de ERPAdditionalTax | Non | Taxes supplémentaires optionnelles. Hors de toute équation de cohérence — seul taxCode non vide est exigé. |
ERPInvoiceItem — champs de ligne
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
designation | string | Oui | Description de la ligne. Non vide. |
classificationCode | string | Non | Code de classification fiscale. |
type | enum | Oui | "product" ou "service". |
unitPrice | number | Oui | Prix unitaire. Doit être ≥ 0.01. |
quantity | number | Oui | Quantité. Doit être ≥ 0.001. |
subtotal | number | Oui | Doit égaler unitPrice * quantity (±0.01). |
discountAmount | number | Oui | Montant de remise de ligne. |
discountType | enum | Oui | "fixed" ou "percentage". Requis même si discountAmount vaut 0. |
netAmount | number | Oui | Doit égaler subtotal − discountAmount (±0.01). |
taxRate | string | Oui | Taux 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. |
taxAmount | number | Oui | Montant de taxe de la ligne. Alimente le contrôle de cohérence des taxes d’en-tête. |
totalAmount | number | Oui | Doit égaler netAmount + taxAmount (±0.01). |
ERPAdditionalTax — champs de taxe additionnelle
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
taxCode | string | Oui | Code de taxe. Non vide (seul champ validé). |
taxLabel | string | Non | Libellé lisible. |
taxAmount | number | Oui | Montant de taxe (passage ; non recoupé). |
taxRate | number | Non | Taux 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 |
|---|---|
invoiceType | salesInvoice, creditNote |
recipientType | individual, business, government, foreign |
paymentMethod | cash, bank_transfer, mobile_money, cheque, card |
items[].type | product, service |
items[].discountType | fixed, 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 1 | subtotal == Σ items[].netAmount |
| En-tête 2 | totalTaxTAmount + totalTaxRAmount == Σ items[].taxAmount |
| En-tête 3 | totalAmount == subtotal + totalTaxTAmount + totalTaxRAmount − discountAmount + electronicStampDuty + additionalCentTax |
| Par ligne a | subtotal == unitPrice * quantity |
| Par ligne b | netAmount == subtotal − discountAmount |
| Par ligne c | totalAmount == 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_JSONRejets 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