Aller au contenu

ADR-0001 — Introduction de TaxAuthorityAdapter comme abstraction multi-juridiction de FISCAL

Date : 2026-05-07 Catégorie : A (décidée en autonomie par le lead architecte dans le cadre de la roadmap FISCAL validée) Décideur : Lead architecte DYNORS Statut : Acceptée Sprint d'implémentation : Sprint 1 phase 1 FISCAL (M+0)


Contexte

FISCAL doit aujourd'hui parler à plusieurs autorités fiscales pour certifier ou enregistrer des factures et documents fiscaux. À ce jour, deux intégrations coexistent comme services ad hoc côte à côte :

  • DgiClient — Sénégal, avec trois modes (STUB, SANDBOX, PROD), portant la vérification DFE/QR publique et la certification en mode DECLARATION_ONLY.
  • ChorusProIntegrationService — France, dépôt de factures B2G dans le système Chorus Pro.

La roadmap 12 mois (voir fiscal-roadmap-12-mois.md) vise l'ajout immédiat de deux nouvelles juridictions : Côte d'Ivoire (FNE — clearance temps réel) et Nigeria (FIRS — clearance temps réel anglophone NGN). Sans une abstraction commune, chaque nouvelle juridiction est une intégration ad hoc supplémentaire, ce qui crée :

  • Duplication de la logique de résilience (timeout, retry, circuit breaker, idempotence) dans chaque service.
  • Difficulté à appliquer une politique de retry homogène (outbox, retry exponentiel, DLQ).
  • Code métier FISCAL qui doit savoir quelle autorité appeler en fonction du pays, avec des branchements if (country == "SN") ... qui ne scalent pas.
  • Impossibilité de déclarer au runtime les capabilities d'une autorité (sync vs async, fenêtre offline, pré-allocation, déclaration uniquement).

L'abstraction est nécessaire maintenant parce que la décision stratégique de viser SN + CI + NG en première vague est actée.

Décision

Introduire l'interface TaxAuthorityAdapter dans com.dynors.fiscal.domain.port.authority comme abstraction obligatoire pour toute intégration d'autorité fiscale, présente ou future. Migrer DgiClientDgiSenegalAdapter et ChorusProIntegrationServiceChorusProAdapter derrière cette interface. Résoudre les adapters au runtime via un TaxAuthorityRegistry Spring indexé par JurisdictionCode.

Interface

package com.dynors.fiscal.domain.port.authority;

public interface TaxAuthorityAdapter {

    /** Code juridiction ISO 3166-1 alpha-2 (SN, CI, BJ, TG, ML, BF, NE, NG, GH, FR…). */
    JurisdictionCode jurisdiction();

    /** Capabilities déclarées : ce que cette autorité supporte / exige. */
    Capabilities capabilities();

    /**
     * Certifie la facture auprès de l'autorité. Idempotent.
     * Lève AuthorityUnavailableException si l'autorité est indisponible
     * (le moteur FISCAL bascule alors en mode déféré via outbox).
     */
    CertificationResult certify(Invoice invoice, IdempotencyKey idempotencyKey)
            throws AuthorityUnavailableException;

    /** Certification d'un avoir (Credit Note). Délègue par défaut à certify. */
    default CertificationResult certifyCreditNote(CreditNote cn, IdempotencyKey key)
            throws AuthorityUnavailableException {
        return certify(cn.toInvoiceLike(), key);
    }

    /** État de santé pour circuit breaker — ne propage pas d'exception. */
    HealthStatus health();

    /** Construit le bundle de déclaration périodique (consommé par BOOKS). */
    DeclarationBundle buildPeriodic(FiscalPeriod period, List<Invoice> issued);

    /** Vérification publique d'un DFE/QR/numéro fiscal — flux non authentifié. */
    PublicVerificationResult verifyPublic(String fiscalReference);
}

Capabilities

public record Capabilities(
    ClearanceMode clearanceMode,         // SYNC_REQUIRED | DECLARATION_ONLY | HYBRID
    Duration maxOfflineToleranceWindow,  // null si DECLARATION_ONLY
    boolean preAllocatedNumbersSupported,
    DeclarationCadence declarationCadence,  // MONTHLY | QUARTERLY | NONE
    int legalArchivalYears,
    boolean softwareCertificationRequired,
    Set<TaxKind> taxKindsSupported,
    Currency primaryCurrency,
    Locale primaryLocale
) {}

Profils initiaux

Adapter clearanceMode declarationCadence archival Devise / Locale
DgiSenegalAdapter DECLARATION_ONLY MONTHLY 10 ans XOF / fr-SN
ChorusProAdapter SYNC_REQUIRED (B2G) NONE 10 ans EUR / fr-FR
DgiCoteIvoireAdapter (à venir Sprint 10) SYNC_REQUIRED MONTHLY 10 ans XOF / fr-CI
FirsNigeriaAdapter (à venir Sprint 12) SYNC_REQUIRED MONTHLY 7 ans NGN / en-NG
GenericFallbackAdapter DECLARATION_ONLY NONE configurable configurable

Conséquences

Bénéfices

  • Le moteur FISCAL devient agnostique de la juridiction : il lit les Capabilities au runtime et applique la politique de résilience appropriée (synchrone strict / async outbox / fallback offline).
  • L'ajout d'un nouveau pays se réduit à implémenter une nouvelle classe XxxAdapter + une migration des TaxRule du pays dans le registry. Aucune modification du code métier FISCAL n'est requise.
  • Toute la mécanique de retry (outbox, circuit breaker, DLQ) est mutualisée dans le moteur, pas dupliquée par adapter.
  • Les tests d'intégration deviennent plus robustes : on peut tester le moteur avec un FakeAuthorityAdapter sans dépendre des sandboxes externes.

Coûts / contraintes induites

  • Sprint 1 dédié au refactoring : zéro nouvelle fonctionnalité livrée, mais l'investissement est non-négociable pour les phases suivantes.
  • Backward compatibility : les services existants qui injectent directement DgiClient ou ChorusProIntegrationService doivent passer par le registry. La compilation forcera la mise à jour ; les tests E2E doivent prouver zéro régression sur le périmètre Sénégal et France actuel.
  • Discipline de revue : tout futur PR qui ajoute un client HTTP vers une autorité fiscale hors TaxAuthorityAdapter doit être refusé en revue (règle Cursor .cursor/rules/fiscal-transformation-2026-priorite.mdc §2.1).

Composants impactés

  • dynors-internal/applications/fiscal/backend — nouveaux packages, migration des deux services existants.
  • Tests d'intégration FISCAL — à compléter avec un FakeAuthorityAdapter réutilisable.
  • Documentation : roadmap, architecture cible, cette ADR, mise à jour du README backend FISCAL.

Alternatives considérées

Alternative A — Statu quo (continuer en ad hoc par juridiction). Écartée : ne scale pas au-delà de 2-3 pays, duplication de la résilience, code métier pollué.

Alternative B — Externaliser la certification à un service tiers (Sovos, Fonoa, équivalents). Écartée : (1) coût récurrent par facture significativement plus élevé que notre cible « Stripe of West Africa », (2) dépendance critique sur un fournisseur étranger pour notre cœur produit, (3) perte de l'avantage compétitif d'agrément éditeur direct par pays UEMOA.

Alternative C — Adapter pattern mais sans Capabilities (juste signature certify). Écartée : oblige le moteur FISCAL à connaître les contraintes de chaque autorité hors interface — perd l'avantage clé de pouvoir décider la politique de résilience au runtime.

Plan d'exécution

  • Sprint 1 (M+0 → M+0,5) : créer l'interface, les records et exceptions, le TaxAuthorityRegistry, les tests unitaires. Pas de migration encore.
  • Sprint 2 (M+0,5 → M+1) : migrer DgiClientDgiSenegalAdapter, migrer ChorusProIntegrationServiceChorusProAdapter. Adapter FiscalInvoiceService pour appeler le registry. Tests E2E zéro régression.
  • Sprint 3 (M+1 → M+1,5) : étendre InvoiceStatus avec PENDING_CERTIFICATION, CERTIFIED, CERTIFICATION_DEFERRED. Introduire la table outbox + worker + circuit breaker. Events fiscal.invoice.certified, fiscal.invoice.certification.deferred au catalogue.
  • Sprints 10-13 (M+5 → M+8) : implémenter DgiCoteIvoireAdapter et FirsNigeriaAdapter sans modification du moteur FISCAL.

Suivi

Critère de succès : à la fin du sprint 2, aucune ligne de code métier FISCAL ne référence directement DgiClient ou ChorusProIntegrationService ; tout passe par TaxAuthorityRegistry.get(JurisdictionCode).

Critère de mesure long terme : à l'introduction de la 3ème juridiction (Côte d'Ivoire ou Nigeria), zéro modification de code dans FiscalInvoiceService, InvoiceNumberingService, ComplianceValidationService. Si modification nécessaire, c'est un signal de défaut de l'abstraction et l'ADR doit être révisée.

Références