Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README-FR.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,21 @@ JSON ne porte que ce qui varie.
Champs optionnels : `due_date` (défaut « à réception » = émission),
`delivery_date` (défaut = émission), `currency` (défaut EUR), `vat`
(défaut exonéré 293 B ; mettre `{"exempt": false, "rate_pct": "20.00"}` pour la
TVA standard), `seller`, `iban`, `notes`.
TVA standard), `seller`, `iban`, `title` (objet de la facture, inscrit au registre),
`notes`.

**Notes** : les trois mentions légales françaises — pénalités de retard (`PMD`), frais
de recouvrement (`PMT`), escompte (`AAB`) — sont toujours émises (BR-FR-05). `notes`
les **complète** : une note sans `subject_code` part en information générale (`AAI`) ;
une note portant `PMD`, `PMT` ou `AAB` remplace la mention par défaut du même code.
Aucune spec ne peut les faire disparaître : la génération refuse.

**Routage PDP** : `seller`/`buyer` acceptent `electronic_address` (BT-34/BT-49) et
`electronic_address_scheme` (ex. `"0225"`). Requis pour l'envoi à une PDP, qui route
sur ces adresses plutôt que sur l'adresse postale. Exemple (override vendeur) :
`electronic_address_scheme` (ex. `"0225"`). Une PDP route sur ces adresses, pas sur
l'adresse postale. Sans adresse déclarée, chaque partie est routée sur son **SIREN**
(dérivé du SIRET) en scheme `0225` ; l'e-mail (`EM`) n'est utilisé qu'à défaut de
tout identifiant légal — il n'est pas dans l'annuaire, et `send_invoice` refuse alors
le dépôt. Exemple (override vendeur) :

```json
"seller": {
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,21 @@ only carries what varies.
Optional fields: `due_date` (defaults to "on receipt" = issue date),
`delivery_date` (defaults to issue date), `currency` (defaults to EUR), `vat`
(defaults to 293 B exemption; set `{"exempt": false, "rate_pct": "20.00"}` for
standard VAT), `seller`, `iban`, `notes`.
standard VAT), `seller`, `iban`, `title` (the invoice subject, recorded in the
registry), `notes`.

**Notes**: the three French legal mentions — late-payment penalties (`PMD`),
recovery fee (`PMT`), discount (`AAB`) — are always emitted (BR-FR-05). `notes`
**adds** to them: a note without `subject_code` goes out as general information
(`AAI`); a note carrying `PMD`, `PMT` or `AAB` replaces the default mention with
that code. No spec can drop them: generation refuses.

**PDP routing**: `seller`/`buyer` accept `electronic_address` (BT-34/BT-49) and
`electronic_address_scheme` (e.g. `"0225"`). Required for PDP submission, which
routes on these addresses rather than on the postal address. Example (seller
override):
`electronic_address_scheme` (e.g. `"0225"`). A PDP routes on these addresses,
not on the postal address. Without a declared address, each party is routed on
its **SIREN** (derived from the SIRET) with scheme `0225`; the e-mail (`EM`) is
only used when no legal identifier is known — it is not in the directory, and
`send_invoice` then refuses the deposit. Example (seller override):

```json
"seller": {
Expand Down
14 changes: 13 additions & 1 deletion docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,19 @@ l'organisation : le JSON ne porte que ce qui varie.
Champs optionnels : `type` (`invoice` | `credit_note`), `due_date` (défaut : à
réception), `delivery_date` (défaut : émission), `currency` (défaut EUR), `vat`
(`{"exempt": false, "rate_pct": "20.00"}` pour la TVA standard), `buyer_reference`,
`seller`, `iban`, `notes`.
`seller`, `iban`, `title` (objet de la facture, inscrit au registre), `notes`.

!!! note "Notes et mentions légales"
Les trois mentions obligatoires — pénalités de retard (`PMD`), frais de
recouvrement (`PMT`), escompte (`AAB`) — sont **toujours** émises. `notes` les
complète : une note sans `subject_code` part en information générale (`AAI`),
une note portant `PMD`, `PMT` ou `AAB` remplace la mention par défaut du même
code. Une facture qui en perdrait une est refusée avant génération (BR-FR-05).

!!! note "Adresse de routage de l'acheteur"
Sans `electronic_address`, l'acheteur est routé sur son SIREN (dérivé du SIRET)
en scheme `0225`, comme le vendeur. L'e-mail (`EM`) ne sert qu'à défaut de tout
identifiant légal — et une PDP ne sait pas le livrer.

## Organisations

Expand Down
13 changes: 13 additions & 0 deletions docs/pdp.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,24 @@ irréversible de l'outil, et elle est traitée comme telle.
rejeté, réessayer en `0002` ;
- introuvable → **ne pas tenter l'envoi** : livrez le PDF par un autre canal.

Sans adresse déclarée, gofact route l'acheteur sur son SIREN en `0225` et inscrit
cette adresse dans le sidecar JSON à la création. Un acheteur dont la seule
adresse est un e-mail n'est pas déposé : `send_invoice` refuse et renvoie vers
`find_routing_address`, plutôt que de laisser la plateforme rejeter.

!!! note "L'émetteur aussi doit être adressable"
La PDP exige que le vendeur de la facture corresponde à la société du compte
authentifié (identifiant légal BT-30) et porte sa propre adresse de routage. Par
défaut gofact route l'émetteur sur son SIREN en scheme `0225`.

## Rejet

Un rejet arrive en général dans la seconde. `send_invoice` relit le cycle de vie
juste après le dépôt et remonte immédiatement un rejet (`fr:213`) avec ses motifs,
règle par règle — par exemple `BR-FR-05/BT-22 : La mention relative aux frais de
recouvrement (code PMT) est absente`. `get_invoice_status` expose les mêmes motifs,
et le journal du dossier trace l'événement `pdp_rejected`.

## Déposer

En conversation, l'IA n'appellera `send_invoice` qu'après **votre confirmation
Expand Down
5 changes: 3 additions & 2 deletions internal/facturx/cii.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,9 @@ func buildParty(p Party) ciiParty {
if p.SIREN != "" {
party.LegalOrg = &ciiLegalOrg{ID: ciiSchemeID{SchemeID: "0002", Value: p.SIREN}}
}
// BT-34/BT-49 adresse électronique : adresse de routage (PDP) prioritaire,
// sinon l'e-mail (scheme EM).
// BT-34/BT-49 adresse électronique : l'adresse résolue par ResolveRouting
// (explicite, sinon SIREN en 0225), avec l'e-mail (scheme EM) en dernier
// repli — une partie sans identifiant légal ni adresse déclarée.
if p.EAddr != "" {
scheme := p.EAddrScheme
if scheme == "" {
Expand Down
17 changes: 17 additions & 0 deletions internal/facturx/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,23 @@ func (inv Invoice) Validate() error {
add("BR-53", "devise %s : la contre-valeur de TVA en EUR (BT-111) est requise", inv.Currency)
}

// Mentions légales françaises (BR-FR-05) : pénalités de retard (PMD), frais
// de recouvrement (PMT) et escompte (AAB) doivent figurer, une fois chacune,
// dans les notes du document (BG-1). Une PDP française rejette la facture
// sinon — et le socle EN 16931 seul ne le voit pas.
for _, code := range legalNoteCodes {
n := 0
for _, note := range inv.Notes {
if note.SubjectCode == code && strings.TrimSpace(note.Content) != "" {
n++
}
}
if n != 1 {
add("BR-FR-05", "la mention légale %s (%s, BT-21) doit figurer exactement une fois dans les "+
"notes de la facture (BG-1), trouvée %d fois", code, legalNoteLabels[code], n)
}
}

if len(v) == 0 {
return nil
}
Expand Down
26 changes: 26 additions & 0 deletions internal/facturx/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,29 @@ func TestValidateExemptSellerNeedsTaxID(t *testing.T) {
t.Errorf("BR-E-02 attendue pour un vendeur exonéré sans identifiant fiscal, obtenu %v", ids)
}
}

// Les trois mentions légales françaises doivent figurer une fois chacune : une
// facture qui en perd une ou en double une est refusée avant génération.
func TestValidateRequiresFrenchLegalMentions(t *testing.T) {
inv := validInvoice(t)
inv.Notes = []Note{{SubjectCode: "PMD", Content: "x"}, {SubjectCode: "AAB", Content: "z"}, {SubjectCode: "AAI", Content: "contexte"}}
ids := violations(t, inv)
if !hasRule(ids, "BR-FR-05") {
t.Errorf("BR-FR-05 attendue sans mention PMT, obtenu %v", ids)
}
if err := inv.Validate(); !strings.Contains(err.Error(), "PMT") || !strings.Contains(err.Error(), "frais de recouvrement") {
t.Errorf("le message doit nommer la mention manquante : %v", err)
}

inv = validInvoice(t)
inv.Notes = append(inv.Notes, Note{SubjectCode: "PMD", Content: "doublon"})
if ids := violations(t, inv); !hasRule(ids, "BR-FR-05") {
t.Errorf("BR-FR-05 attendue pour une mention PMD en double, obtenu %v", ids)
}

inv = validInvoice(t)
inv.Notes = nil
if ids := violations(t, inv); !hasRule(ids, "BR-FR-05") {
t.Errorf("BR-FR-05 attendue sans aucune note, obtenu %v", ids)
}
}
136 changes: 122 additions & 14 deletions internal/facturx/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@ type Spec struct {
VAT *VATSpec `json:"vat,omitempty"` // défaut : franchise 293 B (exonéré)
Seller *PartySpec `json:"seller,omitempty"` // override ; défaut GOFACT_SELLER_*
IBAN string `json:"iban,omitempty"` // override ; défaut GOFACT_PAYEE_IBAN
Notes []NoteSpec `json:"notes,omitempty"` // override ; défaut : mentions légales FR
// Notes complémentaires (BG-1). Les mentions légales PMD, PMT et AAB sont
// TOUJOURS présentes : une note du spec qui porte l'un de ces codes remplace
// la mention par défaut, une note sans code est une information générale (AAI).
Notes []NoteSpec `json:"notes,omitempty" jsonschema:"notes complémentaires (BG-1) ; les mentions légales PMD/PMT/AAB sont toujours ajoutées automatiquement — une note portant l'un de ces codes remplace la mention par défaut du même code, une note sans subject_code est une information générale (AAI)"`
// Objet de la facture, inscrit au registre comme intitulé du projet. À défaut,
// le libellé de la première ligne sert de dernier recours.
Title string `json:"title,omitempty" jsonschema:"objet de la facture en quelques mots (ex. « Refonte du site vitrine ») ; inscrit au registre comme intitulé du projet"`
}

// NoteSpec est une mention libre (BT-22) avec son code sujet (BT-21).
type NoteSpec struct {
Content string `json:"content"`
SubjectCode string `json:"subject_code,omitempty"`
Content string `json:"content" jsonschema:"texte de la note"`
SubjectCode string `json:"subject_code,omitempty" jsonschema:"code sujet UNTDID 4451 ; vide ⇒ AAI (information générale) ; PMD, PMT ou AAB pour personnaliser la mention légale correspondante"`
}

// PartySpec décrit une partie. SIRET (14) prime pour dériver le SIREN (9) si SIREN absent.
Expand Down Expand Up @@ -78,6 +84,115 @@ var defaultNotes = []Note{
{SubjectCode: "AAB", Content: "Pas d'escompte pour paiement anticipé."},
}

// legalNoteCodes sont les codes sujet (BT-21) des trois mentions obligatoires,
// dans l'ordre où elles sont émises.
var legalNoteCodes = []string{"PMD", "PMT", "AAB"}

// legalNoteLabels nomme chaque mention pour les messages d'erreur.
var legalNoteLabels = map[string]string{
"PMD": "pénalités de retard",
"PMT": "frais de recouvrement",
"AAB": "escompte",
}

// generalNoteCode est le code sujet attribué à une note du spec qui n'en déclare
// pas : AAI (UNTDID 4451, « information générale »). Sans lui, un paragraphe de
// contexte partirait sans code et pourrait passer pour une mention légale.
const generalNoteCode = "AAI"

func isLegalNoteCode(code string) bool {
for _, c := range legalNoteCodes {
if c == code {
return true
}
}
return false
}

// mergeNotes compose les mentions du document : les trois mentions légales
// d'abord, dans l'ordre PMD, PMT, AAB — chacune remplacée par la note du spec
// qui porte le même code, s'il y en a une — puis les autres notes du spec.
//
// C'est délibérément une fusion, pas un remplacement : une note de contexte
// ajoutée à la facture ne doit jamais faire disparaître une mention légale.
// C'est exactement ce qui a fait rejeter une facture par une PDP (BR-FR-05)
// alors que le validateur EN 16931 la disait conforme.
func mergeNotes(user []NoteSpec) []Note {
replacements := map[string]Note{}
var extra []Note
for _, n := range user {
content := strings.TrimSpace(n.Content)
if content == "" {
continue
}
code := strings.ToUpper(strings.TrimSpace(n.SubjectCode))
if isLegalNoteCode(code) {
replacements[code] = Note{Content: content, SubjectCode: code}
continue
}
if code == "" {
code = generalNoteCode
}
extra = append(extra, Note{Content: content, SubjectCode: code})
}
out := make([]Note, 0, len(defaultNotes)+len(extra))
for _, d := range defaultNotes {
if r, ok := replacements[d.SubjectCode]; ok {
out = append(out, r)
continue
}
out = append(out, d)
}
return append(out, extra...)
}

// Schémas d'adresse électronique (EAS) rencontrés dans le routage FR. Une PDP
// française route sur l'annuaire : 0225 (SIREN, réforme FR) et 0002 (SIRENE) y
// figurent, un e-mail (EM) jamais — une facture adressée en EM est indélivrable.
const (
eaddrSchemeEmail = "EM"
eaddrSchemeSIRENE = "0002"
)

// ResolveRouting détermine l'adresse électronique (BT-34 vendeur, BT-49
// acheteur) d'une partie, dans cet ordre :
//
// 1. l'adresse explicite (electronic_address), avec son schéma — ou, s'il
// manque, EM pour un e-mail et 0225 pour un identifiant ;
// 2. sinon le SIREN, dérivé du SIRET au besoin, en 0225 ;
// 3. sinon l'e-mail en EM, uniquement quand aucun identifiant légal n'est connu.
//
// Renvoie l'adresse et son schéma, vides si rien n'est exploitable. C'est la
// même résolution pour les deux parties, et c'est elle que create_invoice
// inscrit dans le sidecar : le fichier reflète le XML.
func ResolveRouting(p PartySpec) (addr, scheme string) {
addr = strings.ReplaceAll(strings.TrimSpace(p.EAddr), " ", "")
scheme = strings.TrimSpace(p.EAddrSchema)
if addr != "" {
if scheme == "" {
scheme = defaultEAddrScheme
if strings.Contains(addr, "@") {
scheme = eaddrSchemeEmail
}
}
return addr, scheme
}
if siren := siren9(p.SIREN, p.SIRET); siren != "" {
return siren, defaultEAddrScheme
}
if email := strings.TrimSpace(p.Email); email != "" {
return email, eaddrSchemeEmail
}
return "", ""
}

// IsRoutableScheme dit si un schéma d'adresse est routable par une PDP
// française — c'est-à-dire présent dans l'annuaire : 0225 ou 0002.
func IsRoutableScheme(scheme string) bool {
scheme = strings.TrimSpace(scheme)
return scheme == defaultEAddrScheme || scheme == eaddrSchemeSIRENE
}

// LoadSpec lit et décode un fichier JSON de spécification de facture.
func LoadSpec(path string) (Spec, error) {
var s Spec
Expand Down Expand Up @@ -121,13 +236,14 @@ func partyToCII(p PartySpec, fallbackCountry string) Party {
if country == "" {
country = fallbackCountry
}
eaddr, scheme := ResolveRouting(p)
return Party{
Name: p.Name,
VATNumber: strings.ReplaceAll(strings.TrimSpace(p.VATNumber), " ", ""),
SIREN: siren9(p.SIREN, p.SIRET),
Email: strings.TrimSpace(p.Email),
EAddr: strings.TrimSpace(p.EAddr),
EAddrScheme: strings.TrimSpace(p.EAddrSchema),
EAddr: eaddr,
EAddrScheme: scheme,
Address: p.Address,
PostalCode: p.PostalCode,
City: p.City,
Expand Down Expand Up @@ -322,18 +438,10 @@ func (s Spec) ToInvoiceWith(cfg Config) (Invoice, error) {
iban = cfg.IBAN
}

notes := defaultNotes
if len(s.Notes) > 0 {
notes = make([]Note, 0, len(s.Notes))
for _, n := range s.Notes {
notes = append(notes, Note{Content: n.Content, SubjectCode: n.SubjectCode})
}
}

inv := Invoice{
Number: strings.TrimSpace(s.Number),
Type: docType,
Notes: notes,
Notes: mergeNotes(s.Notes),
IssueDate: issue,
DueDate: due,
DeliveryDate: delivery,
Expand Down
Loading
Loading