Facturation

Modules billing et einvoicing — modèles économiques, cycle de vie financier, facturation électronique et runbook opérationnel.

Facturation

Les modules billing (app/mypromo/server/modules/billing/) et einvoicing (app/mypromo/server/modules/einvoicing/) couvrent respectivement le cycle financier des réservations et la transmission électronique des factures vers les opérateurs de dématérialisation partenaires (ODP).


Modèles économiques

Le module billing supporte deux modèles, pilotés par le champ billingModel d'une réservation :

ValeurDescription
legacy_mandateModèle mandat d'encaissement (historique) — paiement en deux temps : arrhes à la réservation, solde en fin de prestation
principal_sellerModèle vendeur principal (cible) — paiement 100 % à la réservation, facture unique plateforme → industriel

La fonction resolveBillingModel dans billing/domain/billing-model-rules.ts retourne legacy_mandate par défaut si la valeur est absente ou inconnue. Les nouvelles réservations utilisent principal_seller.

Reprise de données — les réservations créées avant la Phase 1 du chantier facturation (2026-07) restent stockées avec pricingCommissionAmount = 0 et billingModel = legacy_mandate par défaut. Un script de reprise (hors périmètre Phase 1, cf. risque R5 du plan docs/ai/tasks/2026-07-05-facturation-...) devra être exécuté avant la mise en production pour aligner l'historique sur principal_seller. Ne pas lancer la collecte en principal_seller sur ces réservations sans reprise préalable — la commission serait à 0 %.

Modèle legacy — mandat d'encaissement

Dans ce modèle :

  • La plateforme agit comme mandataire d'encaissement pour le compte du magasin.
  • Le magasin est le vendeur officiel de la prestation ; l'industriel ne reçoit jamais de facture de la plateforme.
  • Le paiement se déroule en deux étapes : arrhes (dépôt) à la réservation, solde en fin de prestation.

Structure économique (exemple avec loyer HT magasin = 10 000 €, commission 20 %) :

FluxMontant HT
Arrhes magasin (ex. 30 % du loyer)3 000 €
Commission plateforme (intégrée aux arrhes)2 000 €
Total arrhes perçues5 000 €
Solde fin de prestation7 000 €
Total facturé industriel12 000 €
Reversement net magasin (après commission)10 000 €

Les montants sont exprimés en centimes (amountCents) dans toutes les interfaces internes. La conversion entier → centimes applique Math.round(value * 100).

Types de facture legacy (BillingInvoiceKind) :

KindÉmetteur → DestinataireUsage
store_depositMagasin → IndustrielFacture d'arrhes émise par mandat
store_balanceMagasin → IndustrielFacture de solde en fin de prestation
platform_commissionPlateforme → MagasinCommission plateforme prélevée sur flux magasin

Ces types restent lisibles pour l'historique ; les nouvelles réservations n'en génèrent plus.

Modèle vendeur principal

Dans ce modèle :

  • La plateforme est vendeur officiel : elle facture l'industriel en son nom propre.
  • Paiement 100 % à la réservation (statut reservation_total_duereservation_total_paid).
  • La plateforme émet une auto-facture fournisseur (self-billing) pour la part magasin.

Structure économique :

industrialTotalAmount = storeBaseAmount + platformCommissionAmount

Les taux sont exprimés en points de base (bps, 1 bps = 0,01 %). À titre d'exemple, une commission de par exemple 2 000 bps (20 %) correspond à 20 % du montant magasin. Le taux réel est appliqué à la création de la réservation via readPlatformCommissionRateBps() (billing/domain/commission-config.ts), qui lit la variable d'environnement globale PLATFORM_COMMISSION_RATE_BPS — cf. section « Variables d'environnement » ci-dessous. La validation de cohérence est assurée par computeReservationFinancialBreakdown dans billing/domain/financial-rules.ts.

À la création (campaigns/application/reservations/reservation-service.ts) et à l'acceptation d'une contre-proposition (counter-proposal-service.ts), les réservations sont désormais persistées avec billingModel = principal_seller et une commission calculée sur le loyer magasin HT (buildReservationPricingFromCartItem / buildReservationPricingFromBaseEuros). L'invariant grossAmount = commissionAmount + netDistributorAmount est vérifié par assertReservationInvariants avant écriture.

Types de facture cible (BillingInvoiceKind) :

KindÉmetteur → DestinataireUsage
platform_salePlateforme → IndustrielFacture de vente principale
platform_credit_notePlateforme → IndustrielAvoir sur facture de vente
supplier_self_billedMagasin → PlateformeAuto-facture fournisseur (self-billing)
supplier_credit_noteMagasin → PlateformeAvoir sur auto-facture fournisseur

Cycle de vie financier

Statuts financiers (BillingFinancialStatus)

Définis dans billing/domain/financial-status-rules.ts :

draft
  ├── deposit_due ──→ deposit_paid ──→ balance_due ──→ balance_paid   (legacy)
  ├── reservation_total_due ──→ reservation_total_paid                 (principal_seller)
  │     ├── partially_refunded ──→ fully_refunded
  │     ├── supplier_settlement_pending ──→ supplier_settlement_paid
  │     └── written_off
  └── cancelled

Note — chemin nominal uniquement. L'arbre ci-dessus représente le chemin heureux. En réalité, cancelled et written_off sont accessibles depuis la plupart des statuts : voir la map BILLING_FINANCIAL_TRANSITIONS dans financial-status-rules.ts pour les transitions exactes autorisées.

Par ailleurs, le passage de draft à reservation_total_due n'est pas une transition formelle au sens de BILLING_FINANCIAL_TRANSITIONS : il s'effectue via un upsert direct dans reservation-total-collection.ts, qui écrit le statut reservation_total_due sans passer par le mécanisme de transition.

Valeurs exhaustives : draft · deposit_due · deposit_paid · balance_due · balance_paid · reservation_total_due · reservation_total_paid · partially_refunded · fully_refunded · supplier_settlement_pending · supplier_settlement_paid · cancelled · written_off.

Statuts de paiement (BillingPaymentStatus)

Définis dans billing/domain/payment-order-rules.ts :

pending ──→ submitted ──→ processing ──→ paid
                └──→ failed
                └──→ cancelled

Types d'ordre de paiement (BillingPaymentKind)

deposit · balance · reservation_total · refund · supplier_payout.

Méthodes de paiement (BillingPaymentMethod)

sepa_dd · bank_transfer.

Contrainte : sepa_dd requiert un mandateRef valide (mandat SEPA actif pour l'organisation).

Statuts de facture (BillingInvoiceStatus)

issued · partially_paid · paid · cancelled · credited.


Flux de collecte — modèle vendeur principal

L'endpoint POST /api/billing/reservations/{reservationId}/collect déclenche la collecte complète :

  1. Vérification que la réservation est en statut approved ou completed.
  2. Vérification que le modèle est principal_seller.
  3. Résolution du mandat SEPA actif (si paymentMethod = sepa_dd).
  4. Création de l'ordre de paiement (kind = reservation_total, status = pending → paid).
  5. Transition financière reservation_total_due → reservation_total_paid.
  6. Émission de la facture platform_sale.
  7. Écriture des entrées de grand livre (trois lignes : débit industriel, crédit revenus plateforme, crédit fournisseur payable).

Le header Idempotency-Key prévient les doubles collectes : si une clé est connue, la réponse retourne le résultat existant sans rejouer le flux.

Auto-collecte à l'approbation (Phase 7 — D2 révisé)

Depuis la Phase 7, la collecte est automatiquement déclenchée à chaque passage de la réservation à approved — politique « on facture lorsque l'industriel réserve ». Deux points de transition sont câblés (les seuls existants) :

  1. reviewReservationAsDistributor (campaigns/application/reservations/distributor-review-service.ts) — validation distributeur avec status = 'approved'.
  2. respondToCounterProposalAsIndustrial (.../counter-proposal-service.ts) — acceptation par l'industriel d'une contre-proposition (decision = 'accept').

Le trigger unique triggerAutoCollectOnReservationApprovalNonBlocking (billing/application/reservation-approval-collect-trigger.ts) :

  • Fait tourner collectReservationTotalForReservation en void-promesse, JAMAIS bloquant : une erreur de collecte n'annule PAS l'approbation. L'état financier reste reservation_total_due et la relance passe par l'endpoint POST .../collect existant.
  • Choix de méthode : sepa_dd si un mandat SEPA actif existe pour l'industriel, sinon bank_transfer (aucune tentative de collecte SEPA sans mandat).
  • Idempotency-Key déterministe : auto-collect-<reservationId> — un second passage à approved (rare) ne double-collecte pas.
  • Skip en dehors de principal_seller — les réservations legacy conservent leur flux manuel.

Télémétrie :

ScopeÉmission
billing.reservation.collect.auto_triggeredTrigger appelé (avec paymentMethod choisi)
billing.reservation.collect.auto_succeededCollecte automatique OK
billing.reservation.collect.auto_failedCollecte automatique KO (code + errorMessage)

Annulations et avoirs

  • POST /api/billing/invoices/{invoiceId}/credit-note — émet un avoir sur une facture existante (permission billing.manage.all).
  • POST /api/billing/invoices/{invoiceId}/refund — déclenche un remboursement partiel ou total (permission billing.manage.all). Accepte amountCents, reason, paymentMethod, Idempotency-Key.

Seules les factures de kind platform_sale sont remboursables via ce flux. Un avoir parallèle sur la dette fournisseur doit être géré séparément.


API — exemples représentatifs

Authentification — la plateforme utilise des sessions par cookie (Better Auth). La session est établie à la connexion et transmise automatiquement via le cookie better-auth.session_token. Dans les exemples curl, utilisez -b 'better-auth.session_token=<jeton-de-session>'.

Résumé financier d'une réservation

Accessible aux membres de l'organisation (rôles manager, member, billing).

GET /api/billing/reservations/{reservationId}/summary
Cookie: better-auth.session_token=<jeton-de-session>

Collecter le total de réservation (modèle vendeur principal)

Requiert KYB approuvé (billing.collect), rôles manager ou billing.

POST /api/billing/reservations/{reservationId}/collect
Content-Type: application/json
Idempotency-Key: res-collect-<reservationId>
Cookie: better-auth.session_token=<jeton-de-session>

{
  "paymentMethod": "sepa_dd"
}

Réponse en cas de succès :

{
  "reservationId": "...",
  "paymentOrderId": "po_tot_...",
  "invoiceNumber": "FAC-RES-...",
  "financialStatus": "reservation_total_paid",
  "paymentStatus": "paid",
  "idempotent": false
}

Lister les factures de l'organisation connectée

GET /api/billing/invoices?limit=50&kind=platform_sale&kind=platform_credit_note
Cookie: better-auth.session_token=<jeton-de-session>

Query parameters :

ParamTypeDescription
limitnumberNombre max d'items retournés (défaut 50, max 200).
kindstring[]Filtre par BillingInvoiceKind (répétable). Les valeurs inconnues sont ignorées ; laisser vide pour toutes. L'espace industriel envoie platform_sale + platform_credit_note, le distributeur supplier_self_billed + supplier_credit_note.

Réponse — pour chaque item : invoiceNumber, kind, status, amountExclTaxCents, vatAmountCents, vatRatePercent, amountInclTaxCents (recalculés serveur via computeVatBreakdownCents + readPlatformVatRatePercent() — la TVA n'étant pas persistée en base, on la calcule à la volée pour rester cohérent avec le PDF Factur-X et le payload Agicap), issuedAt, dueAt, hasPdf (booléen : indique si le PDF Factur-X est disponible ; jamais la clé S3 elle-même).

Émettre un avoir (admin)

POST /api/billing/invoices/{invoiceId}/credit-note
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "reason": "Annulation exceptionnelle — accord commercial"
}

Déclencher un remboursement (admin)

POST /api/billing/invoices/{invoiceId}/refund
Content-Type: application/json
Idempotency-Key: refund-<invoiceId>-<timestamp>
Cookie: better-auth.session_token=<jeton-de-session>

{
  "amountCents": 50000,
  "reason": "Remboursement partiel convenu",
  "paymentMethod": "bank_transfer"
}

Collecte legacy (dépôt / solde)

Les endpoints suivants restent opérationnels pour les réservations historiques en legacy_mandate :

POST /api/billing/reservations/{reservationId}/deposit/collect
POST /api/billing/reservations/{reservationId}/balance/collect

Ces routes sont vouées à être dépréciées une fois l'historique épuisé. Elles ne doivent pas être utilisées pour les nouvelles réservations.


Module einvoicing — facturation électronique

Rôle et architecture

Le module einvoicing orchestre la transmission des factures légalement émises par la plateforme vers l'opérateur de dématérialisation partenaire (Agicap). Il est découplé du module billing : il consomme les événements d'émission de factures via un orchestrateur interne et maintient un état de transmission indépendant.

Chemin : app/mypromo/server/modules/einvoicing/.

Intégration Agicap (Phase 3)

Le provider Agicap est branché sur la vraie API E-Invoicing v1 :

  • Authentification OAuth2 client_credentials — endpoint AGICAP_TOKEN_URL (défaut https://myaccount.agicap.com/connect/token), scope agicap:public-api. Le token est mis en cache mémoire par createAgicapTokenProvider (infrastructure/agicap-oauth-client.ts), rafraîchi ~60 s avant expiration. Le secret n'est jamais journalisé (contrat testé).
  • Header X-FlowModeSandbox ou Production, valeur pilotée par AGICAP_FLOW_MODE, envoyée sur chaque requête.
  • Soumission Factur-XPOST {AGICAP_BASE_URL}/public/einvoicing/v1/flows en multipart/form-data à deux parts :
    1. file : le PDF Factur-X (Blob application/pdf).
    2. flowInfo : string JSON simple {"entityId": <AGICAP_ENTITY_ID>, "flowSyntax": "Factur-X"}. Piège documenté par myIT : un Blob avec filename sur flowInfo est traité par le binding ASP.NET comme un upload et déclenche un HTTP 400 flowInfo field is required. Le client (infrastructure/agicap-http-client.ts) le force en string.
  • StatutPOST {AGICAP_BASE_URL}/public/einvoicing/v1/flows/search, body { where: { flowMode, flowId }, limit, offset }. Il n'y a plus de webhook fiable : la progression Pending → Ok/Error/Archived est captée uniquement par polling.
  • Retries — 2 essais supplémentaires sur 429 / 503 (backoff simple, 500 ms × attempt) intégrés au client HTTP.
  • Mapping Agicap ↔ machine à états myPromo :
    Ack AgicapStatut provider (EinvoiceProviderStatus)Statut transmission (EinvoiceTransmissionStatus) via syncEinvoiceTransmissionStatus
    Pendingprocessingsent (aucune transition destructive tant qu'Agicap n'a pas tranché)
    Okacceptedsent → acknowledged (terminal)
    Archivedacceptedsent → acknowledged (terminal)
    Errorrejectedsent → rejected (terminal)
    (inconnu)processingreste sent (on repasse au prochain polling)
  • Prérequis PDF — la soumission requiert un PDF Factur-X pointé par EinvoiceTransmission.documentPdfKey. Sans PDF, le dispatch fail en EINVOICE_MISSING_PDF. Le champ est alimenté automatiquement par le hook post-facture billing (Phase 4 — FIN-100, cf. section « Flux billing → einvoicing » ci-dessous). En E2E_TEST_MODE=1, le client HTTP est stubé (aucun appel réseau) et le dispatch tolère un documentPdfKey absent.
  • Webhook dépréciéPOST /api/webhooks/agicap répond désormais 410 Gone avec { ok: false, error: { code: 'WEBHOOK_DEPRECATED', ... } } et journalise l'appelant (compat historique).
  • Polling — activé si EINVOICING_POLLING_ENABLED=true. Le plugin Nitro server/plugins/25-einvoicing-polling.ts démarre une boucle setInterval (défaut 15 min) qui charge jusqu'à 50 transmissions en sent/dispatching et appelle syncEinvoiceTransmissionStatus sur chacune. Une erreur isolée ne casse pas le lot ; arrêt propre au hook close de Nitro.

Types de flux (EinvoiceFlowType)

La politique de routage est définie dans einvoicing/domain/einvoice-routing-policy.ts :

FlowCondition
fr_domestic_b2bÉmetteur FR, destinataire FR — transmission structurée obligatoire
eu_intra_b2bÉmetteur FR, destinataire UE — e-reporting
export_b2bÉmetteur FR, destinataire hors UE — e-reporting

Seul le flux fr_domestic_b2b déclenche une transmission structurée ; les autres génèrent un e-reporting. Seul l'émetteur FR est supporté à ce stade.

Types de documents (EinvoiceDocumentKind)

invoice · credit_note.

Cycle de vie d'une transmission (EinvoiceTransmissionStatus)

Défini dans einvoicing/domain/einvoice-status-rules.ts :

pending_dispatch ──→ dispatching ──→ sent ──→ acknowledged   (terminal)
                                        └──→ rejected         (terminal)
                          └──→ failed ──→ dispatching         (retry)
                                    └──→ cancelled            (terminal)

Statuts terminaux : acknowledged · rejected · cancelled.

Seul le statut failed autorise un retry (transition vers dispatching).

Depuis la Phase 3 (Agicap réel) :

  • Le dispatch Agicap ne renvoie qu'un flowId immédiat — la transmission passe en sent (providerStatus=processing, correspondant à l'ack Agicap Pending).
  • Le polling /flows/search fait ensuite avancer la transmission vers acknowledged (Ok/Archived) ou rejected (Error).
  • Un statut Agicap inconnu ne provoque aucune transition destructive : la transmission reste sent et sera reprise au prochain polling.

Flux billing → einvoicing (Phase 4 — FIN-100 résolu)

Depuis la Phase 4, chaque facture billing émise déclenche automatiquement la création d'une transmission Agicap (draft pending_dispatch) — le GAP historique « billing n'appelle jamais createEinvoiceTransmissionDraft » (FIN-100) est refermé.

Hook post-facture non-bloquant

Un hook unique runPostInvoiceHooksNonBlocking(invoiceNumber) (billing/application/post-invoice-hook.ts) est déclenché en void-promesse après émission d'une facture par :

  • reservation-total-collection.ts — facture platform_sale (FAC-RES-*).
  • credit-notes.ts — avoir (kind mappé via mapSourceKindToCreditNoteKind : platform_sale → platform_credit_note, supplier_self_billed → supplier_credit_note).
  • supplier-settlement.ts — autofacture supplier_self_billed (FAC-SUP-*).

Le hook enchaîne :

  1. Génération PDF Factur-X + stockage S3 + persistance BillingInvoice.pdfStorageKey (Phase 2 — generateAndStoreInvoicePdfNonBlocking).
  2. Résolution du kind einvoicing via mapBillingKindToEinvoiceKind (platform_sale/supplier_self_billed → invoice, *_credit_note → credit_note, mapper pur domaine, cf. billing/domain/billing-einvoicing-mapper.ts).
  3. Résolution du pays acheteur (par défaut FR — PLATFORM_COUNTRY_CODE, dérivé de OrganizationBillingProfile.billingAddressCountry puis Organization.legalAddressCountry).
  4. Calcul TTC via computeVatBreakdownCents (HT stocké + taux PLATFORM_VAT_RATE_PERCENT).
  5. Appel createEinvoiceTransmissionDraft(...) (via server/modules/einvoicing/index.ts uniquement — respect strict de la règle de frontière modules) avec documentPdfKey = pdfStorageKey.

Ordonnancement PDF → transmission

La transmission a besoin du documentPdfKey, or le PDF est généré en asynchrone non-bloquant. La création de la transmission est donc enchaînée APRÈS le stockage du PDF, dans le même chemin asynchrone que le hook PDF. Conséquences :

  • PDF ok → transmission créée (pending_dispatch, documentPdfKey renseigné). Télémétrie : billing.einvoicing.transmission_draft_created.
  • PDF échoué → transmission skippée (journalisé sur billing.einvoicing.transmission_draft_skipped, sans tentative de création qui échouerait au dispatch). Le trou est détecté par la réconciliation admin (missing_transmission) — c'est le filet de sécurité voulu, rattrapage via retry manuel possible.
  • einvoicing throw → journalisé sur billing.einvoicing.transmission_draft_failed, le flux principal (collecte / avoir / autofacture) n'est jamais annulé.

Gating self-billed → Phase 5

Le hook est structurellement prêt pour la Phase 5 (transmission Agicap des autofactures supplier_self_billed / supplier_credit_note). En Phase 4, la branche self-billed est explicitement skippée (log billing.einvoicing.transmission_draft_skippedreason: 'self-billed transmission gated to Phase 5') le temps que le flag EINVOICING_SELF_BILLING_ENABLED + le mandat d'autofacturation Yousign soient livrés (D4 + D3-c du plan).

Idempotence

L'idempotence est portée par createEinvoiceTransmissionDraft : la clé par défaut ${invoiceId}:${kind}:${flowType} (avec invoiceId = invoiceNumber côté billing) garantit qu'une deuxième invocation du hook pour la même facture renvoie la transmission existante avec idempotent: true, sans doublon en base.

Réconciliation — filtre kinds

getEinvoiceReconciliationSnapshot (einvoicing/application/reconciliation.ts) restreint désormais son WHERE Prisma à kind IN ('platform_sale', 'platform_credit_note') : les autofactures (transmission Phase 5) et les kinds legacy (store_*, platform_commission) sont exclus pour éviter les fausses discrepancies missing_transmission. La Phase 5 étendra ce filtre à supplier_self_billed / supplier_credit_note quand le flag EINVOICING_SELF_BILLING_ENABLED sera activé.

Autofacturation différée (Phase 7 — D2 révisé)

L'autofacturation est le mécanisme par lequel la plateforme émet, au nom et pour le compte du distributeur, la facture correspondant à la part magasin (supplier_self_billed, CII type_code 389).

Depuis la Phase 7 (D2 révisé), l'autofacture n'est plus émise à la collecte : elle est différée et déclenchée par un scheduler applicatif qui sélectionne les réservations dont endsAt + 3 jours ouvrés français est révolu, sans incident ouvert et avec un mandat d'autofacturation signé. Le déclenchement post-collecte de la Phase 5 a été retiré — le rattrapage admin ?force=true est conservé.

Vue d'ensemble

Réservation `principal_seller` payée
     │
     └── ... temps passe ...
                 │
                 ▼
     endsAt + 3 jours ouvrés FR révolu ?  (SELF_BILLING_SCHEDULER_INTERVAL_MS ~ 1 h)
                 │
                 ▼
     runSelfBillingSchedulerPass({limit})   (server/plugins/26-self-billing-scheduler.ts)
                 │
                 ├── SELECT réservations `principal_seller` en `reservation_total_paid`
                 │   sans autofacture `supplier_self_billed` et sans incident `open`
                 │
                 └── Pour chaque : tryIssueSelfBillingForReservation()
                        ├── hasOpenReservationIncident ? → skip `open_incident`
                        ├── hasSignedSelfBillingMandate ? → skip `no_mandate` sinon
                        └── issueSupplierSelfBilledInvoiceForReservation()
                                    │
                                    └── runPostInvoiceHooksNonBlocking → PDF Factur-X 389
                                             │
                                             └── EINVOICING_SELF_BILLING_ENABLED=true ?
                                                    → transmission Agicap 389
                                                    sinon → skip télémétrié

Le scheduler (billing/application/self-billing-scheduler.ts) journalise systématiquement les compteurs eligible / issued / skippedNoMandate / skippedOpenIncident / skippedNotDue / failed en fin de passe. Une erreur unitaire est capturée et comptabilisée en failed — jamais propagée.

Le helper tryIssueSelfBillingForReservation (billing/application/self-billing-auto-trigger.ts) porte la logique de gating et est réutilisable en test ou en rattrapage ciblé.

Mandat d'autofacturation Yousign (D3-c)

Le mandat matérialise l'accord préalable exigé par l'article 289 du CGI. Il est signé par le signataire légal du distributeur (OrganizationBillingProfile.signatory*) via Yousign, sur un PDF généré par la plateforme.

Cycle de vie (billing/domain/self-billing-mandate-rules.ts) :

draft ──▶ pending_signature ──▶ signed ──▶ revoked
                    └──▶ declined

Les statuts declined et revoked sont terminaux. La contrainte « un seul mandat actif par organisation » est garantie par un index partiel PostgreSQL (billing_self_billing_mandates_active_unique, statut IN (pending_signature, signed)) doublé d'une garde applicative renvoyant 409 avant tout appel Prisma.

Contenu du document (billing/application/self-billing-mandate-document.ts) :

  1. Identification des deux parties (mandataire plateforme via PLATFORM_LEGAL_* ; fournisseur distributeur via Organization + OrganizationBillingProfile).
  2. Objet du mandat (accord d'autofacturation, article 289 du CGI).
  3. Acceptation tacite / expresse — délai de contestation de 30 jours par facture.
  4. Durée et révocation — sans terme, révocable à tout moment par notification.
  5. Signature électronique Yousign — le signataire est le signataire légal du distributeur.

Endpoints :

POST   /api/billing/organizations/{organizationId}/self-billing-mandate   # Admin : initie la demande (409 si mandat actif existant)
GET    /api/billing/organizations/{organizationId}/self-billing-mandate   # Admin OU membre de l'organisation : statut courant
DELETE /api/billing/organizations/{organizationId}/self-billing-mandate   # Admin : révoque le mandat signé (409 si aucun)

La vue GET masque esignRequestRef et documentStorageKey pour les non-admins (uniquement les statuts et dates leur sont exposés). Le PDF de mandat est stocké dans le contexte S3 privé billing-self-billing-mandate (/billing/self-billing-mandates, 5 Mo max, application/pdf seul).

Webhook Yousign — le webhook existant POST /api/webhooks/yousign a été étendu pour fan-outer les événements vers le module billing après le traitement auth. Séquence :

  1. Vérification HMAC (auth verifyYousignWebhookSignature).
  2. Persistance de l'événement + match d'AuthSignatureRequest par processYousignWebhook.
  3. Extraction de l'identité (parseYousignWebhookIdentity — publiée dans le barrel auth).
  4. Fan-out billing : applyYousignEventToSelfBillingMandate({providerSignatureRequestId, eventName, declineReason}) — recherche par esignRequestRef, applique la transition. NE throw JAMAIS. La déduplication est portée par les transitions (signed → signed NO-OP ; transition invalide silencée).

Rattrapage admin manuel

L'endpoint existant POST /api/billing/reservations/{id}/supplier-self-billing reste disponible mais applique désormais deux gates :

  1. Mandat — sans mandat signé → 409 BILLING_MANDATE_MISSING.
  2. Incident (Phase 7) — si un incident open bloque la réservation → 409 CONFLICT.

L'admin peut forcer le passage via ?force=true. Chaque contournement est journalisé en WARN :

  • billing.supplier.self_billing.forced_without_mandate
  • billing.supplier.self_billing.forced_with_open_incident

Incidents de réservation (Phase 7)

Un incident matérialise un problème signalé par l'industriel de la réservation (affichage manquant, contenu non conforme…). Il bloque l'émission de l'autofacture distributeur tant qu'il n'est pas libéré par un admin. Modèle Prisma : BillingReservationIncident (billing_reservation_incidents, migration 20260706120000_add_billing_reservation_incidents).

Cycle de vie (billing/domain/reservation-incident-rules.ts) :

open ──▶ released   (terminal)

Un seul incident open par réservation à la fois — garanti par un index partiel PostgreSQL (billing_reservation_incidents_open_unique, WHERE status = 'open') doublé d'une garde applicative renvoyant 409 avant tout appel Prisma.

Fenêtre de déclaration :

  • Ouverture : startsAt de la réservation (début de la prestation).
  • Fermeture : endsAt + 3 jours ouvrés français, alignée sur l'échéance du scheduler d'autofacture. Calcul FR sans dépendance externe — algorithme de Butcher + fériés fixes/mobiles centralisés dans billing/domain/business-days.ts.

Endpoints :

POST /api/billing/reservations/{reservationId}/incident               # Industriel de la réservation
GET  /api/billing/reservations/{reservationId}/incident               # Industriel/distributeur/admin
POST /api/admin/billing/incidents/{incidentId}/release                # Admin (billing.manage.all)
GET  /api/admin/billing/incidents?status=open                         # Admin (liste paginée)
  • POST incident — RBAC : rôle industrial + membre actif de l'org industrielle de la réservation. Motif obligatoire (reason). 422 hors fenêtre, 409 si un incident open existe déjà.
  • GET incident — retourne { incident, eligibility }. eligibility.canDeclare (booléen) évite au client de recalculer les jours ouvrés. Raisons ok / not_industrial / not_started / window_closed / incident_open.
  • POST release — RBAC : billing.manage.all. Note de résolution (resolutionNote) obligatoire. Ne rejoue pas l'autofacture — le prochain passage du scheduler la détectera automatiquement.
  • GET admin list — statut par défaut open, ?status=released pour l'archive. Pagination limit / offset.

Télémétrie :

ScopeÉmission
billing.reservation.incident.declaredNouvel incident (par l'industriel)
billing.reservation.incident.releasedLibération par l'admin
billing.supplier.self_billing.skipped_open_incidentScheduler ou manuel — bloqué par un incident ouvert

Feature flag transmission Agicap (D4)

Le PDF Factur-X 389 est TOUJOURS généré à l'émission de l'autofacture — cela permet la mise à disposition du document au distributeur et au comptable sans dépendre d'Agicap. La transmission Agicap est pilotée par EINVOICING_SELF_BILLING_ENABLED (défaut false) :

  • Flag falsepost-invoice-hook skippe la création de transmission pour les kinds supplier_self_billed / supplier_credit_note, journalisée sur billing.einvoicing.transmission_draft_skipped.
  • Flag true et mandat signé côté distributeur émetteur → création de la transmission pending_dispatch avec documentPdfKey, télémétrie billing.supplier.self_billing.transmission_created en plus de billing.einvoicing.transmission_draft_created.
  • Flag true sans mandat signé → transmission skippée + télémétrie (même scope que ci-dessus).

Le filtre de réconciliation getEinvoiceReconciliationSnapshot étend son WHERE Prisma aux kinds supplier_self_billed / supplier_credit_note UNIQUEMENT quand EINVOICING_SELF_BILLING_ENABLED=true (évite les fausses discrepancies missing_transmission quand le flag est off).

Suivi Phase 4 — refunds → PDF/transmission de l'avoir

Le flux issueRefundForInvoice (billing/application/refunds.ts) branche désormais runPostInvoiceHooksNonBlocking sur l'avoir platform_credit_note créé — mêmes garanties non-bloquantes que les autres émissions.

Télémétrie Phase 5

ScopeÉmission
billing.self_billing_mandate.requestedNouvelle demande Yousign créée pour une organisation
billing.self_billing_mandate.signedÉvénement Yousign done → transition mandate signed
billing.self_billing_mandate.declinedÉvénement Yousign declined / expired / canceled / error → transition mandate declined
billing.self_billing_mandate.revokedRévocation admin
billing.self_billing_mandate.request_failedOrchestrator Yousign a levé une exception
billing.self_billing_mandate.webhook_ignoredÉvénement Yousign reçu sur mandat non pending_signature
billing.self_billing_mandate.webhook_apply_failedErreur pendant l'application du webhook (repository)
billing.supplier.self_billing.issuedAutofacture émise (auto ou manuelle)
billing.supplier.self_billing.skipped_no_mandateAuto-trigger skippé — pas de mandat signé
billing.supplier.self_billing.forced_without_mandateAdmin a forcé la manuel-émission sans mandat (?force=true)
billing.supplier.self_billing.trigger_failedErreur du trigger auto (non-bloquant)
billing.supplier.self_billing.transmission_createdTransmission Agicap 389 créée (flag on + mandat signé)

Administration (/admin/einvoicing)

Les routes admin sont accessibles uniquement avec la permission admin :

GET  /api/admin/einvoicing/transmissions
GET  /api/admin/einvoicing/reconciliation
POST /api/admin/einvoicing/transmissions/{transmissionId}/retry
POST /api/admin/einvoicing/transmissions/{transmissionId}/resync

Les seuils d'alerte de réconciliation (calculés côté serveur) :

IndicateurWarningCritical
discrepancy_rate≥ 5 %≥ 15 %
provider_failed_backlog≥ 5≥ 20
missing_transmission_backlog≥ 10≥ 30

Runbook — incidents e-invoicing

Diagnostic initial (10 premières minutes)

  1. Ouvrir /admin/einvoicing et noter : discrepancies totales, cartes d'alerte actives, top des failed avec lastErrorMessage.
  2. Vérifier la santé du provider : erreurs de dispatch sortant (timeout/auth), volume traité par la boucle de polling (scope de log einvoicing-polling), backlog en sent.
  3. Classer le type d'incident : indisponibilité provider, panne OAuth (token 401 persistant), boucle de polling arrêtée, transmission manquante.

Scénario A — Provider indisponible

Symptômes : croissance des transmissions failed, messages réseau/auth dans les logs de dispatch.

Actions :

  1. Geler les retries massifs jusqu'au rétablissement du provider.
  2. Tracer la fenêtre d'incident (heure de début, nombre de transmissions impactées).
  3. Une fois le provider stable, relancer des retries ciblés depuis /admin/einvoicing.
  4. Relancer la réconciliation et confirmer la décroissance du backlog.

Escalade : critique si provider_failed_backlog est en zone critique pendant plus de 15 minutes.

Scénario B — Polling arrêté ou 401 OAuth persistant

Symptômes : les transmissions restent en sent bien après leur soumission ; aucune ligne de log einvoicing-polling scope récente ; ou apparition répétée de EINVOICE_PROVIDER_FAILED avec un message « Agicap credentials are invalid or missing required permissions ».

Actions :

  1. Vérifier que EINVOICING_POLLING_ENABLED=true est bien positionné en production et que le plugin 25-einvoicing-polling a démarré (chercher polling loop started au boot).
  2. Vérifier AGICAP_CLIENT_ID, AGICAP_CLIENT_SECRET, AGICAP_TOKEN_URL, AGICAP_ENTITY_ID. En cas de rotation de secret, redémarrer l'instance (le cache token est en mémoire).
  3. Pour les transmissions bloquées entre-temps, utiliser l'action admin Resync status qui force un POST /flows/search immédiat.
  4. Confirmer la transition vers acknowledged/rejected sur les transmissions relancées.

Escalade : critique si le polling reste inactif > 30 min ou si le token OAuth échoue globalement après un déploiement ou une rotation.

Scénario C — Transmission manquante

Symptômes : discrepancy missing_transmission en réconciliation.

Actions :

  1. Vérifier que la facture existe dans billing et est éligible au flux e-invoicing.
  2. Confirmer que l'événement d'orchestration a été émis sans déduplication incorrecte.
  3. Re-déclencher la création de transmission via le chemin applicatif (jamais via patch direct en base).
  4. Valider que la transmission apparaît dans la liste admin et relancer la réconciliation.

Escalade : critique quand missing_transmission_backlog atteint le seuil critique.

Scénario D — Transmission rejetée par le provider

Symptômes : statut rejected avec raison de validation provider.

Actions :

  1. Collecter la raison de rejet et classifier (payload vs règle métier).
  2. Corriger les données sources dans le domaine billing si nécessaire.
  3. Ré-émettre le document corrigé via le flux normal.
  4. Conserver la trace de la rationale de correction dans l'audit trail.

Contrôles manuels disponibles

ActionStatuts source autorisés
Retryfailed · pending_dispatch
Resync statusTous sauf statuts terminaux

Après chaque action batch : rafraîchir les transmissions, rafraîchir la réconciliation, capturer les compteurs résultants.


Télémétrie et audit

Événements de télémétrie cibles :

  • billing.reservation.collect.requested · paid · failed
  • billing.supplier.self_billing.issued · adjusted
  • billing.supplier.payout.requested · paid · failed
  • billing.refund.requested · paid · failed

L'audit trail est obligatoire pour : remboursements, ajustements fournisseur, corrections manuelles de statuts.


Variables d'environnement

Les variables ci-dessous sont validées au boot par server/plugins/00-env-validation.ts (via server/utils/env.ts). Les secrets ne sont jamais journalisés : seul le statut « présent » ou « absent » apparaît dans les warnings et erreurs.

Facturation — commission plateforme

VariableRôleDéfautComportement
PLATFORM_COMMISSION_RATE_BPSTaux global de commission plateforme en points de base (1 bps = 0,01 %) appliqué en principal_seller.2000 (20 %)Absente → warning + fallback 2000 bps. Hors bornes [0, 10000] ou non-entier → erreur bloquante au boot.

E-invoicing — provider Agicap (émission Factur-X)

Ces variables sont exigées en production (sauf si E2E_TEST_MODE=1, où le stub prend le relais) et rétrogradées en warnings hors production pour permettre le développement local sans compte Agicap.

VariableRôleDéfaut / exemple
AGICAP_CLIENT_IDClient ID OAuth2 (client_credentials).secret — pas de défaut
AGICAP_CLIENT_SECRETClient secret OAuth2. Ne jamais logger.secret — pas de défaut
AGICAP_TOKEN_URLEndpoint OAuth2 (connect/token).https://myaccount.agicap.com/connect/token
AGICAP_BASE_URLBase URL de l'API Agicap (soumission de flux, recherche de statuts).https://api.agicap.com
AGICAP_ENTITY_IDIdentifiant de l'entité juridique côté Agicap.secret — pas de défaut
AGICAP_FLOW_MODESandbox ou Production. Détermine le header X-FlowMode.Sandbox
AGICAP_SELLER_APAccess point vendeur (adresse électronique vendeur configurée sur le compte Agicap).secret — pas de défaut
AGICAP_SELLER_AP_SCHEMESchéma de l'access point (BT-34). Ex. 0225 pour l'Île de France.0225

AGICAP_WEBHOOK_SECRET retiré (Phase 3). L'API Agicap réelle n'expose pas de webhook fiable — la variable et le code de vérification HMAC associé ont été supprimés. Toute présence résiduelle dans les secrets de déploiement peut être nettoyée.

E-invoicing — polling et feature flags

VariableRôleDéfaut applicatif
EINVOICING_POLLING_ENABLEDActive la boucle de polling /flows/search (Agicap n'expose pas de webhook fiable).false
EINVOICING_POLLING_INTERVAL_MSIntervalle de polling en millisecondes. Doit être un entier positif.900000 (15 min)
EINVOICING_SELF_BILLING_ENABLEDFeature flag : transmission Agicap des autofactures fournisseur (type_code 389). Le PDF est toujours généré ; ce flag pilote uniquement la transmission.false

Scheduler d'autofacturation différée (Phase 7)

VariableRôleDéfaut applicatif
SELF_BILLING_SCHEDULER_ENABLEDActive la boucle scheduler qui émet les autofactures distributeur endsAt + 3 jours ouvrés FR après la fin de la prestation. Voir 26-self-billing-scheduler.ts.false
SELF_BILLING_SCHEDULER_INTERVAL_MSIntervalle du scheduler en millisecondes. Doit être un entier positif. Utile en dev pour un premier passage rapide.3600000 (1 h)

Génération PDF — identité légale plateforme (Phase 2)

Ces variables portent l'identité juridique de Pulse my Promo (SAS). Elles alimentent :

  • l'émetteur des PDF platform_sale / platform_credit_note (CII type_code 380) ;
  • l'acheteur des autofactures supplier_self_billed / supplier_credit_note (CII type_code 389, émetteur = distributeur).

Sont validées au boot par server/utils/env.ts. Requises en production sauf E2E_TEST_MODE=1.

VariableRôleDéfaut applicatif
PLATFORM_LEGAL_COMPANY_NAMERaison sociale de Pulse my Promo (BT-27 / issuer.company.legalName).Placeholder lisible (dev)
PLATFORM_LEGAL_SIRETSIRET plateforme (14 chiffres). Sert de BT-30 vendeur et de BT-49 acheteur pour les autofactures.00000000000000 (dev)
PLATFORM_LEGAL_VAT_NUMBERN° TVA intracommunautaire (BT-31 / BT-48). Optionnel mais recommandé (catégorie TVA standard).undefined
PLATFORM_LEGAL_STREETRue / voie (BT-35).
PLATFORM_LEGAL_POSTAL_CODECode postal (BT-38).
PLATFORM_LEGAL_CITYVille (BT-37).
PLATFORM_LEGAL_COUNTRYCode pays ISO 3166-1 alpha-2 (BT-40).FR
PLATFORM_LEGAL_EMAILEmail de facturation.no-reply@pulsemypromo.local
PLATFORM_LEGAL_IBANIBAN affiché sur les factures plateforme (bloc paiement).undefined
PLATFORM_VAT_RATE_PERCENTTaux de TVA global appliqué à la génération (%). Cf. hypothèse HT ci-dessous.20 (Phase 2)

Génération PDF et Factur-X

À partir de la Phase 2, chaque facture billing supportée génère un PDF Factur-X (CII XML embarqué) dès sa création. Le PDF est stocké sur S3 privé et référencé par BillingInvoice.pdfStorageKey.

Hypothèse métier — TVA calculée à la génération

Les montants stockés sur les réservations et les factures (amountExclTaxCents) sont HT. La TVA n'est pas persistée en base : elle est calculée au moment de la génération du document (PDF + transmission) via readPlatformVatRatePercent() (variable PLATFORM_VAT_RATE_PERCENT, défaut 20 %). taxAmountCents reste à 0 sur BillingInvoice. Le hook post-facture (Phase 4) utilise la même formule pour peupler EinvoiceTransmission.amountInclTaxCents — cohérence garantie entre le PDF Factur-X et le payload transmis à Agicap. La persistance de la ventilation TVA en base est reportée (v-next) — le sujet n'est plus bloquant tant que le taux global reste stable.

Pipeline

  1. Chargement facture + organisation acheteuse (industriel) et/ou émettrice (distributeur pour autofacture) + OrganizationBillingProfile.
  2. Résolution du bundle (émetteur / acheteur / ligne / totaux) selon le kind :
    KindÉmetteurAcheteurCII type_code
    platform_salePlateforme (env PLATFORM_LEGAL_*)Organisation industrielle380
    platform_credit_notePlateformeOrganisation industrielle380
    supplier_self_billed (autofacture)Distributeur (Organization + OrganizationBillingProfile)Plateforme389
    supplier_credit_noteDistributeurPlateforme389
  3. Rendu du PDF humain via @aidalinfo/invoice-kit — template classic + branding orange myPromo (#F97316 / #C2410C) + tagline « Publicité en magasin — B2B ». Le template dédié pulse-mypromo fait l'objet de l'issue powerpackages Phase 2 (voir plus bas). En cas d'échec du template, fallback minimal via pdf-lib (Helvetica standard) — la conformité fiscale reste portée par le CII XML embarqué.
  4. Construction du CII XML via buildCiiXml (server/modules/einvoicing/infrastructure/cii-xml.ts) — profil Factur-X Basic EN 16931, mention obligatoire « Autofacturation » (subject code ABL) quand type_code = 389.
  5. Embarquement du XML dans le PDF via embedCiiXmlInPdf (server/modules/einvoicing/infrastructure/facturx-embed.ts, pdf-lib) — AFRelationship Alternative, XMP fx:ConformanceLevel=BASIC.

PDF/A-3 non strict — l'embed n'audite ni polices ni colorspace : risque accepté v1 (ingestion PDP Agicap sandbox suffisante). Consolidation prévue via @aidalinfo/facturx-builder (issue powerpackages ci-dessous).

Stockage S3 privé

  • Contexte de stockage : billing-invoice-pdf (racine /billing/invoices, bucket privé, max 5 Mo, application/pdf seul).
  • Clé S3 canonique : billing/invoices/{invoiceNumber}.pdf — écrit de manière idempotente (une même facture régénérée écrase l'objet précédent).
  • Persistée sur BillingInvoice.pdfStorageKey (nullable).

Endpoint download

GET /api/billing/invoices/{invoiceId}/download
Cookie: better-auth.session_token=<jeton-de-session>
  • Contrôle d'accès (strict) : permission billing.manage.all (admin) OU organisation active du demandeur = émetteur/client de la facture. Toute autre organisation → 403.
  • 404 code BILLING_PDF_NOT_READY si la génération PDF n'a pas encore produit de fichier (pdfStorageKey NULL).
  • Le PDF est streamé directement depuis S3 (Content-Disposition: attachment). L'URL S3 signée intermédiaire n'est jamais journalisée.

Génération non-bloquante

Depuis la Phase 4, la génération PDF est encapsulée dans un hook post-facture unique runPostInvoiceHooksNonBlocking(invoiceNumber) (billing/application/post-invoice-hook.ts), déclenché en void-promesse depuis :

  • billing/application/reservation-total-collection.ts — après création de la facture platform_sale.
  • billing/application/supplier-settlement.ts — après création de l'autofacture supplier_self_billed.
  • billing/application/credit-notes.ts — après création d'un avoir (platform_credit_note ou supplier_credit_note — le kind est correctement mappé depuis la facture source).

Le hook enchaîne PDF Factur-X puis transmission Agicap (Phase 4 — cf. section « Flux billing → einvoicing » plus haut). Il ne re-lève jamais : un échec est journalisé sur le scope billing.invoice.pdf_generation_failed (PDF), billing.einvoicing.transmission_draft_failed (einvoicing throw) ou billing.einvoicing.transmission_draft_skipped (skip volontaire — PDF absent, self-billed Phase 5, kind non éligible), et le flux principal (collecte / autofacturation / avoir) ne peut jamais échouer à cause du PDF ou de la transmission.

Le hook PDF seul (generateAndStoreInvoicePdfNonBlocking) reste exporté et utilisable pour des scénarios ciblés (rattrapage, endpoints admin) — il constitue le premier maillon interne de runPostInvoiceHooksNonBlocking.

Fallback template

Si le template classic d'@aidalinfo/invoice-kit refuse le rendu (données manquantes, erreur d'asset), un PDF minimal pdf-lib est produit. Le CII Factur-X reste embarqué — la facture est légalement conforme.

Issues powerpackages liées


Interfaces utilisateur (Phase 6)

Depuis la Phase 6, chaque workspace expose une entrée « Facturation » dédiée. Les pages sont en orchestration — la logique est extraite dans deux composables partagés (useBillingInvoices, useSelfBillingMandate) et des composants sous app/mypromo/app/components/billing/*.

Espace industriel — « Mes factures »

  • Route : /industrial/billing — layout industrial.
  • Kinds affichés : platform_sale + platform_credit_note.
  • Filtres : période (bornes locales, appliquées côté client sur la réponse serveur).
  • Chaque ligne montre invoiceNumber, kind + statut (badges sémantiques Nuxt UI), ventilation HT / TVA / TTC (source serveur), et un bouton « Télécharger PDF » conditionné à hasPdf. Le téléchargement passe par GET /api/billing/invoices/{invoiceId}/download — jamais de construction d'URL S3 côté client.

Espace industriel — Transparence commission dans le panier

  • L'endpoint GET /api/campaigns/cart retourne désormais la ventilation par item : netDistributorAmountCents (base fournisseur = loyer HT magasin, ancien reservationTotalCents), platformCommissionAmountCents, industrialTotalPayableCents. Le total agrégé du panier expose totalNetDistributorCents, totalPlatformCommissionCents, totalIndustrialPayableCents, commissionRateBps.
  • Le champ reservationTotalCents conserve son ancienne sémantique (base fournisseur) pour ne pas casser buildReservationPricingFromCartItem — la commission est un enrichissement additif.
  • Le composant BillingCartPricingBreakdown (app/mypromo/app/components/billing/CartPricingBreakdown.vue) affiche la ventilation dans le popover panier (CartPreview.vue) et dans la page de confirmation (industrial/reservations/new.vue). L'industriel voit dès le panier le total qui sera facturé — cohérence garantie avec la facture platform_sale.

Espace distributeur — « Mes autofactures »

  • Route : /distributor/billing — layout distributor.
  • Kinds affichés : supplier_self_billed + supplier_credit_note.
  • Bandeau mandat en tête (BillingSelfBillingMandateBanner) — l'organisation active est récupérée via GET /api/auth/onboarding/company puis la vue publique du mandat via GET /api/billing/organizations/{organizationId}/self-billing-mandate. États :
    • mandat absent (404)warning → « Autofacturation non activée, contactez Pulse ».
    • pending_signaturewarning → attente signature du signataire légal.
    • signedsuccess + date de signature + lien vers le document.
    • declined / revokederror → contact commercial.
  • Mention légale permanente sous le bandeau : « Autofacturation — document émis au nom et pour le compte du fournisseur ».
  • Téléchargement PDF conditionné à hasPdf, même endpoint que côté industriel (l'RBAC accepte l'organisation émettrice OU cliente).

Espace admin — Fiche organisation

  • AdminSelfBillingMandateCard (app/mypromo/app/components/admin/organizations/AdminSelfBillingMandateCard.vue) apparaît uniquement pour les organisations actorType = 'distributor'.
  • Affiche l'état courant (badge sémantique + dates + motifs) et propose :
    • Initier la demande de signature (POST /api/billing/organizations/{id}/self-billing-mandate) — actif quand aucun mandat actif n'existe.
    • Révoquer le mandat (DELETE .../self-billing-mandate + motif obligatoire dans une modale de confirmation) — actif quand le mandat est signed.
    • Voir le document — lien vers GET .../self-billing-mandate/document (voir ci-dessous).

Nouveaux endpoints (Phase 6)

GET /api/billing/invoices — filtre kind[] + hasPdf + ventilation TVA

Voir « Lister les factures de l'organisation connectée » plus haut.

GET /api/billing/organizations/{organizationId}/self-billing-mandate/document

Streame le PDF du mandat d'autofacturation courant. RBAC :

  • permission billing.manage.all (admin) → OK ;
  • membre actif de l'organisation (manager / member / billing) → OK ;
  • autre → 403.

Réponses d'erreur :

  • 404 — aucun mandat n'existe pour l'organisation.
  • 404 BILLING_PDF_NOT_READY — le mandat existe mais le PDF n'a pas encore été stocké.

Le PDF est streamé directement depuis S3 (Content-Disposition: attachment) via getSelfBillingMandatePdfStream ; l'URL S3 signée intermédiaire n'apparaît jamais dans les logs.

i18n

Toutes les clés sont namespacées sous billing.* (fr + en, app/mypromo/i18n/locales/*.json) :

  • billing.industrial.page.*, billing.distributor.page.*, billing.distributor.legalNotice.*
  • billing.list.* (headers, filters, totals, empty, actions)
  • billing.invoiceKind.* (labels français / anglais par kind)
  • billing.invoiceStatus.*
  • billing.download.*, billing.cart.breakdown.*
  • billing.mandate.status.*, billing.mandate.banner.*, billing.mandate.admin.*
  • layout.nav.billing + layout.nav.selfBilling (entrées de navigation).