# VoxFactura : API publique (pour assistants IA)

> VoxFactura est un outil de facturation piloté à la voix pour artisans. Cette
> API donne un accès LECTURE (et quelques écritures gated) aux données d'un
> compte : factures, dépenses, clients et marge par client, comptabilité. Elle sert à
> répondre en langage naturel à des questions business et à préparer des
> brouillons. Elle n'envoie jamais rien à un client.

## Base

- Base URL : https://voxfacture-production.up.railway.app
- Préfixe : /api/v1/pub
- Auth : header `Authorization: Bearer vf_live_…` (clé API scopée créée par
  l'artisan dans Réglages → Clés API). Alternative : header `X-API-Key`.
- Doc OpenAPI : /api/v1/pub/openapi.json ; doc interactive : /api/v1/pub/docs
- Toutes les données sont isolées au compte de la clé (aucun accès cross-compte).

## Clé cabinet (cabinet d'expertise comptable)

- Une clé `vf_cab_…` (créée dans l'espace cabinet, page API) lit les dossiers
  des clients qui ont donné accès au cabinet, en LECTURE SEULE.
- Chaque requête précise le dossier : paramètre `?dossier=<numéro>` ou en-tête
  `X-VoxFactura-Dossier`. Sans dossier : 400. Dossier inconnu, pas à ce
  cabinet ou accès retiré : 404. Toute écriture (POST) : 403.
- GET /cabinet/dossiers (clé cabinet seulement) -> [ { num, raison_sociale,
  siren } ] : les dossiers actifs.
- Les mêmes routes de lecture que ci-dessous, avec `dossier` en plus. Les
  numéros (`id`, `*_id`) sont ceux du dossier lu.
- Frein : 600 requêtes par heure et par dossier, 6 000 par heure pour le
  cabinet (429 avec Retry-After). Chaque lecture figure dans le journal du
  client.

## Concepts

- Client : tout se range par client. Une dépense est rattachée à un client,
  à l'un de ses devis ou à l'une de ses factures (`client_id`, `devis_id`,
  `facture_id`, null sinon) ; GET /clients/{id}/margin donne la MARGE du
  client, en tout et par travail.
- Travail : un devis et les factures qui en sont tirées (acompte, solde,
  avoirs), ou une facture faite sans devis, ou un regroupement de documents.
- Chantier : ancien regroupement de documents (`chantier_id`), gardé pour
  compatibilité. Les routes /chantiers et le scope chantiers:read restent.
- Facture : document émis au client. Statuts : brouillon, envoyee,
  payee_partielle, payee, en_retard, annulee.
- Dépense : facture de frais (matériel, sous-traitance…), avec une catégorie.
- Devis : proposition ; créable en brouillon via l'API (jamais envoyé auto).
- Numéro (`numero`) : nul tant qu'une facture ou un devis est un brouillon.
  Le numéro définitif de la série (F-2026-0004, D-..., série de la marque) et
  la date d'émission sont attribués quand le document est émis (envoi, marqué
  envoyé ou payé, dépôt électronique, signature). Un brouillon se désigne par
  son `id` (« Brouillon n°12 »).
- Identifiants : le champ `id` de chaque objet est le numéro propre au compte
  (1, 2, 3...). Toutes les références (`client_id`, `chantier_id`, `devis_id`,
  `facture_id`, `facture_origine_id`, `prestation_id`) et les `{id}` des chemins utilisent
  ces mêmes numéros. Un numéro absent du compte répond 404.

## Permissions (scopes de la clé)

- Lecture : invoices:read, expenses:read, clients:read, accounting:read,
  payments:read, chantiers:read (chantiers et regroupements, ancien)
- La marge d'un client demande invoices:read ET expenses:read.
- Écriture : devis:write, payments:write, expenses:write, expenses:reconcile
- Une clé ne peut que ce que ses scopes autorisent (403 sinon).

## Endpoints lecture

- GET /invoices?unpaid_only=&statut=&chantier_id=&from=&to=&modifie_depuis=&limit=&offset=
  -> { data: [factures], count, total, limit, offset } (`from` / `to` :
  date d'émission AAAA-MM-JJ, incluses ; `count` = éléments de la page,
  `total` = total correspondant aux filtres ; `modifie_depuis` =
  AAAA-MM-JJTHH:MM:SSZ, seulement ce qui a changé depuis (`updated_at`)).
  Chaque facture porte aussi : updated_at, paid_at (paiement complet),
  date_paiement (dernier versement), montant_paye, reste_du, devise (EUR),
  client_siren, stripe_ref (pi_/in_/cs_ si payée par Stripe, sinon null),
  lien_pdf (chemin, null pour un brouillon)
- GET /invoices/{id} -> facture détaillée : lignes (avec leur `nature`, bien
  ou service), date de prestation, adresse de livraison, bon de commande,
  `nature_operations` (biens, services ou mixte)
- GET /invoices/{id}/pdf -> PDF Factur-X de la facture émise (404 pour un
  brouillon)
- GET /expenses?client_id=&chantier_id=&categorie=&from=&to=&modifie_depuis=&a_verifier=&limit=&offset=
  (`client_id` : seulement les dépenses rattachées à ce client ;
  `a_verifier=true` : la file des factures reçues sans paiement connu, hors
  des totaux ; sinon les dépenses comptées). Chaque dépense porte :
  client_id, devis_id, facture_id (rattachement, null sinon), HT, TVA,
  TTC (EUR), categorie, numero_facture, devise_origine et montant_origine
  (facture en devise), montant_eur_estime (euros estimés au taux BCE du jour
  de la facture, faux dès qu'un paiement est rapproché), source et
  ref_externe (dépense créée par POST /expenses), a_verifier, paye_le,
  rapprochement ({ source,
  operation_id, montant, date, devise, origine: api ou banque } ou null),
  lien_justificatifs, updated_at
- GET /expenses/{id}/justificatifs -> [ { id, nom, type, taille } ] ;
  GET /expenses/{id}/justificatifs/{justificatif_id} -> le fichier
- GET /payments?from=&to=&limit=&offset= (payments:read) -> factures
  encaissées : invoice_id, numero, date, montant, moyen (carte, virement,
  declare), statut
- GET /clients?search=&limit=&offset=
- GET /clients/{id}/margin (invoices:read + expenses:read) -> { client_id,
  facture_ht, depenses, marge, depenses_en_ttc, travaux: [ { type (devis,
  facture, regroupement), titre, devis_ids, facture_ids, chantier_ids,
  facture_ht, depenses, marge, nb_depenses, derniere_date } ], sans_document
  (même forme, type sans_document, ou null) }. Mêmes chiffres que la fiche
  client de VoxFactura ; montants en texte décimal ; dépenses en TTC quand
  depenses_en_ttc est vrai (compte en franchise de TVA). Titres : sans
  chantiers:read, un regroupement s'appelle « Regroupement » ; un travail
  fait seulement d'un devis sans facture s'appelle « Travail sans facture ».
- Ancien regroupement, gardé pour compatibilité (scope chantiers:read) :
  GET /chantiers?statut=&client_id=&limit=&offset= ; GET /chantiers/{id} ;
  GET /chantiers/{id}/revenue (CA facturé du chantier, scope invoices:read)

## Endpoints comptabilité (scope accounting:read)

- GET /accounting/vat-summary?period_start=YYYY-MM-DD&period_end=YYYY-MM-DD
  -> TVA collectée par taux, déductible, TVA nette.
- GET /accounting/sales-journal, /accounting/purchase-journal (mêmes dates),
  aussi en CSV : /accounting/sales-journal.csv, /accounting/purchase-journal.csv
  (séparateur « ; », montants à virgule)
- GET /accounting/fec?year=YYYY -> Fichier des Écritures Comptables (texte
  tab-séparé, partie double).

## Endpoints écriture (jamais d'envoi client)

- POST /devis (devis:write) -> crée un devis BROUILLON.
  Body : { client_id, objet?, chantier_id?, validite_jours? (1 à 365) ou
  date_validite?, delai_execution?, acompte_pct?, notes?, lignes: [ {
  designation, quantite, unite?, prix_unitaire_ht, taux_tva, prestation_id?,
  nature? ("bien" ou "service") } ] }. Sans validité : la durée du compte.
  Ligne du catalogue sans prix_unitaire_ht : prix de ce client (réglé par
  l'artisan ou dernier prix facturé), sinon celui du catalogue.
- POST /invoices/{id}/mark-paid (payments:write) -> Body { montant?, date? }
  (partiel si < reste dû ; vide = solde total ; date = jour du paiement
  AAAA-MM-JJ, pas dans le futur, sinon aujourd'hui).
- POST /expenses (expenses:write), deux corps possibles :
  * JSON { designation, montant_ttc, montant_ht?, montant_tva?, chantier_id?,
    fournisseur?, categorie?, source?, ref_externe? } (saisie) ;
  * multipart/form-data { fichier, source?, ref_externe?, chantier_id?,
    categorie? } : justificatif PDF, PDF Factur-X ou XML CII / UBL (10 Mo au
    plus). Factur-X, UBL, CII : fournisseur, SIREN, numéro, date, HT, TVA par
    taux, TTC et devise lus dans la pièce, sans IA ni crédit ; PDF simple :
    lu par l'IA (1 crédit). Mêmes règles qu'une facture reçue par e-mail :
    facture déjà connue non recréée, avoir en négatif, pièce posée sur la
    ligne bancaire du même paiement, sinon a_verifier (hors des totaux).
  Idempotence : source + ref_externe (les deux ou aucun). Même couple = même
  dépense, code 200, jamais de doublon ; 201 = dépense créée ; 200 aussi
  quand la pièce rejoint une dépense existante. 409 si le couple désigne une
  dépense mise à la corbeille, 415 format refusé, 402 plus de crédit.
- POST /expenses/{id}/justificatifs (expenses:write) -> multipart/form-data
  { fichier } : joint une pièce (PDF, PDF Factur-X ou XML CII / UBL, 10 Mo au
  plus) à une dépense existante et la lit (Factur-X, UBL, CII sans crédit ;
  PDF simple : 1 crédit). HT et TVA lus recopiés seulement si le HT de la
  dépense est encore à 0 (ligne du relevé) ; une saisie n'est jamais
  écrasée. Réponse { expense, justificatif_ajoute, lecture: "structuree" |
  "lue" | "illisible" | "deja_la", montants_recopies }. Idempotence :
  empreinte SHA-256 du fichier, même fichier = 200 sans seconde pièce ; 201 =
  pièce jointe. 404 dépense introuvable ou à la corbeille, 402 plus de
  crédit (rien n'est joint), 415 format refusé.
- POST /expenses/{id}/rapprochement (expenses:reconcile) -> Body { source:
  "qonto" | "wise" | "autre", operation_id, montant, date (AAAA-MM-JJ),
  devise? } : la dépense passe payée à cette date et sort de « à
  vérifier » ; aucun montant ne change, sauf une facture en devise aux euros
  estimés : une opération avec devise EUR donne le montant réel. Même opération = même réponse
  (idempotent) ; dépense déjà rapprochée d'une autre opération = 409.
- DELETE /expenses/{id}/rapprochement (expenses:reconcile) -> annule le lien
  posé par l'API et remet l'état d'avant (404 s'il n'y en a pas ; un lien
  du relevé bancaire relié à VoxFactura ne s'annule pas ici).

## Webhooks sortants

L'artisan déclare une adresse https (port 443) dans son espace (page des
clés API) et choisit les événements : invoice.issued, invoice.paid,
expense.created. Un cabinet comptable déclare la sienne pour tous ses
dossiers (le corps porte alors aussi `dossier`). Corps JSON { event, id,
numero, date, montants, lien_api }. En-têtes :
- `X-VoxFactura-Timestamp` : horodatage Unix (secondes) de l'envoi ;
- `X-VoxFactura-Livraison` : identifiant unique de la livraison, identique à
  chaque reprise (écarter les doublons avec) ;
- `X-VoxFactura-Signature` : HMAC-SHA256 hexadécimal, avec le secret du
  webhook (montré une fois à la création), de la chaîne
  `t=<horodatage>.<corps reçu tel quel>` ;
- `X-VoxFactura-Event` : l'événement.
Vérification côté destinataire : refuser un horodatage à plus de 5 minutes
de l'horloge locale, puis comparer la signature en temps constant :

    import hashlib, hmac, time
    def verifier(corps: bytes, entetes, secret: str) -> bool:
        t = int(entetes["X-VoxFactura-Timestamp"])
        if abs(time.time() - t) > 300:
            return False
        attendu = hmac.new(secret.encode(), f"t={t}.".encode() + corps,
                           hashlib.sha256).hexdigest()
        return hmac.compare_digest(attendu, entetes["X-VoxFactura-Signature"])

Trois reprises en cas d'échec ; 20 échecs de suite coupent le webhook.

## Calculs utiles

- Marge d'un client : lire GET /clients/{id}/margin, ne pas la recalculer.
  Facturé HT = factures émises, avoirs en moins, acompte compté une seule
  fois ; dépenses = celles rattachées au client, à ses devis, factures ou
  regroupements ; la somme des travaux et de sans_document égale le total.
- Ancien : marge d'un chantier = ca_ht de /chantiers/{id}/revenue (factures émises,
  avoirs déduits, acompte compté une seule fois) − somme des montant_ht de
  /expenses?chantier_id. Ne pas additionner /invoices : l'acompte y figure
  aussi dans le total de la facture de solde.

## Bonnes pratiques

- Paginer avec limit/offset jusqu'à atteindre `total`.
- Synchroniser : garder l'heure du dernier passage et relire avec
  `modifie_depuis` (l'ordre reste celui de la liste, pas celui des
  modifications). Frein des clés de compte : 600 requêtes par heure (429
  avec Retry-After).
- Ne jamais supposer qu'une écriture envoie au client : l'envoi est manuel.
- En cas de 401 : clé invalide/révoquée. En cas de 403 : scope manquant (ou
  écriture avec une clé cabinet). Clé cabinet : 400 = dossier manquant,
  404 = dossier introuvable ou accès retiré.
