diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e98a770..7e2ec4b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- `addons/fr/ctc`: consolidated French CTC support. invoice rule sets are dispatched at validation time, and Flow 6 lifecycle messages (`bill.Status`) run unconditionally. `eu-en16931-v2017` is no longer a hard `Requires`. - `currency`: new `CanConvertTo` test that will ensure a document has or can convert to the provided currency. - `addons/es/verifactu`: Country is now required on customer identities when the identity type is not NIF-VAT (02). - `cbc`: `Meta.Keys()`, `Meta.Values()`, and `Meta.All()` (iter.Seq2) for ordered iteration over meta entries. diff --git a/addons/fr/ctc/bill.go b/addons/fr/ctc/bill.go deleted file mode 100644 index eeaf38039..000000000 --- a/addons/fr/ctc/bill.go +++ /dev/null @@ -1,234 +0,0 @@ -package ctc - -import ( - "regexp" - "slices" - - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/catalogues/iso" - "github.com/invopop/gobl/catalogues/untdid" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/regimes/fr" - "github.com/invopop/gobl/tax" -) - -// BR-FR-01/02: Invoice code validation -// Max 35 characters, alphanumeric plus -+_/ -var invoiceCodeRegexp = regexp.MustCompile(`^[A-Za-z0-9\-\+_/]{1,35}$`) - -// BR-FR-04: Allowed UNTDID document types for French CTC -var allowedDocumentTypes = []cbc.Code{ - "380", // Commercial invoice - "389", // Self-billed invoice - "393", // Factoring invoice - "501", // Final invoice - "386", // Advance payment invoice - "500", // Self-billed advance payment - "384", // Corrective invoice - "471", // Prepaid amount invoice - "472", // Self-billed prepaid amount - "473", // Stand-alone credit note - "261", // Self-billed credit note - "262", // Consolidated credit note - "381", // Credit note - "396", // Factoring credit note - "502", // Self-billed corrective - "503", // Self-billed credit for claim -} - -// Allowed BAR treatment values for French CTC -var allowedBARTreatments = []string{ - "B2B", - "B2BINT", - "B2C", - "OUTOFSCOPE", - "ARCHIVEONLY", -} - -// Self-billed document types (used for BR-FR-21/BR-FR-22) -var selfBilledDocumentTypes = []cbc.Code{ - "389", // Self-billed invoice - "501", // Final invoice (self-billed context) - "500", // Self-billed advance payment - "471", // Prepaid amount invoice (self-billed context) - "473", // Stand-alone credit note (self-billed context) - "261", // Self-billed credit note - "502", // Self-billed corrective -} - -// Corrective invoice document types (BR-FR-CO-04) -var correctiveInvoiceTypes = []cbc.Code{ - "384", // Corrective invoice - "471", // Prepaid amount invoice - "472", // Self-billed prepaid amount - "473", // Stand-alone credit note -} - -// Credit note document types (BR-FR-CO-05) -var creditNoteTypes = []cbc.Code{ - "261", // Self-billed credit note - "381", // Credit note - "396", // Factoring credit note - "502", // Self-billed corrective - "503", // Self-billed credit for claim -} - -var advancePaymentDocumentTypes = []cbc.Code{ - "386", // Advance payment invoice - "500", // Self-billed advance payment - "503", // Self-billed credit for claim -} - -// Allowed attachment description values for French CTC (BR-FR-17) -var allowedAttachmentDescriptions = []string{ - "RIB", // Bank account details (Relevé d'Identité Bancaire) - "LISIBLE", // Human-readable representation of the invoice - "FEUILLE_DE_STYLE", // Style sheet for document presentation - "PJA", // Additional supporting document (Pièce Jointe Additionnelle) - "BON_LIVRAISON", // Delivery note - "BON_COMMANDE", // Purchase order - "DOCUMENT_ANNEXE", // Annex document - "BORDEREAU_SUIVI", // Follow-up form - "BORDEREAU_SUIVI_VALIDATION", // Validated follow-up form - "ETAT_ACOMPTE", // Payment status statement - "FACTURE_PAIEMENT_DIRECT", // Direct payment invoice - "RECAPITULATIF_COTRAITANCE", // Co-contracting summary -} - -const ( - // attachmentFormatLisible is the attachment format category for BR-FR-18 - attachmentFormatLisible = "LISIBLE" -) - -// normalizeInvoice ensures invoice settings comply with French CTC requirements -func normalizeInvoice(inv *bill.Invoice) { - if inv == nil { - return - } - - // Ensure Tax object exists - if inv.Tax == nil { - inv.Tax = &bill.Tax{} - } - - // Always set rounding to currency for French CTC - inv.Tax.Rounding = tax.RoundingRuleCurrency -} - -// isB2BTransaction determines if the transaction is B2B (business to business) -// by checking for a note with code "BAR" and text containing "B2B" -func isB2BTransaction(inv *bill.Invoice) bool { - if inv == nil || len(inv.Notes) == 0 { - return false - } - - for _, note := range inv.Notes { - if note != nil && !note.Ext.IsZero() { - if note.Ext.Get(untdid.ExtKeyTextSubject) == "BAR" && note.Text == "B2B" { - // Check if note text indicates B2B transaction (B2B or B2BINT) - return true - } - } - } - - return false -} - -// isSelfBilledInvoice checks if the invoice is self-billed based on document type -func isSelfBilledInvoice(inv *bill.Invoice) bool { - if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { - return false - } - - docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) - if docType == "" { - return false - } - - return slices.Contains(selfBilledDocumentTypes, docType) -} - -// isCorrectiveInvoice checks if the invoice is corrective based on document type -func isCorrectiveInvoice(inv *bill.Invoice) bool { - if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { - return false - } - - docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) - if docType == "" { - return false - } - - return slices.Contains(correctiveInvoiceTypes, docType) -} - -func isPartyIdentitySTC(party *org.Party) bool { - if party == nil || len(party.Identities) == 0 { - return false - } - - for _, id := range party.Identities { - if id != nil && !id.Ext.IsZero() { - if code := id.Ext.Get(iso.ExtKeySchemeID); code == "0231" { - return true - } - } - } - return false -} - -func isCreditNote(inv *bill.Invoice) bool { - if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { - return false - } - docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) - return slices.Contains(creditNoteTypes, docType) -} - -func isConsolidatedCreditNote(inv *bill.Invoice) bool { - if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { - return false - } - docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) - return docType == "262" // Consolidated credit note -} - -func isAdvancedInvoice(inv *bill.Invoice) bool { - if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { - return false - } - - docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) - return slices.Contains(advancePaymentDocumentTypes, docType) -} - -// isFinalInvoice checks if the invoice is a final invoice based on billing mode (B2, S2, M2) -func isFinalInvoice(inv *bill.Invoice) bool { - if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { - return false - } - - bm := inv.Tax.Ext.Get(ExtKeyBillingMode) - return bm == BillingModeB2 || bm == BillingModeS2 || bm == BillingModeM2 -} - -func isFactoringExtension(bm cbc.Code) bool { - return bm == BillingModeB4 || bm == BillingModeS4 || bm == BillingModeM4 -} - -// getPartySIREN extracts the SIREN from the party's SIREN identity -func getPartySIREN(party *org.Party) string { - if party == nil { - return "" - } - - // SIREN identity - check by type or ISO scheme ID 0002 - for _, id := range party.Identities { - if id != nil && (id.Type == fr.IdentityTypeSIREN || (!id.Ext.IsZero() && id.Ext.Get(iso.ExtKeySchemeID) == identitySchemeIDSIREN)) { - return string(id.Code) - } - } - - return "" -} diff --git a/addons/fr/ctc/bill_invoice.go b/addons/fr/ctc/bill_invoice.go new file mode 100644 index 000000000..77fb67246 --- /dev/null +++ b/addons/fr/ctc/bill_invoice.go @@ -0,0 +1,1175 @@ +package ctc + +import ( + "regexp" + "slices" + "strings" + + "github.com/invopop/gobl/addons/eu/en16931" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/fr" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// -- Constants & lookup tables -------------------------------------------- + +// invoiceCodeRegexp enforces BR-FR-01/02 invoice-code format: max 35 +// characters, alphanumeric plus -+_/. +var invoiceCodeRegexp = regexp.MustCompile(`^[A-Za-z0-9\-\+_/]{1,35}$`) + +// Self-billed document types (BR-FR-21/BR-FR-22). +var selfBilledDocumentTypes = []cbc.Code{ + "389", // Self-billed invoice + "501", // Final invoice (self-billed context) + "500", // Self-billed advance payment + "471", // Prepaid amount invoice (self-billed context) + "473", // Stand-alone credit note (self-billed context) + "261", // Self-billed credit note + "502", // Self-billed corrective +} + +// Corrective invoice document types (BR-FR-CO-04). +var correctiveInvoiceTypes = []cbc.Code{ + "384", // Corrective invoice + "471", // Prepaid amount invoice + "472", // Self-billed prepaid amount + "473", // Stand-alone credit note +} + +// Credit note document types (BR-FR-CO-05). +var creditNoteTypes = []cbc.Code{ + "261", // Self-billed credit note + "381", // Credit note + "396", // Factoring credit note + "502", // Self-billed corrective + "503", // Self-billed credit for claim +} + +// advancePaymentDocumentTypes are the UNTDID 1001 codes representing +// advance-payment invoices (forbidden combined with B4/S4/M4 modes). +var advancePaymentDocumentTypes = []cbc.Code{ + "386", // Advance payment invoice + "500", // Self-billed advance payment + "503", // Self-billed credit for claim +} + +// finalAfterAdvanceBillingModes are the billing-mode codes that mark +// an invoice as a "final invoice after down payment". +var finalAfterAdvanceBillingModes = []cbc.Code{ + BillingModeB4, BillingModeS4, BillingModeM4, +} + +// allowedAttachmentDescriptions enumerates the BR-FR-17 attachment +// descriptions accepted on a French CTC invoice. +var allowedAttachmentDescriptions = []string{ + "RIB", + "LISIBLE", + "FEUILLE_DE_STYLE", + "PJA", + "BON_LIVRAISON", + "BON_COMMANDE", + "DOCUMENT_ANNEXE", + "BORDEREAU_SUIVI", + "BORDEREAU_SUIVI_VALIDATION", + "ETAT_ACOMPTE", + "FACTURE_PAIEMENT_DIRECT", + "RECAPITULATIF_COTRAITANCE", +} + +// allowedVATPercents is the whitelist of VAT percentages authorised on a +// Flow 10 invoice / payment (G1.24). +var allowedVATPercents = mustParsePercentages( + "0%", "0.9%", "1.05%", "1.75%", "2.1%", "5.5%", "7%", "8.5%", + "9.2%", "9.6%", "10%", "13%", "19.6%", "20%", "20.6%", +) + +func mustParsePercentages(values ...string) []num.Percentage { + out := make([]num.Percentage, len(values)) + for i, v := range values { + p, err := num.PercentageFromString(v) + if err != nil { + panic(err) + } + out[i] = p + } + return out +} + +func percentageInList(p num.Percentage, list []num.Percentage) bool { + for _, a := range list { + if p.Compare(a) == 0 { + return true + } + } + return false +} + +// vatKeyToUNTDIDCategory maps each supported GOBL VAT rate key to its +// UNTDID 5305 category code. +var vatKeyToUNTDIDCategory = map[cbc.Key]cbc.Code{ + tax.KeyStandard: "S", + tax.KeyZero: "Z", + tax.KeyExempt: "E", + tax.KeyReverseCharge: "AE", + tax.KeyIntraCommunity: "K", + tax.KeyExport: "G", + tax.KeyOutsideScope: "O", +} + +const ( + // attachmentFormatLisible is the attachment format category for BR-FR-18. + attachmentFormatLisible = "LISIBLE" + + // noteSubjectTXD is the UNTDID 4451 text-subject code carried on the + // BR-FR-CO-14 STC (single-VAT-group) mention. + noteSubjectTXD cbc.Code = "TXD" + + // stcMembreAssujettiUnique is the fixed text that pairs with TXD. + stcMembreAssujettiUnique = "MEMBRE_ASSUJETTI_UNIQUE" +) + +// -- Dispatcher ----------------------------------------------------------- + +// invoiceIsDomesticFrench reports whether both supplier and customer +// resolve as French (SIREN identity or French tax ID). When true, the +// Flow 2 clearance ruleset applies; otherwise Flow 10 reporting does. +func invoiceIsDomesticFrench(inv *bill.Invoice) bool { + if inv == nil { + return false + } + return partyIsFrench(inv.Supplier) && partyIsFrench(inv.Customer) +} + +func invoiceIsDomesticFrenchAny(v any) bool { + inv, ok := v.(*bill.Invoice) + return ok && invoiceIsDomesticFrench(inv) +} + +func invoiceIsNotDomesticFrenchAny(v any) bool { + inv, ok := v.(*bill.Invoice) + return ok && !invoiceIsDomesticFrench(inv) +} + +// -- Normalisation -------------------------------------------------------- + +func normalizeInvoice(inv *bill.Invoice) { + if inv == nil { + return + } + + // Map VAT keys to UNTDID 5305 category extensions on every line. + // Safe to run unconditionally — it only sets the extension when the + // key is one of the known buckets. + normalizeInvoiceTaxCategories(inv) + + // Party-level normalisation applies to both supplier and customer + // (SIREN derivation, peppol-key inbox marking, etc.). + normalizeParty(inv.Supplier) + normalizeParty(inv.Customer) + + // Branch on the dispatcher: Flow 2 (French B2B clearance) gets the + // rounding, billing-mode, note and STC defaults; Flow 10 gets the + // B2C category default (when applicable) and its own billing-mode + // default for B2B reporting. + if invoiceIsDomesticFrench(inv) { + normalizeFlow2Invoice(inv) + return + } + normalizeFlow10Invoice(inv) +} + +func normalizeFlow2Invoice(inv *bill.Invoice) { + if inv.Tax == nil { + inv.Tax = &bill.Tax{} + } + inv.Tax.Rounding = tax.RoundingRuleCurrency + normalizeBillingMode(inv) + normalizeRequiredNotes(inv) + normalizeSTCNote(inv) +} + +func normalizeFlow10Invoice(inv *bill.Invoice) { + if invoiceIsB2C(inv) { + normalizeB2CCategoryOnInvoice(inv) + return + } + normalizeBillingMode(inv) +} + +// invoiceIsB2C reports whether the invoice is a business-to-consumer +// transaction. Flow 10 distinguishes B2C from B2B by the presence of a +// Customer party. +func invoiceIsB2C(inv *bill.Invoice) bool { + return inv != nil && inv.Customer == nil +} + +// normalizeBillingMode picks a sensible default for the billing-mode +// extension when the caller hasn't supplied one. M2 when the invoice +// is fully paid, M1 otherwise. +func normalizeBillingMode(inv *bill.Invoice) { + if inv.Tax != nil && !inv.Tax.Ext.IsZero() && inv.Tax.Ext.Get(ExtKeyBillingMode) != "" { + return + } + mode := BillingModeM1 + if inv.Totals != nil && inv.Totals.Paid() { + mode = BillingModeM2 + } + if inv.Tax == nil { + inv.Tax = &bill.Tax{} + } + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, mode) +} + +// normalizeSTCNote appends the BR-FR-CO-14 TXD / MEMBRE_ASSUJETTI_UNIQUE +// note when the supplier carries an STC-scheme (0231) identity and no +// such note has been provided yet. +func normalizeSTCNote(inv *bill.Invoice) { + if !isPartyIdentitySTC(inv.Supplier) { + return + } + for _, n := range inv.Notes { + if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectTXD && n.Text == stcMembreAssujettiUnique { + return + } + } + inv.Notes = append(inv.Notes, &org.Note{ + Key: org.NoteKeyLegal, + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), + }) +} + +// defaultRequiredNotes lists the three UNTDID 4451 mentions French CTC +// requires on every B2B invoice (BR-FR-05). +var defaultRequiredNotes = []*org.Note{ + { + Key: org.NoteKeyPayment, + Text: "Conditions de paiement selon les conditions générales de vente.", + Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMT"}), + }, + { + Key: org.NoteKeyPaymentMethod, + Text: "Pénalités et indemnités de retard applicables conformément aux conditions générales de vente.", + Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMD"}), + }, + { + Key: org.NoteKeyPaymentTerm, + Text: "Aucun escompte n'est accordé pour paiement anticipé.", + Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "AAB"}), + }, +} + +// normalizeRequiredNotes appends any of the three regulatory PMT / PMD +// / AAB notes that are missing from the invoice. +func normalizeRequiredNotes(inv *bill.Invoice) { + for _, def := range defaultRequiredNotes { + want := def.Ext.Get(untdid.ExtKeyTextSubject) + if invoiceHasNoteWithSubject(inv, want) { + continue + } + clone := *def + inv.Notes = append(inv.Notes, &clone) + } +} + +func invoiceHasNoteWithSubject(inv *bill.Invoice, subject cbc.Code) bool { + for _, n := range inv.Notes { + if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == subject { + return true + } + } + return false +} + +// normalizeB2CCategoryOnInvoice defaults the B2C transaction category +// to TNT1 (not subject to French VAT) when the caller has not supplied +// one. +func normalizeB2CCategoryOnInvoice(inv *bill.Invoice) { + if inv.Tax != nil && inv.Tax.Ext.Get(ExtKeyB2CCategory) != "" { + return + } + if inv.Tax == nil { + inv.Tax = &bill.Tax{} + } + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyB2CCategory, B2CCategoryNotTaxable) +} + +// normalizeInvoiceTaxCategories sets the UNTDID 5305 category extension +// on each VAT combo based on its rate key. +func normalizeInvoiceTaxCategories(inv *bill.Invoice) { + for _, line := range inv.Lines { + if line == nil { + continue + } + for _, combo := range line.Taxes { + if combo == nil || combo.Category != tax.CategoryVAT { + continue + } + if combo.Ext.Get(untdid.ExtKeyTaxCategory) != "" { + continue + } + if code, ok := vatKeyToUNTDIDCategory[combo.Key]; ok { + combo.Ext = combo.Ext.Set(untdid.ExtKeyTaxCategory, code) + } + } + } +} + +// -- Rule set ------------------------------------------------------------- + +func billInvoiceRules() *rules.Set { + return rules.For(new(bill.Invoice), + // Shared rules — apply to every French CTC invoice regardless of + // the flow. EUR-convertibility is required by both flows. + rules.Assert("01", "invoice must be in EUR or provide an exchange rate to EUR", + currency.CanConvertTo(currency.EUR), + ), + + // Flow 2 — domestic B2B clearance ---------------------------------- + rules.When( + is.Func("domestic French B2B (Flow 2 clearance)", invoiceIsDomesticFrenchAny), + flow2InvoiceDefs()..., + ), + + // Flow 10 — e-reporting (non-domestic or B2C) ---------------------- + rules.When( + is.Func("Flow 10 reporting (cross-border or B2C)", invoiceIsNotDomesticFrenchAny), + flow10InvoiceDefs()..., + ), + ) +} + +// flow2InvoiceDefs returns the rule defs applied to a domestic French +// B2B (Flow 2 clearance) invoice. Pulled out so the rule.When call in +// billInvoiceRules reads cleanly. +func flow2InvoiceDefs() []rules.Def { + return []rules.Def{ + // EN16931 base profile is mandatory for domestic French B2B + // (Flow 2 clearance). It is intentionally not a hard Requires + // on the addon so that pure Flow 10 / Flow 6 callers don't + // have to drag it in. + rules.Assert("02", "domestic French B2B invoices must also declare the eu-en16931-v2017 addon", + tax.HasAddon(en16931.V2017), + ), + // Invoice code validation (BR-FR-01/02). + rules.Assert("03", "must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", + is.Func("valid invoice code", invoiceCodeValid), + ), + // Preceding document code validation. + rules.Field("preceding", + rules.Each( + rules.Assert("04", "preceding code must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", + is.Func("valid preceding code", precedingDocCodeValid), + ), + ), + ), + rules.When( + is.Func("corrective invoice", invoiceIsCorrectiveAny), + rules.Field("preceding", + rules.Assert("05", "corrective invoices must reference the original invoice in preceding (BR-FR-CO-04)", + is.Present, + ), + rules.Assert("06", "corrective invoices must reference exactly one preceding invoice — multiple references are not allowed (BR-FR-CO-04)", + is.Length(1, 1), + ), + ), + ), + rules.When( + is.Func("credit note", invoiceIsCreditNoteAny), + rules.Field("preceding", + rules.Assert("07", "credit notes must have at least one preceding invoice reference (BR-FR-CO-05)", + is.Present, + ), + ), + ), + rules.Field("tax", + rules.Assert("08", "tax is required", is.Present), + rules.Field("ext", + rules.Assert("09", "UNTDID document type must be valid (BR-FR-04)", + tax.ExtensionsHasCodes(untdid.ExtKeyDocumentType, allowedInvoiceDocumentTypes...), + ), + rules.Assert("10", "billing mode extension is required", + tax.ExtensionsRequire(ExtKeyBillingMode), + ), + ), + ), + rules.When( + is.Func("factoring mode", invoiceIsFactoringAny), + rules.Field("tax", + rules.Field("ext", + rules.Assert("11", "advance payment document types (386, 500, 503) are not allowed for factoring billing modes (B4, S4, M4) (BR-FR-CO-08)", + tax.ExtensionsExcludeCodes(untdid.ExtKeyDocumentType, advancePaymentDocumentTypes...), + ), + ), + ), + ), + rules.Field("supplier", + rules.Field("inboxes", + rules.Assert("12", "seller electronic address required for French B2B invoices (BR-FR-13)", + is.Present, + ), + ), + rules.Field("identities", + rules.Assert("13", "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", + is.Func("has SIREN (legal scope)", identitiesHasLegalSIREN), + ), + ), + ), + rules.When( + is.Func("not self-billed", invoiceIsNotSelfBilledAny), + rules.Field("supplier", + rules.Assert("14", "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", + is.Func("has SIREN inbox", partyHasSIRENInbox), + ), + ), + ), + rules.Field("customer", + rules.Field("inboxes", + rules.Assert("15", "buyer electronic address required for French B2B invoices (BR-FR-13)", + is.Present, + ), + ), + rules.Field("identities", + rules.Assert("16", "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", + is.Func("has SIREN (legal scope)", identitiesHasLegalSIREN), + ), + ), + ), + rules.When( + is.Func("self-billed", invoiceIsSelfBilledAny), + rules.Field("customer", + rules.Assert("17", "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", + is.Func("has SIREN inbox", partyHasSIRENInbox), + ), + ), + ), + rules.Field("ordering", + rules.Field("identities", + rules.Assert("18", "only one ordering identity with UNTDID reference 'AFL' is allowed (BR-FR-30)", + is.Func("no dup AFL", orderingIdentitiesNoDupAFL), + ), + rules.Assert("19", "only one ordering identity with UNTDID reference 'AWW' is allowed (BR-FR-30)", + is.Func("no dup AWW", orderingIdentitiesNoDupAWW), + ), + ), + ), + rules.When( + is.Func("supplier STC", invoiceSupplierIsSTC), + rules.Field("ordering", + rules.Assert("20", "ordering with seller is required when supplier is under STC scheme (BR-FR-CO-15)", + is.Present, + ), + rules.Field("seller", + rules.Assert("21", "seller is required when supplier is under STC scheme (BR-FR-CO-15)", + is.Present, + ), + rules.Field("tax_id", + rules.Assert("22", "tax ID is required when supplier is under STC scheme (BR-FR-CO-15)", + is.Present, + ), + rules.Field("code", + rules.Assert("23", "code is required when supplier is under STC scheme (BR-FR-CO-15)", + is.Present, + ), + ), + ), + ), + ), + rules.Field("notes", + rules.Assert("24", "for sellers with STC scheme (0231), a note with code 'TXD' and text 'MEMBRE_ASSUJETTI_UNIQUE' is required (BR-FR-CO-14)", + is.Func("has TXD note", notesHaveTXD), + ), + ), + ), + rules.When( + is.Func("consolidated credit note", invoiceIsConsolidatedCreditNoteAny), + rules.Field("ordering", + rules.Assert("25", "ordering with contracts is required for consolidated credit notes (BR-FR-CO-03)", + is.Present, + ), + rules.Field("contracts", + rules.Assert("26", "ordering.contracts is required for consolidated credit notes (BR-FR-CO-03)", + is.Present, + ), + rules.Assert("27", "ordering.contracts must contain at least one entry for consolidated credit notes (BR-FR-CO-03)", + is.Length(1, 0), + ), + ), + ), + rules.Field("delivery", + rules.Assert("28", "delivery details are required for consolidated credit notes (BR-FR-CO-03)", + is.Present, + ), + rules.Field("period", + rules.Assert("29", "delivery period is required for consolidated credit notes (BR-FR-CO-03)", + is.Present, + ), + ), + ), + ), + rules.When( + is.Func("not advance or final", invoiceIsNotAdvanceOrFinalAny), + rules.Assert("30", "due dates must not be before invoice issue date (BR-FR-CO-07)", + is.Func("due dates valid", invoiceDueDatesValid), + ), + ), + rules.When( + is.Func("final invoice", invoiceIsFinalAny), + rules.Field("payment", + rules.Assert("31", "payment details are required for final invoices (BR-FR-CO-09)", + is.Present, + ), + rules.Field("terms", + rules.Assert("32", "payment terms required for final invoices (BR-FR-CO-09)", + is.Present, + ), + rules.Field("due_dates", + rules.Assert("33", "at least one due date required for final invoices (BR-FR-CO-09)", + is.Present, + ), + ), + ), + ), + rules.Field("totals", + rules.Field("advance", + rules.Assert("34", "advance amount is required for already-paid invoices (BR-FR-CO-09)", + is.Present, + ), + ), + rules.Assert("35", "advance amount must equal total with tax for final invoices (BR-FR-CO-09)", + is.Func("advances match", finalInvoiceAdvancesMatch), + ), + rules.Assert("36", "payable amount must be zero for final invoices (BR-FR-CO-09)", + is.Func("payable zero", finalInvoicePayableZero), + ), + ), + ), + rules.Field("notes", + rules.Assert("37", "notes are required for French CTC invoices (BR-FR-05)", is.Present), + rules.Assert("38", "missing required note codes (BR-FR-05)", + is.Func("has required notes", notesHaveRequired), + ), + rules.Assert("39", "duplicate note codes found (BR-FR-06/BR-FR-30)", + is.Func("no duplicate notes", notesNoDuplicates), + ), + ), + rules.Field("attachments", + rules.Each( + rules.Field("description", + rules.Assert("40", "must be one of the allowed attachment descriptions (BR-FR-17)", + is.Present, + ), + rules.Assert("41", "must be one of the allowed attachment descriptions (BR-FR-17)", + is.In(toAnySlice(allowedAttachmentDescriptions)...), + ), + ), + ), + rules.Assert("42", "only one attachment with description 'LISIBLE' is allowed per invoice (BR-FR-18)", + is.Func("unique LISIBLE", attachmentsUniqueLISIBLE), + ), + ), + } +} + +// flow10InvoiceDefs returns the rule defs applied to a non-domestic +// (Flow 10 e-reporting) invoice — B2C or cross-border B2B. +func flow10InvoiceDefs() []rules.Def { + return []rules.Def{ + // B2C rules: category, supplier SIREN, VAT percent whitelist. + rules.When( + is.Func("B2C invoice", invoiceIsB2CAny), + rules.Field("tax", + rules.Field("ext", + rules.Assert("43", "B2C transaction category extension (fr-ctc-b2c-category) is required on B2C invoices (G1.68)", + is.Func("has B2C category", extensionsHaveB2CCategory), + ), + ), + ), + rules.Field("supplier", + rules.Assert("44", "supplier is required on B2C invoice", + is.Present, + ), + rules.Assert("45", "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002) on a B2C invoice", + is.Func("party has SIREN", partyHasSIREN), + ), + ), + rules.Assert("46", "every VAT line percent must be one of the Flow 10 permitted values (G1.24): 0, 0.9, 1.05, 1.75, 2.1, 5.5, 7, 8.5, 9.2, 9.6, 10, 13, 19.6, 20, 20.6", + is.Func("allowed Flow 10 VAT percents", invoiceVATPercentsAllowed), + ), + ), + rules.Field("supplier", + rules.Field("addresses", + rules.Each( + rules.Field("country", + rules.Assert("47", "supplier address must include country", + is.Present, + ), + ), + ), + ), + ), + rules.Field("customer", + rules.Field("addresses", + rules.Each( + rules.Field("country", + rules.Assert("48", "customer address must include country", + is.Present, + ), + ), + ), + ), + ), + // B2B reporting: supplier + customer must each carry a legal + // identity declaring an allowed ICD 6523 scheme, plus matching + // TaxID when scheme is SIREN / EU-VAT. + rules.When( + is.Func("cross-border B2B invoice", invoiceIsCrossBorderB2BAny), + rules.Field("tax", + rules.Field("ext", + rules.Assert("49", "invoice document type must be one of the Flow 10 permitted UNTDID 1001 codes", + is.Func("allowed Flow 10 document type", invoiceDocumentTypeAllowed), + ), + rules.Assert("50", "billing mode extension (fr-ctc-billing-mode) is required (G1.02)", + is.Func("has billing mode", extensionsHaveBillingMode), + ), + ), + ), + rules.When( + is.Func("billing mode is final-after-advance (B4/S4/M4)", invoiceIsFinalAfterAdvance), + rules.Field("tax", + rules.Field("ext", + rules.Assert("51", "final-after-advance billing mode (B4/S4/M4) cannot be combined with an advance-payment document type (386/500/503) (G1.60)", + is.Func("not advance-payment doc type", invoiceNotAdvancePaymentDocType), + ), + ), + ), + ), + rules.Field("supplier", + rules.Assert("52", "supplier is required for Flow 10 B2B invoice (G2.19)", + is.Present, + ), + rules.Assert("53", "supplier must declare a legal identity with an allowed ICD 6523 scheme (G2.19): 0002, 0223, 0227, 0228 or 0229", + is.Func("party has allowed legal scheme", partyHasAllowedLegalScheme), + ), + rules.Assert("54", "supplier TaxID is required when legal identity scheme is SIREN (0002) or EU VAT (0223) (G2.33)", + is.Func("party has TaxID when required", partyHasTaxIDWhenRequired), + ), + ), + rules.When( + is.Func("invoice has exempt (E) VAT category", invoiceHasExemptCombo), + rules.Assert("55", "supplier VAT ID or ordering.seller (tax representative) VAT ID is required when the invoice VAT breakdown contains an exempt (E) category", + is.Func("supplier or tax rep has VAT ID", invoiceHasSellerVATIDForExempt), + ), + rules.Assert("56", "invoice with an exempt (E) VAT category must include an exemption reason in tax.notes (key=exempt, non-empty text)", + is.Func("has exempt tax note", invoiceHasExemptTaxNote), + ), + ), + rules.Field("customer", + rules.Assert("57", "customer is required for Flow 10 B2B invoice (G2.19)", + is.Present, + ), + rules.Assert("58", "customer must declare a legal identity with an allowed ICD 6523 scheme (G2.19): 0002, 0223, 0227, 0228 or 0229", + is.Func("party has allowed legal scheme", partyHasAllowedLegalScheme), + ), + rules.Assert("59", "customer TaxID is required when legal identity scheme is SIREN (0002) or EU VAT (0223) (G2.33)", + is.Func("party has TaxID when required", partyHasTaxIDWhenRequired), + ), + ), + ), + } +} + +// -- Flow 2 helpers ------------------------------------------------------- + +func invoiceCodeValid(val any) bool { + inv, ok := val.(*bill.Invoice) + if !ok || inv == nil || inv.Code == cbc.CodeEmpty { + return true + } + invoiceID := string(inv.Code) + if inv.Series != cbc.CodeEmpty { + invoiceID = string(inv.Series.Join(inv.Code)) + } + return invoiceCodeRegexp.MatchString(invoiceID) +} + +func precedingDocCodeValid(val any) bool { + docRef, ok := val.(*org.DocumentRef) + if !ok || docRef == nil || docRef.Code == cbc.CodeEmpty { + return true + } + invoiceID := string(docRef.Code) + if docRef.Series != cbc.CodeEmpty { + invoiceID = string(docRef.Series.Join(docRef.Code)) + } + return invoiceCodeRegexp.MatchString(invoiceID) +} + +func invoiceIsCorrectiveAny(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && isCorrectiveInvoice(inv) +} + +func invoiceIsCreditNoteAny(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && isCreditNote(inv) +} + +func invoiceIsFactoringAny(val any) bool { + inv, ok := val.(*bill.Invoice) + if !ok || inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + return isFactoringExtension(inv.Tax.Ext.Get(ExtKeyBillingMode)) +} + +// Within the Flow 2 dispatcher branch the invoice is B2B by +// construction (both parties French), so self-billed vs not is the +// only remaining axis. + +func invoiceIsNotSelfBilledAny(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && !isSelfBilledInvoice(inv) +} + +func invoiceIsSelfBilledAny(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && isSelfBilledInvoice(inv) +} + +func invoiceSupplierIsSTC(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && inv != nil && isPartyIdentitySTC(inv.Supplier) +} + +func invoiceIsConsolidatedCreditNoteAny(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && isConsolidatedCreditNote(inv) +} + +func invoiceIsNotAdvanceOrFinalAny(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && inv != nil && !isAdvancedInvoice(inv) && !isFinalInvoice(inv) +} + +func invoiceIsFinalAny(val any) bool { + inv, ok := val.(*bill.Invoice) + return ok && isFinalInvoice(inv) +} + +func isSelfBilledInvoice(inv *bill.Invoice) bool { + if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) + if docType == "" { + return false + } + return slices.Contains(selfBilledDocumentTypes, docType) +} + +func isCorrectiveInvoice(inv *bill.Invoice) bool { + if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) + if docType == "" { + return false + } + return slices.Contains(correctiveInvoiceTypes, docType) +} + +func isPartyIdentitySTC(party *org.Party) bool { + if party == nil || len(party.Identities) == 0 { + return false + } + for _, id := range party.Identities { + if id != nil && !id.Ext.IsZero() { + if code := id.Ext.Get(iso.ExtKeySchemeID); code == "0231" { + return true + } + } + } + return false +} + +func isCreditNote(inv *bill.Invoice) bool { + if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) + return slices.Contains(creditNoteTypes, docType) +} + +func isConsolidatedCreditNote(inv *bill.Invoice) bool { + if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) + return docType == "262" +} + +func isAdvancedInvoice(inv *bill.Invoice) bool { + if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + docType := inv.Tax.Ext.Get(untdid.ExtKeyDocumentType) + return slices.Contains(advancePaymentDocumentTypes, docType) +} + +// isFinalInvoice checks if the invoice is a final invoice based on +// billing mode (B2, S2, M2). +func isFinalInvoice(inv *bill.Invoice) bool { + if inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + bm := inv.Tax.Ext.Get(ExtKeyBillingMode) + return bm == BillingModeB2 || bm == BillingModeS2 || bm == BillingModeM2 +} + +func isFactoringExtension(bm cbc.Code) bool { + return bm == BillingModeB4 || bm == BillingModeS4 || bm == BillingModeM4 +} + +// getPartySIREN extracts the SIREN string from a party's SIREN identity. +func getPartySIREN(party *org.Party) string { + if party == nil { + return "" + } + for _, id := range party.Identities { + if id != nil && (id.Type == fr.IdentityTypeSIREN || (!id.Ext.IsZero() && id.Ext.Get(iso.ExtKeySchemeID) == identitySchemeIDSIREN)) { + return string(id.Code) + } + } + return "" +} + +func identitiesHasLegalSIREN(val any) bool { + identities, ok := val.([]*org.Identity) + if !ok { + return true + } + for _, id := range identities { + if id != nil && !id.Ext.IsZero() { + if code := id.Ext.Get(iso.ExtKeySchemeID); code == identitySchemeIDSIREN && id.Scope.Has(org.IdentityScopeLegal) { + return true + } + } + } + return false +} + +func partyHasSIRENInbox(val any) bool { + party, ok := val.(*org.Party) + if !ok || party == nil { + return true + } + siren := getPartySIREN(party) + if siren == "" { + return true + } + for _, inbox := range party.Inboxes { + if inbox != nil && inbox.Scheme == inboxSchemeSIREN { + return strings.HasPrefix(string(inbox.Code), siren) + } + } + return false +} + +func orderingIdentitiesNoDupAFL(val any) bool { + return orderingIdentitiesNoDup(val, "AFL") +} + +func orderingIdentitiesNoDupAWW(val any) bool { + return orderingIdentitiesNoDup(val, "AWW") +} + +func orderingIdentitiesNoDup(val any, ref string) bool { + identities, ok := val.([]*org.Identity) + if !ok { + return true + } + count := 0 + for _, id := range identities { + if id == nil || id.Ext.IsZero() { + continue + } + if id.Ext.Get(untdid.ExtKeyReference).String() == ref { + count++ + if count > 1 { + return false + } + } + } + return true +} + +func notesHaveTXD(val any) bool { + notes, ok := val.([]*org.Note) + if !ok || len(notes) == 0 { + return false + } + for _, note := range notes { + if note != nil && !note.Ext.IsZero() { + if code := note.Ext.Get(untdid.ExtKeyTextSubject); code == noteSubjectTXD && note.Text == stcMembreAssujettiUnique { + return true + } + } + } + return false +} + +func notesHaveRequired(val any) bool { + notes, ok := val.([]*org.Note) + if !ok || len(notes) == 0 { + return false + } + required := []cbc.Code{"PMT", "PMD", "AAB"} + counts := make(map[cbc.Code]int) + for _, note := range notes { + if note != nil && !note.Ext.IsZero() { + if code := note.Ext.Get(untdid.ExtKeyTextSubject); code != cbc.CodeEmpty { + counts[code]++ + } + } + } + for _, code := range required { + if counts[code] == 0 { + return false + } + } + return true +} + +func notesNoDuplicates(val any) bool { + notes, ok := val.([]*org.Note) + if !ok || len(notes) == 0 { + return true + } + counts := make(map[cbc.Code]int) + for _, note := range notes { + if note != nil && !note.Ext.IsZero() { + if code := note.Ext.Get(untdid.ExtKeyTextSubject); code != cbc.CodeEmpty { + counts[code]++ + } + } + } + checkUnique := []cbc.Code{"PMT", "PMD", "AAB", "TXD"} + for _, code := range checkUnique { + if counts[code] > 1 { + return false + } + } + return true +} + +func invoiceDueDatesValid(val any) bool { + inv, ok := val.(*bill.Invoice) + if !ok || inv == nil { + return true + } + if inv.Payment == nil || inv.Payment.Terms == nil || len(inv.Payment.Terms.DueDates) == 0 { + return true + } + for _, dd := range inv.Payment.Terms.DueDates { + if dd == nil || dd.Date == nil { + continue + } + if inv.IssueDate.DaysSince(dd.Date.Date) > 0 { + return false + } + } + return true +} + +func finalInvoiceAdvancesMatch(val any) bool { + totals, ok := val.(*bill.Totals) + if !ok || totals == nil || totals.Advances == nil { + return true + } + return totals.Advances.Equals(totals.TotalWithTax) +} + +func finalInvoicePayableZero(val any) bool { + totals, ok := val.(*bill.Totals) + if !ok || totals == nil { + return true + } + if totals.Due != nil { + return totals.Due.Equals(num.AmountZero) + } + return totals.Payable.Equals(num.AmountZero) +} + +func attachmentsUniqueLISIBLE(val any) bool { + attachments, ok := val.([]*org.Attachment) + if !ok || len(attachments) == 0 { + return true + } + count := 0 + for _, att := range attachments { + if att != nil && att.Description == attachmentFormatLisible { + count++ + } + } + return count <= 1 +} + +func toAnySlice(ss []string) []any { + out := make([]any, len(ss)) + for i, s := range ss { + out[i] = s + } + return out +} + +// -- Flow 10 helpers ------------------------------------------------------ + +func invoiceIsB2CAny(v any) bool { + inv, ok := v.(*bill.Invoice) + return ok && invoiceIsB2C(inv) +} + +// invoiceIsCrossBorderB2BAny reports whether a Flow 10 invoice is a +// cross-border B2B transaction. The parent rules.When already +// constrains the branch to "not domestic French" (at least one party +// is non-French), so "has Customer" is the remaining axis: present → +// cross-border B2B, absent → B2C (handled by invoiceIsB2CAny). +func invoiceIsCrossBorderB2BAny(v any) bool { + inv, ok := v.(*bill.Invoice) + return ok && !invoiceIsB2C(inv) +} + +func invoiceVATPercentsAllowed(v any) bool { + inv, ok := v.(*bill.Invoice) + if !ok || inv == nil { + return true + } + for _, line := range inv.Lines { + if line == nil { + continue + } + for _, combo := range line.Taxes { + if combo == nil || combo.Category != tax.CategoryVAT || combo.Percent == nil { + continue + } + if !percentageInList(*combo.Percent, allowedVATPercents) { + return false + } + } + } + return true +} + +func extensionsHaveB2CCategory(v any) bool { + return extValue(v).Get(ExtKeyB2CCategory) != "" +} + +func extensionsHaveBillingMode(v any) bool { + return extValue(v).Get(ExtKeyBillingMode) != "" +} + +func partyHasAllowedLegalScheme(v any) bool { + party, ok := v.(*org.Party) + if !ok || party == nil { + return false + } + return slices.Contains(allowedPartySchemeIDs, partyLegalSchemeID(party)) +} + +func invoiceIsFinalAfterAdvance(v any) bool { + inv, ok := v.(*bill.Invoice) + if !ok || inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { + return false + } + return slices.Contains(finalAfterAdvanceBillingModes, inv.Tax.Ext.Get(ExtKeyBillingMode)) +} + +func invoiceNotAdvancePaymentDocType(v any) bool { + return !slices.Contains(advancePaymentDocumentTypes, extValue(v).Get(untdid.ExtKeyDocumentType)) +} + +// invoiceDocumentTypeAllowed reads the untdid-document-type extension +// set by the scenarios and confirms it is one of the permitted codes. +func invoiceDocumentTypeAllowed(v any) bool { + return slices.Contains(allowedInvoiceDocumentTypes, extValue(v).Get(untdid.ExtKeyDocumentType)) +} + +// invoiceHasSellerVATIDForExempt returns true if either the supplier +// or the ordering.seller (treated as the supplier's tax representative) +// carries a non-empty TaxID code. +func invoiceHasSellerVATIDForExempt(v any) bool { + inv, ok := v.(*bill.Invoice) + if !ok || inv == nil { + return false + } + if partyHasVATCode(inv.Supplier) { + return true + } + if inv.Ordering != nil && partyHasVATCode(inv.Ordering.Seller) { + return true + } + return false +} + +func partyHasVATCode(p *org.Party) bool { + return p != nil && p.TaxID != nil && p.TaxID.Code != "" +} + +// invoiceHasExemptCombo reports whether the invoice has any VAT combo +// whose UNTDID 5305 tax-category extension is "E" (exempt). +func invoiceHasExemptCombo(v any) bool { + inv, ok := v.(*bill.Invoice) + if !ok || inv == nil { + return false + } + for _, line := range inv.Lines { + if line == nil { + continue + } + for _, combo := range line.Taxes { + if combo == nil || combo.Category != tax.CategoryVAT { + continue + } + if combo.Ext.Get(untdid.ExtKeyTaxCategory) == "E" { + return true + } + } + } + return false +} + +// invoiceHasExemptTaxNote checks for at least one tax.Note with +// Key=exempt and non-empty Text. +func invoiceHasExemptTaxNote(v any) bool { + inv, ok := v.(*bill.Invoice) + if !ok || inv == nil || inv.Tax == nil { + return false + } + for _, n := range inv.Tax.Notes { + if n != nil && n.Key == tax.KeyExempt && n.Text != "" { + return true + } + } + return false +} + +func partyHasTaxIDWhenRequired(v any) bool { + party, ok := v.(*org.Party) + if !ok || party == nil { + return true + } + scheme := partyLegalSchemeID(party) + if !slices.Contains(schemeIDsRequiringVAT, scheme) { + return true + } + return party.TaxID != nil && party.TaxID.Code != "" +} diff --git a/addons/fr/ctc/bill_invoice_test.go b/addons/fr/ctc/bill_invoice_test.go new file mode 100644 index 000000000..001b2e3f1 --- /dev/null +++ b/addons/fr/ctc/bill_invoice_test.go @@ -0,0 +1,1704 @@ +package ctc + +import ( + "testing" + + _ "github.com/invopop/gobl/addons/eu/en16931" // Flow 2 ruleset depends on en16931 being registered as an addon. + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/regimes/fr" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// -- Fixtures ------------------------------------------------------------- + +// testInvoiceB2BStandard mirrors the old flow2 fixture but adds the +// eu-en16931-v2017 addon (now a soft Flow 2 requirement enforced by +// rule, not by addon.Requires) and carries the iso-scheme-id ext on +// identities so org_party rule 03 (identitiesSchemeFormatValid) accepts +// them. +func testInvoiceB2BStandard(t *testing.T) *bill.Invoice { + t.Helper() + return &bill.Invoice{ + Regime: tax.WithRegime("FR"), + Addons: tax.WithAddons(V1, "eu-en16931-v2017"), + Code: "FAC-2024-001", + Currency: "EUR", + Type: bill.InvoiceTypeStandard, + Tax: &bill.Tax{ + Ext: tax.ExtensionsOf(cbc.CodeMap{ + ExtKeyBillingMode: BillingModeS1, + untdid.ExtKeyDocumentType: "380", + }), + }, + Supplier: &org.Party{ + Name: "Test Supplier SARL", + TaxID: &tax.Identity{ + Country: "FR", + Code: "39356000000", + }, + Identities: []*org.Identity{ + { + Type: fr.IdentityTypeSIREN, + Code: "356000000", + Scope: org.IdentityScopeLegal, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: identitySchemeIDSIREN, + }), + }, + }, + Addresses: []*org.Address{ + { + Street: "123 Rue de Test", + Code: "75001", + Locality: "Paris", + Country: "FR", + }, + }, + Inboxes: []*org.Inbox{ + { + Key: org.InboxKeyPeppol, + Scheme: cbc.Code("0225"), + Code: "356000000", + }, + }, + }, + Customer: &org.Party{ + Name: "Test Customer SAS", + TaxID: &tax.Identity{ + Country: "FR", + Code: "44732829320", + }, + Identities: []*org.Identity{ + { + Type: fr.IdentityTypeSIREN, + Code: "732829320", + Scope: org.IdentityScopeLegal, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: identitySchemeIDSIREN, + }), + }, + }, + Addresses: []*org.Address{ + { + Street: "456 Avenue du Client", + Code: "69001", + Locality: "Lyon", + Country: "FR", + }, + }, + Inboxes: []*org.Inbox{ + { + Key: org.InboxKeyPeppol, + Scheme: cbc.Code("0225"), + Code: "732829320", + }, + }, + }, + IssueDate: cal.MakeDate(2024, 6, 13), + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(10, 0), + Item: &org.Item{ + Name: "Test Service", + Price: num.NewAmount(10000, 2), + }, + Taxes: tax.Set{ + {Category: "VAT", Rate: "standard"}, + }, + }, + }, + Payment: &bill.PaymentDetails{ + Terms: &pay.Terms{ + Key: pay.TermKeyDueDate, + DueDates: []*pay.DueDate{ + { + Date: cal.NewDate(2024, 7, 13), + Percent: num.NewPercentage(100, 3), + }, + }, + }, + Instructions: &pay.Instructions{ + Key: pay.MeansKeyCreditTransfer, + CreditTransfer: []*pay.CreditTransfer{ + { + IBAN: "FR7630006000011234567890189", + Name: "Test Supplier SARL", + }, + }, + }, + }, + Notes: []*org.Note{ + { + Key: org.NoteKeyPayment, + Text: "Une penalite fixe de 40 EUR sera appliquee.", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyTextSubject: "PMT", + }), + }, + { + Key: org.NoteKeyPaymentMethod, + Text: "Penalites de retard applicables.", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyTextSubject: "PMD", + }), + }, + { + Key: org.NoteKeyPaymentTerm, + Text: "Aucun escompte pour paiement anticipe.", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyTextSubject: "AAB", + }), + }, + }, + } +} + +// testInvoiceB2BFlow10 returns a Flow 10 cross-border B2B fixture +// (French supplier, German customer) so the Flow 10 dispatcher branch +// fires. +func testInvoiceB2BFlow10(t *testing.T) *bill.Invoice { + t.Helper() + return &bill.Invoice{ + Regime: tax.WithRegime("FR"), + Addons: tax.WithAddons(V1), + Code: "INV-2026-001", + Currency: "EUR", + IssueDate: cal.MakeDate(2026, 1, 15), + Type: bill.InvoiceTypeStandard, + Supplier: frPartyWithSIREN(), + Customer: deCustomerWithVATID(), + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "Product", + Price: num.NewAmount(100, 0), + }, + Taxes: tax.Set{ + {Category: tax.CategoryVAT, Percent: num.NewPercentage(20, 2)}, + }, + }, + }, + } +} + +// testInvoiceB2C returns a Flow 10 B2C fixture (no customer). +func testInvoiceB2C(t *testing.T) *bill.Invoice { + t.Helper() + return &bill.Invoice{ + Regime: tax.WithRegime("FR"), + Addons: tax.WithAddons(V1), + Code: "INV-2026-B2C-001", + Currency: "EUR", + IssueDate: cal.MakeDate(2026, 1, 15), + Type: bill.InvoiceTypeStandard, + Tax: &bill.Tax{ + Ext: tax.ExtensionsOf(cbc.CodeMap{ + ExtKeyB2CCategory: B2CCategoryGoods, + }), + }, + Supplier: frPartyWithSIREN(), + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "Product", + Price: num.NewAmount(100, 0), + }, + Taxes: tax.Set{ + {Category: tax.CategoryVAT, Percent: num.NewPercentage(20, 2)}, + }, + }, + }, + } +} + +func setDocumentType(inv *bill.Invoice, docType string) { + if inv.Tax == nil { + inv.Tax = &bill.Tax{} + } + if inv.Tax.Ext.IsZero() { + inv.Tax.Ext = tax.MakeExtensions() + } + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, cbc.Code(docType)) +} + +// ========================================================================= +// Flow 10 invoice tests (ported from addons/fr/ctc/flow10/bill_invoice_test.go) +// ========================================================================= + +func TestInvoiceB2BFlow10HappyPath(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) +} + +func TestInvoiceB2CHappyPath(t *testing.T) { + inv := testInvoiceB2C(t) + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) +} + +func TestInvoiceFlow10CurrencyRequiresEURConversion(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + inv.Currency = "USD" + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "EUR") +} + +func TestInvoiceFlow10CurrencyUSDWithExchangeRate(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + inv.Currency = "USD" + inv.ExchangeRates = []*currency.ExchangeRate{ + {From: "USD", To: "EUR", Amount: num.MakeAmount(875967, 6)}, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) +} + +func TestInvoiceB2BFlow10DocTypeNotAllowed(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "325") // proforma, not allowed + err := rules.Validate(inv) + assert.ErrorContains(t, err, "Flow 10 permitted UNTDID 1001 codes") +} + +func TestInvoiceB2BFlow10MissingBillingMode(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "billing mode") +} + +func TestInvoiceB2BFlow10FinalAfterAdvanceRejectsDepositDocType(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext. + Set(ExtKeyBillingMode, BillingModeM4). + Set(untdid.ExtKeyDocumentType, "386") // Advance payment invoice + err := rules.Validate(inv) + assert.ErrorContains(t, err, "G1.60") +} + +func TestInvoiceB2BFlow10SupplierRequiresAllowedScheme(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + inv.Supplier.Identities = nil + err := rules.Validate(inv) + assert.ErrorContains(t, err, "supplier must declare a legal identity") +} + +func TestInvoiceB2BFlow10AddressRequiresCountry(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + inv.Supplier.Addresses = []*org.Address{{Street: "No country"}} + err := rules.Validate(inv) + assert.ErrorContains(t, err, "supplier address must include country") +} + +func TestInvoiceB2BFlow10CustomerAddressRequiresCountry(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + inv.Customer.Addresses = []*org.Address{{Street: "No country"}} + err := rules.Validate(inv) + assert.ErrorContains(t, err, "customer address must include country") +} + +func TestInvoiceB2BFlow10ExemptRequiresSellerVATID(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + inv.Lines[0].Taxes = tax.Set{ + {Category: tax.CategoryVAT, Key: tax.KeyExempt}, + } + require.NoError(t, inv.Calculate()) + inv.Supplier.TaxID = nil + inv.Ordering = nil + err := rules.Validate(inv) + assert.ErrorContains(t, err, "supplier VAT ID or ordering.seller") +} + +func TestInvoiceB2BFlow10ExemptRequiresExemptTaxNote(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + inv.Lines[0].Taxes = tax.Set{ + {Category: tax.CategoryVAT, Key: tax.KeyExempt}, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "exemption reason") +} + +func TestInvoiceB2BFlow10ExemptHappyWithSellerVATAndNote(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + inv.Lines[0].Taxes = tax.Set{ + {Category: tax.CategoryVAT, Key: tax.KeyExempt}, + } + require.NoError(t, inv.Calculate()) + // supplier already has a TaxID from frPartyWithSIREN. Add an exempt + // tax note so rule 56 is satisfied. + if inv.Tax == nil { + inv.Tax = &bill.Tax{} + } + inv.Tax.Notes = []*tax.Note{ + {Key: tax.KeyExempt, Text: "Exempt under VAT directive art. 138"}, + } + require.NoError(t, rules.Validate(inv)) +} + +// TestInvoiceB2BFlow10ExemptOrderingSellerHasVATID covers the tax +// representative arrangement: a non-EU supplier (scheme 0227, no +// TaxID required by rule 54) sells exempt goods to a French customer +// and the French tax representative carries the VAT ID via +// ordering.seller. Rule 55 must accept the ordering.seller VAT ID in +// lieu of supplier.TaxID. +func TestInvoiceB2BFlow10ExemptOrderingSellerHasVATID(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + // Replace the French supplier with a non-EU one. Scheme 0227 doesn't + // trigger rule 54's TaxID requirement, leaving rule 55 (exempt + // reliance on ordering.seller VAT) as the meaningful check. + inv.Supplier = &org.Party{ + Name: "Foreign Supplier Inc", + // Foreign TaxID — the FR regime requires a tax_id code or a + // SIREN/SIRET identity on the supplier, so we give the foreign + // company its own (non-French) tax ID. The Flow 10 rule 54 + // requirement to carry a TaxID *when scheme is SIREN/EU-VAT* + // does not apply here: the legal identity is non-EU (0227). + TaxID: &tax.Identity{ + Country: "US", + Code: "12-3456789", + }, + Identities: []*org.Identity{ + { + Code: "US-12345", + Scope: org.IdentityScopeLegal, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0227", + }), + }, + }, + Addresses: []*org.Address{{Country: "US"}}, + } + inv.Customer = frCustomerWithSIREN() + inv.Lines[0].Taxes = tax.Set{ + {Category: tax.CategoryVAT, Key: tax.KeyExempt}, + } + inv.Ordering = &bill.Ordering{ + Seller: &org.Party{ + Name: "Représentant Fiscal SARL", + TaxID: &tax.Identity{ + Country: "FR", + Code: "39356000000", + }, + }, + } + require.NoError(t, inv.Calculate()) + if inv.Tax == nil { + inv.Tax = &bill.Tax{} + } + inv.Tax.Notes = []*tax.Note{ + {Key: tax.KeyExempt, Text: "Exempt — represented by French tax rep"}, + } + require.NoError(t, rules.Validate(inv)) +} + +func TestInvoiceB2CDefaultsCategoryToTNT1(t *testing.T) { + inv := testInvoiceB2C(t) + inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyB2CCategory) + require.NoError(t, inv.Calculate()) + assert.Equal(t, B2CCategoryNotTaxable, inv.Tax.Ext.Get(ExtKeyB2CCategory)) + require.NoError(t, rules.Validate(inv)) +} + +func TestInvoiceB2CSupplierRequiresSIREN(t *testing.T) { + inv := testInvoiceB2C(t) + inv.Supplier.TaxID = nil + inv.Supplier.Identities = nil + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "SIREN") +} + +func TestInvoiceB2CVATRateNotInWhitelist(t *testing.T) { + inv := testInvoiceB2C(t) + inv.Lines[0].Taxes = tax.Set{ + {Category: tax.CategoryVAT, Percent: num.NewPercentage(17, 2)}, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "G1.24") +} + +func TestNormalizeFlow10DefaultBillingModeM1(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + require.NoError(t, inv.Calculate()) + assert.Equal(t, BillingModeM1, inv.Tax.Ext.Get(ExtKeyBillingMode)) +} + +func TestNormalizeFlow10TaxCategorySetFromKey(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + inv.Lines[0].Taxes = tax.Set{ + {Category: tax.CategoryVAT, Key: tax.KeyReverseCharge}, + } + require.NoError(t, inv.Calculate()) + combo := inv.Lines[0].Taxes[0] + assert.Equal(t, "AE", combo.Ext.Get(untdid.ExtKeyTaxCategory).String()) +} + +func TestNormalizeFlow10GeneratesSIRENFromFrenchTaxID(t *testing.T) { + inv := testInvoiceB2BFlow10(t) + inv.Supplier.Identities = nil + require.NoError(t, inv.Calculate()) + found := false + for _, id := range inv.Supplier.Identities { + if id.Ext.Get(iso.ExtKeySchemeID).String() == "0002" { + found = true + assert.Equal(t, "356000000", id.Code.String()) + } + } + assert.True(t, found, "expected SIREN identity to be generated from TaxID") +} + +func TestNormalizeB2CGeneratesSIRENFromFrenchTaxID(t *testing.T) { + inv := testInvoiceB2C(t) + inv.Supplier.Identities = nil + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + found := false + for _, id := range inv.Supplier.Identities { + if id.Ext.Get(iso.ExtKeySchemeID).String() == "0002" { + found = true + } + } + assert.True(t, found, "expected SIREN identity to be generated from TaxID for B2C") +} + +// --- Internal helper coverage (Flow 10) --------------------------------- + +func TestPartyHasSIRENWrongType(t *testing.T) { + assert.False(t, partyHasSIREN("x")) +} + +func TestPartyHasAllowedLegalSchemeWrongType(t *testing.T) { + assert.False(t, partyHasAllowedLegalScheme("x")) +} + +func TestPartyHasTaxIDWhenRequiredWrongType(t *testing.T) { + assert.True(t, partyHasTaxIDWhenRequired("x")) +} + +func TestPartyHasTaxIDWhenRequiredNonRequiredScheme(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{ + Code: "X", + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0227"}), + }}} + assert.True(t, partyHasTaxIDWhenRequired(p)) +} + +func TestInvoiceIsCrossBorderB2BWrongType(t *testing.T) { + assert.False(t, invoiceIsCrossBorderB2BAny("x")) +} + +func TestInvoiceIsB2CWrongType(t *testing.T) { + assert.False(t, invoiceIsB2CAny("x")) +} + +func TestInvoiceDocumentTypeAllowedEmpty(t *testing.T) { + assert.False(t, invoiceDocumentTypeAllowed(tax.Extensions{})) +} + +func TestExtensionsHaveBillingModeMissing(t *testing.T) { + assert.False(t, extensionsHaveBillingMode(tax.Extensions{})) +} + +func TestExtensionsHaveB2CCategoryMissing(t *testing.T) { + assert.False(t, extensionsHaveB2CCategory(tax.Extensions{})) +} + +func TestInvoiceIsFinalAfterAdvanceWrongType(t *testing.T) { + assert.False(t, invoiceIsFinalAfterAdvance("x")) +} + +func TestInvoiceIsFinalAfterAdvanceNoExt(t *testing.T) { + assert.False(t, invoiceIsFinalAfterAdvance(&bill.Invoice{Tax: &bill.Tax{}})) +} + +func TestInvoiceNotAdvancePaymentDocTypeWrongType(t *testing.T) { + assert.True(t, invoiceNotAdvancePaymentDocType(42)) +} + +func TestInvoiceHasSellerVATIDForExemptWrongType(t *testing.T) { + assert.False(t, invoiceHasSellerVATIDForExempt("x")) +} + +func TestInvoiceHasExemptComboWrongType(t *testing.T) { + assert.False(t, invoiceHasExemptCombo("x")) +} + +func TestInvoiceHasExemptTaxNoteWrongType(t *testing.T) { + assert.False(t, invoiceHasExemptTaxNote("x")) +} + +func TestInvoiceVATRatesAllowedWrongType(t *testing.T) { + assert.True(t, invoiceVATPercentsAllowed("x")) +} + +func TestMustParsePercentagesPanicsOnBadInput(t *testing.T) { + assert.Panics(t, func() { mustParsePercentages("not-a-percentage") }) +} + +func TestPercentageInListEmpty(t *testing.T) { + p := num.MakePercentage(20, 2) + assert.False(t, percentageInList(p, nil)) +} + +func TestNormalizeInvoiceNilSafe(t *testing.T) { + assert.NotPanics(t, func() { normalizeInvoice(nil) }) +} + +func TestNormalizeBillingModeDefaultsM2WhenPaid(t *testing.T) { + due := num.MakeAmount(0, 2) + inv := &bill.Invoice{ + Totals: &bill.Totals{Due: &due}, + Tax: &bill.Tax{}, + } + normalizeBillingMode(inv) + assert.Equal(t, BillingModeM2, inv.Tax.Ext.Get(ExtKeyBillingMode)) +} + +// ========================================================================= +// Flow 2 invoice tests (ported from addons/fr/ctc/flow2/bill_invoice_test.go) +// ========================================================================= + +func TestInvoiceValidation(t *testing.T) { + t.Run("basic B2B invoice", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("non-EUR currency without exchange rates", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Currency = "USD" + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "EUR") + }) + + t.Run("non-EUR currency with exchange rates", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Currency = "USD" + inv.ExchangeRates = []*currency.ExchangeRate{ + {From: "USD", To: "EUR", Amount: num.MakeAmount(875967, 6)}, + } + require.NoError(t, inv.Calculate()) + assert.NoError(t, rules.Validate(inv)) + }) + + t.Run("invoice code too long", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Code = "THIS-IS-A-VERY-LONG-INVOICE-CODE-THAT-EXCEEDS-35-CHARACTERS" + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "BR-FR-01/02") + }) + + t.Run("invoice code valid special chars", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Code = "INV-2024+001_TEST/A" + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("duplicate note codes not allowed", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Notes = append(inv.Notes, &org.Note{ + Key: org.NoteKeyPayment, + Text: "Duplicate payment terms", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyTextSubject: "PMT", + }), + }) + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "duplicate note codes") + assert.ErrorContains(t, err, "BR-FR-06") + }) + + t.Run("supplier SIREN required (BR-FR-10)", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = []*org.Identity{} + inv.Supplier.TaxID = nil // prevent normalizer from regenerating SIREN + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "SIREN") + }) + + t.Run("B2B non-self-billed requires SIREN inbox (BR-FR-21)", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Inboxes = []*org.Inbox{ + {Scheme: "0088", Code: "1234567890123"}, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "0225") + assert.ErrorContains(t, err, "BR-FR-21") + }) + + t.Run("B2B non-self-billed SIREN inbox must start with SIREN (BR-FR-21)", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Inboxes = []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: "999999999"}, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "0225") + }) + + t.Run("self-billed invoice does not require supplier SIREN inbox start match", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.SetTags(tax.TagSelfBilled) + require.NoError(t, inv.Calculate()) + // docType is 389 now (self-billed). Remove SIREN inbox. + inv.Supplier.Inboxes = []*org.Inbox{ + {Scheme: "0088", Code: "1234567890123"}, + } + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("B2B self-billed requires customer SIREN inbox", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.SetTags(tax.TagSelfBilled) + require.NoError(t, inv.Calculate()) + inv.Customer.Inboxes = []*org.Inbox{ + {Scheme: "0088", Code: "1234567890123"}, + } + err := rules.Validate(inv) + assert.ErrorContains(t, err, "0225") + }) + + t.Run("B2B self-billed customer SIREN inbox must start with SIREN", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.SetTags(tax.TagSelfBilled) + require.NoError(t, inv.Calculate()) + inv.Customer.Inboxes = []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: "999999999"}, + } + err := rules.Validate(inv) + assert.ErrorContains(t, err, "0225") + }) +} + +func TestDocumentTypeValidation(t *testing.T) { + t.Run("valid document type", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "380", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("invalid document type", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "999") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "BR-FR-04") + }) +} + +func TestDocumentTypeScenarios(t *testing.T) { + t.Run("standard invoice", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "380", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) + + t.Run("factoring invoice", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.SetTags(tax.TagFactoring) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "393", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) + + t.Run("advance payment invoice", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.SetTags(tax.TagPrepayment) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "386", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) + + t.Run("self-billed invoice", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.SetTags(tax.TagSelfBilled) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "389", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) + + t.Run("credit note", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Type = bill.InvoiceTypeCreditNote + require.NoError(t, inv.Calculate()) + assert.Equal(t, "381", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) + + t.Run("self-billed credit note", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Type = bill.InvoiceTypeCreditNote + inv.SetTags(tax.TagSelfBilled) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "261", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) + + t.Run("corrective invoice", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Type = bill.InvoiceTypeCorrective + require.NoError(t, inv.Calculate()) + assert.Equal(t, "384", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) + + t.Run("factoring credit note", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Type = bill.InvoiceTypeCreditNote + inv.SetTags(tax.TagFactoring) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "396", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) + }) +} + +func TestBillingModeNormalization(t *testing.T) { + t.Run("user-specified billing mode preserved", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS5) + require.NoError(t, inv.Calculate()) + assert.Equal(t, BillingModeS5.String(), inv.Tax.Ext.Get(ExtKeyBillingMode).String()) + }) +} + +func TestAttachmentValidation(t *testing.T) { + t.Run("valid attachment description - LISIBLE", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Attachments = []*org.Attachment{ + {Code: "ATT001", Description: "LISIBLE", URL: "https://example.com/invoice.pdf"}, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("valid attachment description - RIB", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Attachments = []*org.Attachment{ + {Code: "ATT001", Description: "RIB", URL: "https://example.com/rib.pdf"}, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("invalid attachment description", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Attachments = []*org.Attachment{ + {Code: "ATT001", Description: "INVALID_TYPE", URL: "https://example.com/doc.pdf"}, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "BR-FR-17") + }) + + t.Run("multiple LISIBLE attachments", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Attachments = []*org.Attachment{ + {Code: "ATT001", Description: "LISIBLE", URL: "https://example.com/invoice1.pdf"}, + {Code: "ATT002", Description: "LISIBLE", URL: "https://example.com/invoice2.pdf"}, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "only one attachment with description 'LISIBLE'") + assert.ErrorContains(t, err, "BR-FR-18") + }) + + t.Run("nil attachments handled gracefully", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + var att *org.Attachment + inv.Attachments = []*org.Attachment{ + att, + {Code: "ATT001", Description: "LISIBLE", URL: "https://example.com/invoice.pdf"}, + att, + {Code: "ATT002", Description: "RIB", URL: "https://example.com/rib.pdf"}, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestOrderingIdentitiesValidation(t *testing.T) { + t.Run("valid ordering with one AFL reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Ordering = &bill.Ordering{ + Identities: []*org.Identity{ + {Code: "12345", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AFL"})}, + }, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("valid ordering with one AWW reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Ordering = &bill.Ordering{ + Identities: []*org.Identity{ + {Code: "12345", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AWW"})}, + }, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("valid ordering with one AFL and one AWW", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Ordering = &bill.Ordering{ + Identities: []*org.Identity{ + {Code: "12345", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AFL"})}, + {Code: "67890", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AWW"})}, + }, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("invalid ordering with duplicate AFL reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Ordering = &bill.Ordering{ + Identities: []*org.Identity{ + {Code: "12345", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AFL"})}, + {Code: "67890", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AFL"})}, + }, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "AFL") + assert.ErrorContains(t, err, "BR-FR-30") + }) + + t.Run("invalid ordering with duplicate AWW reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Ordering = &bill.Ordering{ + Identities: []*org.Identity{ + {Code: "12345", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AWW"})}, + {Code: "67890", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyReference: "AWW"})}, + }, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "AWW") + assert.ErrorContains(t, err, "BR-FR-30") + }) + + t.Run("ordering without identities is valid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Ordering = &bill.Ordering{Code: "ORD-12345"} + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestConsolidatedCreditNoteValidation(t *testing.T) { + t.Run("valid consolidated credit note with delivery and contract", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Delivery = &bill.DeliveryDetails{ + Period: &cal.Period{ + Start: cal.MakeDate(2024, 5, 1), + End: cal.MakeDate(2024, 5, 31), + }, + } + inv.Ordering = &bill.Ordering{ + Contracts: []*org.DocumentRef{{Code: "CONTRACT-001"}}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "262") + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("consolidated credit note without delivery is invalid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Delivery = nil + inv.Ordering = &bill.Ordering{ + Contracts: []*org.DocumentRef{{Code: "CONTRACT-001"}}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "262") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "delivery details are required") + assert.ErrorContains(t, err, "BR-FR-CO-03") + }) + + t.Run("consolidated credit note without delivery period", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Delivery = &bill.DeliveryDetails{} + inv.Ordering = &bill.Ordering{ + Contracts: []*org.DocumentRef{{Code: "CONTRACT-001"}}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "262") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "delivery period is required") + assert.ErrorContains(t, err, "BR-FR-CO-03") + }) + + t.Run("consolidated credit note without ordering contracts", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Delivery = &bill.DeliveryDetails{ + Period: &cal.Period{ + Start: cal.MakeDate(2024, 5, 1), + End: cal.MakeDate(2024, 5, 31), + }, + } + inv.Ordering = &bill.Ordering{Contracts: nil} + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "262") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "ordering.contracts") + assert.ErrorContains(t, err, "BR-FR-CO-03") + }) + + t.Run("consolidated credit note with nil ordering", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Delivery = &bill.DeliveryDetails{ + Period: &cal.Period{ + Start: cal.MakeDate(2024, 5, 1), + End: cal.MakeDate(2024, 5, 31), + }, + } + inv.Ordering = nil + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "262") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "BR-FR-CO-03") + }) + + t.Run("non-consolidated credit note does not require delivery or contracts", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Delivery = nil + inv.Ordering = nil + inv.Preceding = []*org.DocumentRef{ + {Code: "INV-001", IssueDate: cal.NewDate(2024, 5, 1)}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "381") + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestSTCSupplierValidation(t *testing.T) { + t.Run("STC supplier happy path", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Ordering = &bill.Ordering{ + Seller: &org.Party{ + Name: "Assujetti Unique", + TaxID: inv.Supplier.TaxID, + }, + } + require.NoError(t, inv.Calculate()) + // normalizeSTCNote auto-appends the TXD / MEMBRE_ASSUJETTI_UNIQUE note. + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("STC supplier seller missing tax ID", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Ordering = &bill.Ordering{ + Seller: &org.Party{Name: "Assujetti Unique", TaxID: nil}, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "tax ID is required when supplier is under STC scheme") + }) + + t.Run("STC supplier seller with empty tax ID code", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Ordering = &bill.Ordering{ + Seller: &org.Party{ + Name: "Assujetti Unique", + TaxID: &tax.Identity{Country: "FR", Code: ""}, + }, + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "code is required when supplier is under STC scheme") + }) + + t.Run("STC supplier with nil ordering", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Ordering = nil + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "BR-FR-CO-15") + }) + + t.Run("STC supplier requires TXD note", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Ordering = &bill.Ordering{ + Seller: &org.Party{ + Name: "Assujetti Unique", + TaxID: inv.Supplier.TaxID, + }, + } + require.NoError(t, inv.Calculate()) + // Strip the auto-added TXD note. + kept := inv.Notes[:0] + for _, n := range inv.Notes { + if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectTXD { + continue + } + kept = append(kept, n) + } + inv.Notes = kept + err := rules.Validate(inv) + assert.ErrorContains(t, err, string(noteSubjectTXD)) + assert.ErrorContains(t, err, stcMembreAssujettiUnique) + }) + + t.Run("STC supplier normalizer auto-fills TXD note", func(t *testing.T) { + // Exercise normalizeSTCNote directly to avoid the org_party rule 04 + // rejection of the 0231 identity scheme. + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + // Drop the auto-added TXD note in case the fixture already has one. + inv.Notes = inv.Notes[:0] + normalizeSTCNote(inv) + var found bool + for _, n := range inv.Notes { + if n.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectTXD && n.Text == stcMembreAssujettiUnique { + found = true + break + } + } + assert.True(t, found, "expected normalizer to add TXD note") + }) + + t.Run("normalizeSTCNote is idempotent when TXD already present", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Notes = []*org.Note{{ + Key: org.NoteKeyLegal, + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), + }} + normalizeSTCNote(inv) + assert.Len(t, inv.Notes, 1) + }) + + t.Run("normalizeSTCNote no-op when supplier has no STC scheme", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + before := len(inv.Notes) + normalizeSTCNote(inv) + assert.Len(t, inv.Notes, before) + }) +} + +func TestFinalInvoicePaymentValidation(t *testing.T) { + t.Run("final invoice B2 with nil payment should fail", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) + inv.Payment = nil + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "payment") + }) + + t.Run("final invoice S2 with nil payment should fail", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS2) + inv.Payment = nil + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "payment") + }) + + t.Run("final invoice M2 with nil payment should fail", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeM2) + inv.Payment = nil + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "payment") + }) +} + +func TestPrecedingReferencesValidation(t *testing.T) { + t.Run("corrective invoice with exactly one preceding reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = []*org.DocumentRef{ + {Code: "INV-001", IssueDate: cal.NewDate(2024, 5, 1)}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "384") + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("corrective invoice with no preceding reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = nil + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "384") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "must reference the original invoice in preceding") + assert.ErrorContains(t, err, "BR-FR-CO-04") + }) + + t.Run("corrective invoice with multiple preceding references", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = []*org.DocumentRef{ + {Code: "INV-001", IssueDate: cal.NewDate(2024, 5, 1)}, + {Code: "INV-002", IssueDate: cal.NewDate(2024, 5, 2)}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "384") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "must reference exactly one preceding invoice") + assert.ErrorContains(t, err, "BR-FR-CO-04") + }) + + t.Run("corrective invoice type 471 requires one preceding reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = []*org.DocumentRef{ + {Code: "INV-001", IssueDate: cal.NewDate(2024, 5, 1)}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "471") + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("credit note with at least one preceding reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = []*org.DocumentRef{ + {Code: "INV-001", IssueDate: cal.NewDate(2024, 5, 1)}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "381") + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("credit note with multiple preceding references is valid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = []*org.DocumentRef{ + {Code: "INV-001", IssueDate: cal.NewDate(2024, 5, 1)}, + {Code: "INV-002", IssueDate: cal.NewDate(2024, 5, 2)}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "381") + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("credit note with no preceding reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = nil + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "381") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "at least one preceding invoice reference") + assert.ErrorContains(t, err, "BR-FR-CO-05") + }) + + t.Run("credit note type 261 with preceding", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = []*org.DocumentRef{ + {Code: "INV-001", IssueDate: cal.NewDate(2024, 5, 1)}, + } + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "261") + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("credit note type 502 with no preceding", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = nil + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "502") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "BR-FR-CO-05") + }) + + t.Run("standard invoice does not require preceding reference", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Preceding = nil + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "380") + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestPaymentDueDateValidation(t *testing.T) { + t.Run("valid due date on or after issue date", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.IssueDate = cal.MakeDate(2024, 6, 1) + inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 7, 1) + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("valid due date same as issue date", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.IssueDate = cal.MakeDate(2024, 6, 1) + inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("invalid due date before issue date", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.IssueDate = cal.MakeDate(2024, 6, 15) + inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "due dates must not be before invoice issue date") + }) + + t.Run("advance payment type 386 allows due date before issue date", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.IssueDate = cal.MakeDate(2024, 6, 15) + inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) + require.NoError(t, inv.Calculate()) + setDocumentType(inv, "386") + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("no due date is valid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.IssueDate = cal.MakeDate(2024, 6, 1) + inv.Payment.Terms.DueDates = nil + inv.Payment.Terms.Notes = "Payment on delivery" + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestBillingModeDocumentTypeCompatibility(t *testing.T) { + t.Run("factoring B4 with advance payment type 386 is invalid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB4) + setDocumentType(inv, "386") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "advance payment document types") + }) + + t.Run("factoring S4 with advance payment type 500 is invalid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS4) + setDocumentType(inv, "500") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "advance payment document types") + }) + + t.Run("factoring M4 with advance payment type 503 is invalid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeM4) + setDocumentType(inv, "503") + err := rules.Validate(inv) + assert.ErrorContains(t, err, "advance payment document types") + }) + + t.Run("factoring B4 with standard 380 is valid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB4) + setDocumentType(inv, "380") + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestFinalInvoiceValidation(t *testing.T) { + t.Run("valid final invoice B2 fully paid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) + totalWithTax := inv.Totals.TotalWithTax + inv.Totals.Advances = &totalWithTax + zero := num.MakeAmount(0, 2) + inv.Totals.Payable = zero + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("final invoice B2 without advance amount is invalid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) + inv.Totals.Advances = nil + err := rules.Validate(inv) + assert.ErrorContains(t, err, "advance amount is required for already-paid invoices") + }) + + t.Run("final invoice B2 with incorrect advance amount is invalid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) + wrongAmount := num.MakeAmount(5000, 2) + inv.Totals.Advances = &wrongAmount + err := rules.Validate(inv) + assert.ErrorContains(t, err, "advance amount must equal total with tax") + }) + + t.Run("final invoice S2 with non-zero payable amount is invalid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS2) + totalWithTax := inv.Totals.TotalWithTax + inv.Totals.Advances = &totalWithTax + nonZero := num.MakeAmount(100, 2) + inv.Totals.Due = &nonZero + err := rules.Validate(inv) + assert.ErrorContains(t, err, "payable amount must be zero") + }) + + t.Run("final invoice M2 without due date", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeM2) + totalWithTax := inv.Totals.TotalWithTax + inv.Totals.Advances = &totalWithTax + zero := num.MakeAmount(0, 2) + inv.Totals.Payable = zero + inv.Payment.Terms.DueDates = nil + inv.Payment.Terms.Notes = "Payment already made" + err := rules.Validate(inv) + assert.ErrorContains(t, err, "at least one due date required") + }) + + t.Run("non-final invoice B7 does not require these validations", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB7) + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestAdditionalDocumentTypes(t *testing.T) { + t.Run("471 prepaid amount invoice", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "471") + inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("473 standalone credit note", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "473") + inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("502 self-billed corrective", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "502") + inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("503 self-billed credit for claim", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "503") + inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("472 self-billed prepaid", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "472") + inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) + + t.Run("261 self-billed credit note", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "261") + inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + }) +} + +func TestInvoiceNormalization(t *testing.T) { + ad := tax.AddonForKey(V1) + + t.Run("normalizes invoice with existing tax sets currency rounding (Flow 2)", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + ad.Normalizer(inv) + assert.Equal(t, tax.RoundingRuleCurrency, inv.Tax.Rounding) + }) + + t.Run("normalizes nil invoice", func(t *testing.T) { + var inv *bill.Invoice + ad.Normalizer(inv) + assert.Nil(t, inv) + }) +} + +// --- Defensive nil / wrong-type branches --------------------------------- + +func TestIsSelfBilledInvoiceNilInvoice(t *testing.T) { + assert.False(t, isSelfBilledInvoice(nil)) +} + +func TestIsSelfBilledInvoiceMissingDocType(t *testing.T) { + inv := &bill.Invoice{Tax: &bill.Tax{Ext: tax.ExtensionsOf(cbc.CodeMap{"other": "x"})}} + assert.False(t, isSelfBilledInvoice(inv)) +} + +func TestIsCorrectiveInvoiceNilInvoice(t *testing.T) { + assert.False(t, isCorrectiveInvoice(nil)) +} + +func TestGetPartySIRENNilParty(t *testing.T) { + assert.Equal(t, "", getPartySIREN(nil)) +} + +func TestPrecedingDocCodeValidWrongType(t *testing.T) { + assert.True(t, precedingDocCodeValid(42)) +} + +func TestIdentitiesHasLegalSIRENWrongType(t *testing.T) { + assert.True(t, identitiesHasLegalSIREN(42)) +} + +func TestPartyHasSIRENInboxWrongType(t *testing.T) { + assert.True(t, partyHasSIRENInbox(42)) +} + +func TestOrderingIdentitiesNoDupAFLWrongType(t *testing.T) { + assert.True(t, orderingIdentitiesNoDupAFL(42)) +} + +func TestOrderingIdentitiesNoDupAWWWrongType(t *testing.T) { + assert.True(t, orderingIdentitiesNoDupAWW(42)) +} + +func TestNotesHaveTXDWrongType(t *testing.T) { + assert.False(t, notesHaveTXD(42)) +} + +func TestNotesHaveRequiredWrongType(t *testing.T) { + assert.False(t, notesHaveRequired(42)) +} + +func TestNotesNoDuplicatesWrongType(t *testing.T) { + assert.True(t, notesNoDuplicates(42)) +} + +func TestInvoiceDueDatesValidWrongType(t *testing.T) { + assert.True(t, invoiceDueDatesValid(42)) +} + +func TestFinalInvoicePayableZeroWrongType(t *testing.T) { + assert.True(t, finalInvoicePayableZero(42)) +} + +func TestFinalInvoiceAdvancesMatchWrongType(t *testing.T) { + assert.True(t, finalInvoiceAdvancesMatch(42)) +} + +func TestAttachmentsUniqueLISIBLEEmpty(t *testing.T) { + assert.True(t, attachmentsUniqueLISIBLE([]*org.Attachment{})) +} + +func TestAttachmentsUniqueLISIBLEWrongType(t *testing.T) { + assert.True(t, attachmentsUniqueLISIBLE(42)) +} + +func TestInvoiceIsDomesticFrenchNil(t *testing.T) { + assert.False(t, invoiceIsDomesticFrench(nil)) +} + +func TestInvoiceIsDomesticFrenchAnyWrongType(t *testing.T) { + assert.False(t, invoiceIsDomesticFrenchAny("x")) +} + +func TestInvoiceIsNotDomesticFrenchAnyWrongType(t *testing.T) { + assert.False(t, invoiceIsNotDomesticFrenchAny("x")) +} + +func TestInvoiceMissingEN16931OnFlow2(t *testing.T) { + inv := testInvoiceB2BStandard(t) + require.NoError(t, inv.Calculate()) + // Drop the en16931 addon to simulate a caller forgetting to declare it. + inv.Addons = tax.WithAddons(V1) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "eu-en16931-v2017") +} + +// --- defensive coverage: nil / wrong-type / empty-slice guards -------- + +func TestInvoiceCodeValidNonInvoice(t *testing.T) { + assert.True(t, invoiceCodeValid(42)) +} + +func TestInvoiceCodeValidEmptyCode(t *testing.T) { + assert.True(t, invoiceCodeValid(&bill.Invoice{})) +} + +func TestPrecedingDocCodeValidNonDocumentRef(t *testing.T) { + assert.True(t, precedingDocCodeValid(42)) +} + +func TestPrecedingDocCodeValidNil(t *testing.T) { + assert.True(t, precedingDocCodeValid((*org.DocumentRef)(nil))) +} + +func TestInvoiceIsFactoringAnyNonInvoice(t *testing.T) { + assert.False(t, invoiceIsFactoringAny(42)) +} + +func TestInvoiceIsFactoringAnyEmptyTax(t *testing.T) { + assert.False(t, invoiceIsFactoringAny(&bill.Invoice{})) +} + +func TestIsCorrectiveInvoiceEmptyExt(t *testing.T) { + assert.False(t, isCorrectiveInvoice(&bill.Invoice{Tax: &bill.Tax{}})) +} + +func TestIsCreditNoteEmptyExt(t *testing.T) { + assert.False(t, isCreditNote(&bill.Invoice{Tax: &bill.Tax{}})) +} + +func TestIsConsolidatedCreditNoteEmptyExt(t *testing.T) { + assert.False(t, isConsolidatedCreditNote(&bill.Invoice{Tax: &bill.Tax{}})) +} + +func TestIsAdvancedInvoiceEmptyExt(t *testing.T) { + assert.False(t, isAdvancedInvoice(&bill.Invoice{Tax: &bill.Tax{}})) +} + +func TestIsFinalInvoiceEmptyExt(t *testing.T) { + assert.False(t, isFinalInvoice(&bill.Invoice{Tax: &bill.Tax{}})) +} + +func TestIsPartyIdentitySTCNilIdentity(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{nil}} + assert.False(t, isPartyIdentitySTC(p)) +} + +func TestIsPartyIdentitySTCEmptyExt(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{Code: "X"}}} + assert.False(t, isPartyIdentitySTC(p)) +} + +func TestGetPartySIRENEmpty(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{Code: "X"}}} + assert.Equal(t, "", getPartySIREN(p)) +} + +func TestIdentitiesHasLegalSIRENNilEntry(t *testing.T) { + assert.False(t, identitiesHasLegalSIREN([]*org.Identity{nil})) +} + +func TestPartyHasSIRENInboxNoSIREN(t *testing.T) { + p := &org.Party{Inboxes: []*org.Inbox{{Scheme: inboxSchemeSIREN, Code: "X"}}} + assert.True(t, partyHasSIRENInbox(p)) +} + +func TestOrderingIdentitiesNoDupWrongType(t *testing.T) { + assert.True(t, orderingIdentitiesNoDup("x", "AFL")) +} + +func TestOrderingIdentitiesNoDupNilEntry(t *testing.T) { + assert.True(t, orderingIdentitiesNoDup([]*org.Identity{nil}, "AFL")) +} + +func TestNotesHaveRequiredEmpty(t *testing.T) { + assert.False(t, notesHaveRequired([]*org.Note{})) +} + +func TestNotesHaveRequiredNilEntry(t *testing.T) { + assert.False(t, notesHaveRequired([]*org.Note{nil})) +} + +func TestInvoiceHasNoteWithSubjectNilNote(t *testing.T) { + inv := &bill.Invoice{Notes: []*org.Note{nil}} + assert.False(t, invoiceHasNoteWithSubject(inv, "PMT")) +} + +func TestNormalizeRequiredNotesNoOpWhenPresent(t *testing.T) { + inv := &bill.Invoice{ + Notes: []*org.Note{ + {Key: org.NoteKeyPayment, Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMT"})}, + {Key: org.NoteKeyPaymentMethod, Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMD"})}, + {Key: org.NoteKeyPaymentTerm, Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "AAB"})}, + }, + } + before := len(inv.Notes) + normalizeRequiredNotes(inv) + assert.Equal(t, before, len(inv.Notes)) +} + +func TestNormalizeB2CCategoryOnInvoicePreservesExisting(t *testing.T) { + inv := &bill.Invoice{Tax: &bill.Tax{ + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyB2CCategory: B2CCategoryGoods}), + }} + normalizeB2CCategoryOnInvoice(inv) + assert.Equal(t, B2CCategoryGoods, inv.Tax.Ext.Get(ExtKeyB2CCategory)) +} + +func TestNormalizeInvoiceTaxCategoriesNilLine(t *testing.T) { + inv := &bill.Invoice{Lines: []*bill.Line{nil}} + assert.NotPanics(t, func() { normalizeInvoiceTaxCategories(inv) }) +} + +func TestNormalizeInvoiceTaxCategoriesNilCombo(t *testing.T) { + inv := &bill.Invoice{Lines: []*bill.Line{{Taxes: tax.Set{nil}}}} + assert.NotPanics(t, func() { normalizeInvoiceTaxCategories(inv) }) +} diff --git a/addons/fr/ctc/bill_invoices.go b/addons/fr/ctc/bill_invoices.go deleted file mode 100644 index 11eeb7907..000000000 --- a/addons/fr/ctc/bill_invoices.go +++ /dev/null @@ -1,560 +0,0 @@ -package ctc - -import ( - "slices" - "strings" - - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/catalogues/iso" - "github.com/invopop/gobl/catalogues/untdid" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/currency" - "github.com/invopop/gobl/num" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/rules" - "github.com/invopop/gobl/rules/is" - "github.com/invopop/gobl/tax" -) - -func billInvoiceRules() *rules.Set { - return rules.For(new(bill.Invoice), - rules.Assert("42", "invoice must be in EUR or provide exchange rate for conversion", currency.CanConvertTo(currency.EUR)), - // Invoice code validation (BR-FR-01/02) - cross-field: series + code - rules.Assert("01", "must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", - is.Func("valid invoice code", invoiceCodeValid), - ), - // Preceding document code validation - rules.Field("preceding", - rules.Each( - rules.Assert("02", "preceding code must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", - is.Func("valid preceding code", precedingDocCodeValid), - ), - ), - ), - // Corrective invoice preceding (BR-FR-CO-04) - rules.When( - is.Func("corrective invoice", invoiceIsCorrectiveAny), - rules.Field("preceding", - rules.Assert("03", "corrective invoices must have exactly one preceding invoice reference (BR-FR-CO-04)", - is.Present, - ), - rules.Assert("04", "corrective invoices must have exactly one preceding invoice reference (BR-FR-CO-04)", - is.Length(1, 1), - ), - ), - ), - // Credit note preceding (BR-FR-CO-05) - rules.When( - is.Func("credit note", invoiceIsCreditNoteAny), - rules.Field("preceding", - rules.Assert("05", "credit notes must have at least one preceding invoice reference (BR-FR-CO-05)", - is.Present, - ), - ), - ), - // Tax validation - rules.Field("tax", - rules.Assert("06", "tax is required", is.Present), - rules.Field("ext", - rules.Assert("07", "UNTDID document type must be valid (BR-FR-04)", - tax.ExtensionsHasCodes(untdid.ExtKeyDocumentType, allowedDocumentTypes...), - ), - rules.Assert("08", "billing mode extension is required", - tax.ExtensionsRequire(ExtKeyBillingMode), - ), - ), - ), - // Factoring restriction (BR-FR-CO-08) - rules.When( - is.Func("factoring mode", invoiceIsFactoringAny), - rules.Field("tax", - rules.Field("ext", - rules.Assert("09", "advance payment document types not allowed for factoring billing modes (BR-FR-CO-08)", - tax.ExtensionsExcludeCodes(untdid.ExtKeyDocumentType, advancePaymentDocumentTypes...), - ), - ), - ), - ), - // Supplier validation - rules.Field("supplier", - rules.Field("inboxes", - rules.Assert("10", "seller electronic address required for French B2B invoices (BR-FR-13)", - is.Present, - ), - ), - rules.Field("identities", - rules.Assert("11", "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", - is.Func("has SIREN", identitiesHasSIREN), - ), - ), - ), - // Supplier SIREN inbox for B2B non-self-billed (BR-FR-21) - rules.When( - is.Func("B2B non-self-billed", invoiceIsB2BNonSelfBilledAny), - rules.Field("supplier", - rules.Assert("12", "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", - is.Func("has SIREN inbox", partyHasSIRENInbox), - ), - ), - ), - // Customer validation - rules.Field("customer", - rules.Field("inboxes", - rules.Assert("13", "buyer electronic address required for French B2B invoices (BR-FR-13)", - is.Present, - ), - ), - ), - // B2B customer requires SIREN (BR-FR-14) - rules.When( - is.Func("B2B transaction", invoiceIsB2BAny), - rules.Field("customer", - rules.Field("identities", - rules.Assert("14", "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", - is.Func("has SIREN", identitiesHasSIREN), - ), - ), - ), - ), - // B2B self-billed customer SIREN inbox (BR-FR-22) - rules.When( - is.Func("B2B self-billed", invoiceIsB2BSelfBilledAny), - rules.Field("customer", - rules.Assert("15", "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", - is.Func("has SIREN inbox", partyHasSIRENInbox), - ), - ), - ), - // Ordering validation (BR-FR-30) - rules.Field("ordering", - rules.Field("identities", - rules.Assert("16", "only one ordering identity with UNTDID reference 'AFL' is allowed (BR-FR-30)", - is.Func("no dup AFL", orderingIdentitiesNoDupAFL), - ), - rules.Assert("17", "only one ordering identity with UNTDID reference 'AWW' is allowed (BR-FR-30)", - is.Func("no dup AWW", orderingIdentitiesNoDupAWW), - ), - ), - ), - // STC supplier ordering (BR-FR-CO-15) - rules.When( - is.Func("supplier STC", invoiceSupplierIsSTC), - rules.Field("ordering", - rules.Assert("18", "ordering with seller is required when supplier is under STC scheme (BR-FR-CO-15)", - is.Present, - ), - rules.Field("seller", - rules.Assert("19", "seller is required when supplier is under STC scheme (BR-FR-CO-15)", - is.Present, - ), - rules.Field("tax_id", - rules.Assert("20", "tax ID is required when supplier is under STC scheme (BR-FR-CO-15)", - is.Present, - ), - rules.Field("code", - rules.Assert("21", "code is required when supplier is under STC scheme (BR-FR-CO-15)", - is.Present, - ), - ), - ), - ), - ), - // TXD note requirement (BR-FR-CO-14) - rules.Field("notes", - rules.Assert("22", "for sellers with STC scheme (0231), a note with code 'TXD' and text 'MEMBRE_ASSUJETTI_UNIQUE' is required (BR-FR-CO-14)", - is.Func("has TXD note", notesHaveTXD), - ), - ), - ), - // Consolidated credit note ordering (BR-FR-CO-03) - rules.When( - is.Func("consolidated credit note", invoiceIsConsolidatedCreditNoteAny), - rules.Field("ordering", - rules.Assert("23", "ordering with contracts is required for consolidated credit notes (BR-FR-CO-03)", - is.Present, - ), - rules.Field("contracts", - rules.Assert("24", "at least one contract reference is required in ordering details for consolidated credit notes (BR-FR-CO-03)", - is.Present, - ), - rules.Assert("25", "at least one contract reference is required in ordering details for consolidated credit notes (BR-FR-CO-03)", - is.Length(1, 0), - ), - ), - ), - rules.Field("delivery", - rules.Assert("26", "delivery details are required for consolidated credit notes (BR-FR-CO-03)", - is.Present, - ), - rules.Field("period", - rules.Assert("27", "delivery period is required for consolidated credit notes (BR-FR-CO-03)", - is.Present, - ), - ), - ), - ), - // Payment due date validation (BR-FR-CO-07) - rules.When( - is.Func("not advance or final", invoiceIsNotAdvanceOrFinalAny), - rules.Assert("28", "due dates must not be before invoice issue date (BR-FR-CO-07)", - is.Func("due dates valid", invoiceDueDatesValid), - ), - ), - // Final invoice payment (BR-FR-CO-09) - rules.When( - is.Func("final invoice", invoiceIsFinalAny), - rules.Field("payment", - rules.Assert("29", "payment details are required for final invoices (BR-FR-CO-09)", - is.Present, - ), - rules.Field("terms", - rules.Assert("30", "payment terms required for final invoices (BR-FR-CO-09)", - is.Present, - ), - rules.Field("due_dates", - rules.Assert("31", "at least one due date required for final invoices (BR-FR-CO-09)", - is.Present, - ), - ), - ), - ), - // Totals for final invoices - rules.Field("totals", - rules.Field("advance", - rules.Assert("32", "advance amount is required for already-paid invoices (BR-FR-CO-09)", - is.Present, - ), - ), - rules.Assert("33", "advance amount must equal total with tax for final invoices (BR-FR-CO-09)", - is.Func("advances match", finalInvoiceAdvancesMatch), - ), - rules.Assert("34", "payable amount must be zero for final invoices (BR-FR-CO-09)", - is.Func("payable zero", finalInvoicePayableZero), - ), - ), - ), - // Notes validation - rules.Field("notes", - rules.Assert("35", "notes are required for French CTC invoices (BR-FR-05)", is.Present), - rules.Assert("36", "missing required note codes (BR-FR-05)", - is.Func("has required notes", notesHaveRequired), - ), - rules.Assert("37", "duplicate note codes found (BR-FR-06/BR-FR-30)", - is.Func("no duplicate notes", notesNoDuplicates), - ), - rules.Assert("38", "BAR note text must be one of: B2B, B2BINT, B2C, OUTOFSCOPE, ARCHIVEONLY", - is.Func("valid BAR text", notesValidBARText), - ), - ), - // Attachment validation - rules.Field("attachments", - rules.Each( - rules.Field("description", - rules.Assert("39", "must be one of the allowed attachment descriptions (BR-FR-17)", - is.Present, - ), - rules.Assert("40", "must be one of the allowed attachment descriptions (BR-FR-17)", - is.In(toAnySlice(allowedAttachmentDescriptions)...), - ), - ), - ), - rules.Assert("41", "only one attachment with description 'LISIBLE' is allowed per invoice (BR-FR-18)", - is.Func("unique LISIBLE", attachmentsUniqueLISIBLE), - ), - ), - ) -} - -// toAnySlice converts a []string to []any for is.In -func toAnySlice(ss []string) []any { - out := make([]any, len(ss)) - for i, s := range ss { - out[i] = s - } - return out -} - -// --- Invoice-level helper functions --- - -func invoiceCodeValid(val any) bool { - inv, ok := val.(*bill.Invoice) - if !ok || inv == nil || inv.Code == cbc.CodeEmpty { - return true // let required validation handle empty - } - invoiceID := string(inv.Code) - if inv.Series != cbc.CodeEmpty { - invoiceID = string(inv.Series.Join(inv.Code)) - } - return invoiceCodeRegexp.MatchString(invoiceID) -} - -func precedingDocCodeValid(val any) bool { - docRef, ok := val.(*org.DocumentRef) - if !ok || docRef == nil || docRef.Code == cbc.CodeEmpty { - return true - } - invoiceID := string(docRef.Code) - if docRef.Series != cbc.CodeEmpty { - invoiceID = string(docRef.Series.Join(docRef.Code)) - } - return invoiceCodeRegexp.MatchString(invoiceID) -} - -func invoiceIsCorrectiveAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && isCorrectiveInvoice(inv) -} - -func invoiceIsCreditNoteAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && isCreditNote(inv) -} - -func invoiceIsFactoringAny(val any) bool { - inv, ok := val.(*bill.Invoice) - if !ok || inv == nil || inv.Tax == nil || inv.Tax.Ext.IsZero() { - return false - } - return isFactoringExtension(inv.Tax.Ext.Get(ExtKeyBillingMode)) -} - -func invoiceIsB2BAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && isB2BTransaction(inv) -} - -func invoiceIsB2BNonSelfBilledAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && isB2BTransaction(inv) && !isSelfBilledInvoice(inv) -} - -func invoiceIsB2BSelfBilledAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && isB2BTransaction(inv) && isSelfBilledInvoice(inv) -} - -func invoiceSupplierIsSTC(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && inv != nil && isPartyIdentitySTC(inv.Supplier) -} - -func invoiceIsConsolidatedCreditNoteAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && isConsolidatedCreditNote(inv) -} - -func invoiceIsNotAdvanceOrFinalAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && inv != nil && !isAdvancedInvoice(inv) && !isFinalInvoice(inv) -} - -func invoiceIsFinalAny(val any) bool { - inv, ok := val.(*bill.Invoice) - return ok && isFinalInvoice(inv) -} - -// --- Field-level helper functions --- - -func identitiesHasSIREN(val any) bool { - identities, ok := val.([]*org.Identity) - if !ok { - return true // nil/empty passes - } - for _, id := range identities { - if id != nil && !id.Ext.IsZero() { - if code := id.Ext.Get(iso.ExtKeySchemeID); code == "0002" && id.Scope.Has(org.IdentityScopeLegal) { - return true - } - } - } - return false -} - -func partyHasSIRENInbox(val any) bool { - party, ok := val.(*org.Party) - if !ok || party == nil { - return true - } - siren := getPartySIREN(party) - if siren == "" { - return true // SIREN validation handled elsewhere - } - for _, inbox := range party.Inboxes { - if inbox != nil && inbox.Scheme == inboxSchemeSIREN { - if !strings.HasPrefix(string(inbox.Code), siren) { - return false // "party endpoint ID scheme inbox (0225) must start with SIREN (BR-FR-21/22)" - } - return true - } - } - return false // no SIREN inbox found -} - -func orderingIdentitiesNoDupAFL(val any) bool { - identities, ok := val.([]*org.Identity) - if !ok { - return true - } - count := 0 - for _, id := range identities { - if id == nil || id.Ext.IsZero() { - continue - } - if id.Ext.Get(untdid.ExtKeyReference).String() == "AFL" { - count++ - if count > 1 { - return false - } - } - } - return true -} - -func orderingIdentitiesNoDupAWW(val any) bool { - identities, ok := val.([]*org.Identity) - if !ok { - return true - } - count := 0 - for _, id := range identities { - if id == nil || id.Ext.IsZero() { - continue - } - if id.Ext.Get(untdid.ExtKeyReference).String() == "AWW" { - count++ - if count > 1 { - return false - } - } - } - return true -} - -func notesHaveTXD(val any) bool { - notes, ok := val.([]*org.Note) - if !ok || len(notes) == 0 { - return false - } - for _, note := range notes { - if note != nil && !note.Ext.IsZero() { - if code := note.Ext.Get(untdid.ExtKeyTextSubject); code == "TXD" && note.Text == "MEMBRE_ASSUJETTI_UNIQUE" { - return true - } - } - } - return false -} - -func notesHaveRequired(val any) bool { - notes, ok := val.([]*org.Note) - if !ok || len(notes) == 0 { - return false - } - required := []cbc.Code{"PMT", "PMD", "AAB"} - counts := make(map[cbc.Code]int) - for _, note := range notes { - if note != nil && !note.Ext.IsZero() { - if code := note.Ext.Get(untdid.ExtKeyTextSubject); code != cbc.CodeEmpty { - counts[code]++ - } - } - } - for _, code := range required { - if counts[code] == 0 { - return false - } - } - return true -} - -func notesNoDuplicates(val any) bool { - notes, ok := val.([]*org.Note) - if !ok || len(notes) == 0 { - return true - } - counts := make(map[cbc.Code]int) - for _, note := range notes { - if note != nil && !note.Ext.IsZero() { - if code := note.Ext.Get(untdid.ExtKeyTextSubject); code != cbc.CodeEmpty { - counts[code]++ - } - } - } - checkUnique := []cbc.Code{"PMT", "PMD", "AAB", "TXD", "BAR"} - for _, code := range checkUnique { - if counts[code] > 1 { - return false - } - } - return true -} - -func notesValidBARText(val any) bool { - notes, ok := val.([]*org.Note) - if !ok || len(notes) == 0 { - return true - } - for _, note := range notes { - if note != nil && !note.Ext.IsZero() { - if note.Ext.Get(untdid.ExtKeyTextSubject) == "BAR" { - if note.Text != "" && !slices.Contains(allowedBARTreatments, note.Text) { - return false - } - } - } - } - return true -} - -func invoiceDueDatesValid(val any) bool { - inv, ok := val.(*bill.Invoice) - if !ok || inv == nil { - return true - } - if inv.Payment == nil || inv.Payment.Terms == nil || len(inv.Payment.Terms.DueDates) == 0 { - return true - } - for _, dd := range inv.Payment.Terms.DueDates { - if dd == nil || dd.Date == nil { - continue - } - if inv.IssueDate.DaysSince(dd.Date.Date) > 0 { - return false - } - } - return true -} - -func finalInvoiceAdvancesMatch(val any) bool { - totals, ok := val.(*bill.Totals) - if !ok || totals == nil || totals.Advances == nil { - return true // handled by required check - } - return totals.Advances.Equals(totals.TotalWithTax) -} - -func finalInvoicePayableZero(val any) bool { - totals, ok := val.(*bill.Totals) - if !ok || totals == nil { - return true - } - // PayableAmount maps to Due if present, otherwise Payable - if totals.Due != nil { - return totals.Due.Equals(num.AmountZero) - } - return totals.Payable.Equals(num.AmountZero) -} - -func attachmentsUniqueLISIBLE(val any) bool { - attachments, ok := val.([]*org.Attachment) - if !ok || len(attachments) == 0 { - return true - } - count := 0 - for _, att := range attachments { - if att != nil && att.Description == attachmentFormatLisible { - count++ - } - } - return count <= 1 -} diff --git a/addons/fr/ctc/bill_payment.go b/addons/fr/ctc/bill_payment.go new file mode 100644 index 000000000..9ca493f23 --- /dev/null +++ b/addons/fr/ctc/bill_payment.go @@ -0,0 +1,106 @@ +package ctc + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// paymentIsB2C reports whether the payment reports a B2C settlement, +// determined by the absence of a Customer party. Payments themselves +// are not routed by residency — every payment runs the Flow 10 +// e-reporting ruleset regardless of where the parties are based — so +// "B2C" here is only used to mean "no customer present". +func paymentIsB2C(pmt *bill.Payment) bool { + return pmt != nil && pmt.Customer == nil +} + +// paymentHasCustomerAny is the "has Customer party" predicate used to +// gate the per-line invoice-reference rules. Despite the historical +// "B2B" labelling, it does not imply cross-border: a domestic FR-FR +// payment receipt has a customer and goes through this branch. +func paymentHasCustomerAny(v any) bool { + pmt, ok := v.(*bill.Payment) + return ok && !paymentIsB2C(pmt) +} + +func billPaymentRules() *rules.Set { + return rules.For(new(bill.Payment), + // Flow 10 only reports payment receipts, not requests or advices. + rules.Field("type", + rules.Assert("01", "payment type must be 'receipt' for Flow 10 reporting", + is.In(bill.PaymentTypeReceipt), + ), + ), + rules.Field("value_date", + rules.Assert("02", "payment value_date (settlement date) is required", + is.Present, + ), + ), + rules.Assert("03", "every VAT line percent must be one of the Flow 10 permitted values (G1.24): 0, 0.9, 1.05, 1.75, 2.1, 5.5, 7, 8.5, 9.2, 9.6, 10, 13, 19.6, 20, 20.6", + is.Func("allowed Flow 10 VAT percents", paymentVATPercentsAllowed), + ), + rules.Field("supplier", + rules.Assert("04", "supplier is required", + is.Present, + ), + rules.Assert("05", "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002)", + is.Func("party has SIREN", partyHasSIREN), + ), + ), + // Per-line invoice references are required when the payment + // carries a Customer (cleared invoice receipts), not B2C settlements. + rules.When( + is.Func("payment has customer", paymentHasCustomerAny), + rules.Field("lines", + rules.Each( + rules.Field("document", + rules.Assert("06", "each payment line must reference a document (invoice) when a customer is present", + is.Present, + ), + rules.Field("code", + rules.Assert("07", "payment line document code (invoice ID) is required when a customer is present", + is.Present, + ), + ), + rules.Field("issue_date", + rules.Assert("08", "payment line document issue_date (invoice issue date) is required when a customer is present", + is.Present, + ), + ), + ), + ), + ), + ), + ) +} + +// paymentVATPercentsAllowed reports whether every VAT percent +// declared on the payment's line totals matches one of the G1.24 +// whitelist values. +func paymentVATPercentsAllowed(v any) bool { + pmt, ok := v.(*bill.Payment) + if !ok || pmt == nil { + return true + } + for _, line := range pmt.Lines { + if line == nil || line.Tax == nil { + continue + } + for _, cat := range line.Tax.Categories { + if cat == nil || cat.Code != tax.CategoryVAT { + continue + } + for _, rate := range cat.Rates { + if rate == nil || rate.Percent == nil { + continue + } + if !percentageInList(*rate.Percent, allowedVATPercents) { + return false + } + } + } + } + return true +} diff --git a/addons/fr/ctc/bill_payment_test.go b/addons/fr/ctc/bill_payment_test.go new file mode 100644 index 000000000..678a9d4c8 --- /dev/null +++ b/addons/fr/ctc/bill_payment_test.go @@ -0,0 +1,165 @@ +package ctc + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testPaymentB2B(t *testing.T) *bill.Payment { + t.Helper() + issued := cal.MakeDate(2026, 1, 10) + paid := cal.MakeDate(2026, 2, 1) + return &bill.Payment{ + Regime: tax.WithRegime("FR"), + Addons: tax.WithAddons(V1), + Type: bill.PaymentTypeReceipt, + Code: "PAY-2026-001", + Currency: "EUR", + IssueDate: cal.MakeDate(2026, 2, 1), + ValueDate: &paid, + Methods: []*pay.Record{ + {Key: pay.MeansKeyCreditTransfer, Amount: num.MakeAmount(12000, 2), Currency: "EUR"}, + }, + Supplier: frPartyWithSIREN(), + Customer: frCustomerWithSIREN(), + Lines: []*bill.PaymentLine{ + { + Document: &org.DocumentRef{ + Code: "INV-2026-001", + IssueDate: &issued, + }, + Amount: num.MakeAmount(12000, 2), + }, + }, + } +} + +func testPaymentB2C(t *testing.T) *bill.Payment { + t.Helper() + paid := cal.MakeDate(2026, 2, 1) + return &bill.Payment{ + Regime: tax.WithRegime("FR"), + Addons: tax.WithAddons(V1), + Type: bill.PaymentTypeReceipt, + Code: "PAY-2026-B2C-001", + Currency: "EUR", + IssueDate: cal.MakeDate(2026, 2, 1), + ValueDate: &paid, + Methods: []*pay.Record{ + {Key: pay.MeansKeyCreditTransfer, Amount: num.MakeAmount(12000, 2), Currency: "EUR"}, + }, + Supplier: frPartyWithSIREN(), + Lines: []*bill.PaymentLine{ + {Amount: num.MakeAmount(12000, 2)}, + }, + } +} + +func TestPaymentB2BHappyPath(t *testing.T) { + p := testPaymentB2B(t) + require.NoError(t, p.Calculate()) + require.NoError(t, rules.Validate(p)) +} + +func TestPaymentB2CHappyPath(t *testing.T) { + p := testPaymentB2C(t) + require.NoError(t, p.Calculate()) + require.NoError(t, rules.Validate(p)) +} + +func TestPaymentMissingValueDate(t *testing.T) { + p := testPaymentB2B(t) + p.ValueDate = nil + require.NoError(t, p.Calculate()) + err := rules.Validate(p) + assert.ErrorContains(t, err, "value_date") +} + +func TestPaymentB2BRequiresDocumentRef(t *testing.T) { + p := testPaymentB2B(t) + p.Lines[0].Document = nil + require.NoError(t, p.Calculate()) + err := rules.Validate(p) + assert.ErrorContains(t, err, "document") +} + +func TestPaymentB2BRequiresDocumentCode(t *testing.T) { + p := testPaymentB2B(t) + p.Lines[0].Document.Code = "" + require.NoError(t, p.Calculate()) + err := rules.Validate(p) + assert.ErrorContains(t, err, "invoice ID") +} + +func TestPaymentB2BRequiresDocumentIssueDate(t *testing.T) { + p := testPaymentB2B(t) + p.Lines[0].Document.IssueDate = nil + require.NoError(t, p.Calculate()) + err := rules.Validate(p) + assert.ErrorContains(t, err, "invoice issue date") +} + +func TestPaymentB2CDoesNotRequireDocumentRef(t *testing.T) { + p := testPaymentB2C(t) + require.NoError(t, p.Calculate()) + require.NoError(t, rules.Validate(p)) +} + +func TestPaymentSupplierSIRENRequired(t *testing.T) { + p := testPaymentB2B(t) + p.Supplier.TaxID = nil + p.Supplier.Identities = nil + require.NoError(t, p.Calculate()) + err := rules.Validate(p) + assert.ErrorContains(t, err, "SIREN") +} + +func TestPaymentVATRateNotInWhitelist(t *testing.T) { + p := testPaymentB2B(t) + pct := num.MakePercentage(17, 2) // 17% — not allowed + p.Lines[0].Tax = &tax.Total{ + Categories: []*tax.CategoryTotal{ + { + Code: tax.CategoryVAT, + Rates: []*tax.RateTotal{ + {Percent: &pct, Base: num.MakeAmount(10000, 2), Amount: num.MakeAmount(1700, 2)}, + }, + }, + }, + } + require.NoError(t, p.Calculate()) + err := rules.Validate(p) + assert.ErrorContains(t, err, "G1.24") +} + +func TestPaymentRejectsNonReceiptType(t *testing.T) { + p := testPaymentB2B(t) + p.Type = bill.PaymentTypeRequest + require.NoError(t, p.Calculate()) + err := rules.Validate(p) + assert.ErrorContains(t, err, "payment type must be 'receipt'") +} + +// --- Internal helper coverage ------------------------------------------- + +func TestPaymentHasCustomerAnyWrongType(t *testing.T) { + assert.False(t, paymentHasCustomerAny("x")) +} + +func TestPaymentVATRatesAllowedWrongType(t *testing.T) { + assert.True(t, paymentVATPercentsAllowed("x")) +} + +func TestPaymentVATRatesAllowedNilLine(t *testing.T) { + p := &bill.Payment{Lines: []*bill.PaymentLine{nil}} + assert.True(t, paymentVATPercentsAllowed(p)) +} diff --git a/addons/fr/ctc/bill_status.go b/addons/fr/ctc/bill_status.go new file mode 100644 index 000000000..bded29b87 --- /dev/null +++ b/addons/fr/ctc/bill_status.go @@ -0,0 +1,612 @@ +package ctc + +import ( + "slices" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" +) + +func normalizeStatus(st *bill.Status) { + if st == nil { + return + } + + // If the caller pinned fr-ctc-status-code but left Line.Key / + // Status.Type blank, fill them from the process table. + if code := st.Ext.Get(ExtKeyStatusCode).String(); code != "" { + if key, typ, ok := StatusKeyFor(code); ok { + if len(st.Lines) > 0 && st.Lines[0] != nil && st.Lines[0].Key == "" { + st.Lines[0].Key = key + } + if st.Type == "" { + st.Type = typ + } + } + } + + // Default Type from the first line's Key. + if st.Type == "" { + for _, line := range st.Lines { + if line == nil { + continue + } + if typ, ok := statusTypeForKey(line.Key); ok { + st.Type = typ + break + } + } + } + + // Deduce the fr-ctc-role on Issuer / Recipient from the line's + // (key, type) → side mapping per Annexe A "Acteurs CDV". + if len(st.Lines) > 0 && st.Lines[0] != nil { + issuerRole, recipientRole := rolesForSide(SideForKeyType(st.Lines[0].Key, st.Type)) + if issuerRole != "" { + setPartyRoleDefault(st.Issuer, issuerRole) + } + if recipientRole != "" { + setPartyRoleDefault(st.Recipient, recipientRole) + } + } + + // Propagate the SE-roled party's SIREN onto Supplier when missing. + if siren := sirenFromSEParty(st.Issuer, st.Recipient); siren != nil { + st.Supplier = ensureSIRENOnSupplier(st.Supplier, siren) + } + + // Surface the CDAR ProcessConditionCode on the document. + if len(st.Lines) > 0 && st.Lines[0] != nil && st.Ext.Get(ExtKeyStatusCode) == "" { + if code, ok := CDARProcessCodeFor(st.Lines[0].Key, st.Type); ok { + st.Ext = st.Ext.Set(ExtKeyStatusCode, cbc.Code(code)) + } + } +} + +// sirenFromSEParty returns the first SIREN identity carried by an +// SE-roled party among the given candidates, or nil. Relies on the +// addon normaliser having set iso-scheme-id=0002 on SIREN identities +// (see normalizeIdentity in org.go). +func sirenFromSEParty(candidates ...*org.Party) *org.Identity { + for _, p := range candidates { + if p == nil { + continue + } + if p.Ext.Get(ExtKeyRole) != RoleSE { + continue + } + for _, id := range p.Identities { + if id == nil || id.Ext.IsZero() { + continue + } + if id.Ext.Get(iso.ExtKeySchemeID).String() == identitySchemeIDSIREN { + return id + } + } + } + return nil +} + +// ensureSIRENOnSupplier returns a Supplier party that carries the +// given SIREN identity, creating one if it was nil and appending the +// identity if the existing Supplier doesn't already carry the same +// SIREN. +func ensureSIRENOnSupplier(p *org.Party, siren *org.Identity) *org.Party { + clone := *siren + if p == nil { + return &org.Party{Identities: []*org.Identity{&clone}} + } + for _, id := range p.Identities { + if id == nil || id.Ext.IsZero() { + continue + } + if id.Ext.Get(iso.ExtKeySchemeID).String() == identitySchemeIDSIREN && + id.Code == siren.Code { + return p + } + } + p.Identities = append(p.Identities, &clone) + return p +} + +// rolesForSide returns the (Issuer.role, Recipient.role) pair implied +// by an Annexe A side. +func rolesForSide(side CDVSide) (issuer, recipient cbc.Code) { + switch side { + case CDVSideBuyer: + return RoleBY, RoleSE + case CDVSideSeller: + return RoleSE, RoleBY + } + return "", "" +} + +func setPartyRoleDefault(p *org.Party, role cbc.Code) { + if p == nil { + return + } + if !p.Ext.IsZero() && p.Ext.Get(ExtKeyRole) != "" { + return + } + p.Ext = p.Ext.Set(ExtKeyRole, role) +} + +func partyHasRole(v any) bool { + p, ok := v.(*org.Party) + if !ok || p == nil { + return false + } + if p.Ext.IsZero() { + return false + } + return p.Ext.Get(ExtKeyRole) != "" +} + +// partyHasInboxWhenRequired enforces BR-FR-CDV-08: a party whose role +// is not WK (dematerialisation platform / operator) and not DFH +// (PPF) must carry a URIID (electronic inbox). WK and DFH are the +// two routing infrastructure roles whose endpoint is implied by the +// CDV channel itself, so an explicit inbox would be redundant. +func partyHasInboxWhenRequired(v any) bool { + p, ok := v.(*org.Party) + if !ok || p == nil { + return true + } + role := p.Ext.Get(ExtKeyRole) + if role == RoleWK || role == RoleDFH { + return true + } + for _, ib := range p.Inboxes { + if ib != nil && ib.Code != "" { + return true + } + } + return false +} + +func billStatusRules() *rules.Set { + return rules.For(new(bill.Status), + rules.Field("type", + rules.Assert("01", "status type must be one of: response, update", + is.In(bill.StatusTypeResponse, bill.StatusTypeUpdate), + ), + ), + rules.Field("supplier", + rules.Assert("02", "supplier is required (BR-FR-CDV-13)", + is.Present, + ), + rules.Assert("03", "supplier must have an identity with ISO/IEC 6523 scheme 0002 (SIREN)", + is.Func("supplier has SIREN", statusPartyHasSIRENIdentity), + ), + ), + rules.Field("issuer", + rules.Assert("14", "issuer is required (BR-FR-CDV-CL-03)", + is.Present, + ), + rules.Assert("15", "issuer.ext.fr-ctc-role must be set. Allowed values are BY/DL/SE/AB/SR/PE/PR/II/IV/WK/DFH (BR-FR-CDV-CL-03)", + is.Func("issuer has fr-ctc-role", partyHasRole), + ), + rules.Assert("20", "issuer must have an electronic address (inbox) when its role is not WK or DFH (BR-FR-CDV-08)", + is.Func("issuer has inbox unless WK/DFH", partyHasInboxWhenRequired), + ), + ), + rules.Field("recipient", + rules.Assert("16", "recipient is required (BR-FR-CDV-CL-04)", + is.Present, + ), + rules.Assert("17", "recipient.ext.fr-ctc-role must be set. Allowed values are BY/DL/SE/AB/SR/PE/PR/II/IV/WK/DFH (BR-FR-CDV-CL-04)", + is.Func("recipient has fr-ctc-role", partyHasRole), + ), + rules.Assert("18", "recipient must have an electronic address (inbox) when its role is not WK or DFH (BR-FR-CDV-08)", + is.Func("recipient has inbox unless WK/DFH", partyHasInboxWhenRequired), + ), + ), + rules.Field("lines", + rules.Assert("04", "exactly one status line is required", + is.Func("exactly one line", statusHasExactlyOneLine), + ), + rules.Each( + rules.Field("doc", + rules.Assert("05", "status line must reference a document (BR-FR-CDV-10)", + is.Present, + ), + rules.Field("code", + rules.Assert("11", "referenced invoice code is required (BR-FR-CDV-10)", + is.Present, + ), + ), + rules.Field("issue_date", + rules.Assert("12", "referenced invoice issue date is required (BR-FR-CDV-11)", + is.Present, + ), + ), + ), + rules.Assert("06", "status line key must be a recognised Flow 6 event", + is.Func("known Flow 6 status event", statusLineKeyKnown), + ), + rules.Assert("13", "status lines with key rejected / error / disputed / partially-accepted / suspended require at least one reason (BR-FR-CDV-15)", + is.Func("reason required for rejection-like statuses", statusLineRequiresReason), + ), + rules.Assert("09", "Characteristic.ReasonCode must match the fr-ctc-reason-code of some sibling Reason on the same status line", + is.Func("characteristic reason link resolves", statusLineReasonLinksResolve), + ), + rules.Assert("10", "Characteristic.TypeCode must be one of the MDT-207 values", + is.Func("characteristic type code known", statusLineTypeCodesKnown), + ), + ), + ), + rules.Assert("08", "Status.Type must be a valid pair with each StatusLine.Key in the Flow 6 process table", + is.Func("status type consistent with line keys", statusTypeMatchesLines), + ), + rules.Assert("19", "each Reason's fr-ctc-reason-code must be allowed for the line's CDAR ProcessConditionCode (BR-FR-CDV-CL-09)", + is.Func("reason codes allowed for status", statusReasonCodesAllowed), + ), + rules.Assert("07", "status line with key 'paid' on a response status (CDAR 212) must carry a Characteristic complement with Amount (value + currency) set — this is the MEN (BR-FR-CDV-14)", + is.Func("amount received set when paid response", statusPaidResponseHasAmount), + ), + rules.Assert("21", "ext.fr-ctc-status-code must match the CDAR ProcessConditionCode implied by (line.Key, Status.Type)", + is.Func("status code matches key/type", statusCodeMatchesLine), + ), + rules.Assert("22", "every CDV party identity scheme must be in the Flow 6 allow-list (STC 0231 is rejected — it is a Flow 2 invoice concept)", + is.Func("CDV identity schemes allowed", statusPartiesIdentitySchemesAllowed), + ), + ) +} + +// statusPartiesIdentitySchemesAllowed rejects any identity whose +// iso-scheme-id falls outside allowedFlow6IdentitySchemes on any of +// the four party slots on a bill.Status. +func statusPartiesIdentitySchemesAllowed(v any) bool { + st, ok := v.(*bill.Status) + if !ok || st == nil { + return true + } + for _, p := range []*org.Party{st.Supplier, st.Customer, st.Issuer, st.Recipient} { + if p == nil { + continue + } + for _, id := range p.Identities { + if id == nil || id.Ext.IsZero() { + continue + } + scheme := id.Ext.Get(iso.ExtKeySchemeID).String() + if scheme != "" && !slices.Contains(allowedFlow6IdentitySchemes, scheme) { + return false + } + } + } + return true +} + +// statusCodeMatchesLine ensures the fr-ctc-status-code ext, when set, +// is consistent with the (line.Key, Status.Type) pair. +func statusCodeMatchesLine(v any) bool { + st, ok := v.(*bill.Status) + if !ok || st == nil { + return true + } + code := st.Ext.Get(ExtKeyStatusCode).String() + if code == "" { + return true + } + if len(st.Lines) == 0 || st.Lines[0] == nil { + return true + } + expected, ok := CDARProcessCodeFor(st.Lines[0].Key, st.Type) + if !ok { + return true + } + return code == expected +} + +// statusHasExactlyOneLine enforces the CDAR invariant that a CDV +// message carries one and only one status. +func statusHasExactlyOneLine(v any) bool { + lines, ok := v.([]*bill.StatusLine) + if !ok { + return false + } + return len(lines) == 1 +} + +func statusPartyHasSIRENIdentity(v any) bool { + p, ok := v.(*org.Party) + if !ok || p == nil { + return false + } + for _, id := range p.Identities { + if id == nil || id.Ext.IsZero() { + continue + } + if id.Ext.Get(iso.ExtKeySchemeID).String() == identitySchemeIDSIREN { + return true + } + } + return false +} + +func statusLineKeyKnown(v any) bool { + line, ok := v.(*bill.StatusLine) + if !ok || line == nil { + return false + } + return statusKeyKnown(line.Key) +} + +// statusPaidResponseHasAmount checks BR-FR-CDV-14: every line with +// key=paid on a response status (CDAR 212 Encaissée) must carry a +// Characteristic with TypeCode=MEN and Amount populated. +func statusPaidResponseHasAmount(v any) bool { + st, ok := v.(*bill.Status) + if !ok || st == nil { + return true + } + if st.Type != bill.StatusTypeResponse { + return true + } + for _, line := range st.Lines { + if line == nil || line.Key != bill.StatusEventPaid { + continue + } + if !lineHasMENAmount(line) { + return false + } + } + return true +} + +// lineHasMENAmount returns true if the given line carries a +// Characteristic complement with TypeCode=MEN and a populated +// Amount (value + currency). +func lineHasMENAmount(line *bill.StatusLine) bool { + for _, obj := range line.Complements { + if obj == nil { + continue + } + c, ok := obj.Instance().(*Characteristic) + if !ok || c == nil { + continue + } + if c.TypeCode != TypeCodeAmountReceived { + continue + } + if c.Amount == nil || c.Amount.Currency == "" { + continue + } + return true + } + return false +} + +// statusLineTypeCodesKnown ensures every Characteristic.TypeCode on +// the line is one of the MDT-207 controlled values. +func statusLineTypeCodesKnown(v any) bool { + line, ok := v.(*bill.StatusLine) + if !ok || line == nil { + return true + } + for _, obj := range line.Complements { + if obj == nil { + continue + } + c, ok := obj.Instance().(*Characteristic) + if !ok || c == nil || c.TypeCode == "" { + continue + } + if !slices.Contains(typeCodes, c.TypeCode) { + return false + } + } + return true +} + +// statusLineReasonLinksResolve ensures that every Characteristic on the +// line whose ReasonCode is set matches the fr-ctc-reason-code of some +// sibling bill.Reason on the same line. +func statusLineReasonLinksResolve(v any) bool { + line, ok := v.(*bill.StatusLine) + if !ok || line == nil { + return true + } + if len(line.Complements) == 0 { + return true + } + for _, obj := range line.Complements { + if obj == nil { + continue + } + c, ok := obj.Instance().(*Characteristic) + if !ok || c == nil || c.ReasonCode == "" { + continue + } + if !lineHasReasonCode(line, c.ReasonCode) { + return false + } + } + return true +} + +func lineHasReasonCode(line *bill.StatusLine, code cbc.Code) bool { + for _, r := range line.Reasons { + if r == nil { + continue + } + if r.Ext.Get(ExtKeyReasonCode) == code { + return true + } + } + return false +} + +// reasonRequiredStatusKeys lists the Flow 6 status-line keys that BR-FR-CDV-15 +// designates as carrying mandatory motifs. +var reasonRequiredStatusKeys = []cbc.Key{ + bill.StatusEventRejected, + bill.StatusEventError, + bill.StatusEventQuerying, + StatusEventDisputed, + StatusEventPartiallyAccepted, +} + +// statusReasonCodesAllowed enforces BR-FR-CDV-CL-09 at the +// bill.Status level. +func statusReasonCodesAllowed(v any) bool { + st, ok := v.(*bill.Status) + if !ok || st == nil { + return true + } + for _, line := range st.Lines { + if line == nil { + continue + } + processCode, ok := CDARProcessCodeFor(line.Key, st.Type) + if !ok { + continue + } + for _, r := range line.Reasons { + if r == nil { + continue + } + code := r.Ext.Get(ExtKeyReasonCode).String() + if code == "" { + continue + } + if !ReasonCodeAllowedForProcessCode(code, processCode) { + return false + } + } + } + return true +} + +func statusLineRequiresReason(v any) bool { + line, ok := v.(*bill.StatusLine) + if !ok || line == nil { + return true + } + if !slices.Contains(reasonRequiredStatusKeys, line.Key) { + return true + } + return len(line.Reasons) > 0 +} + +func statusTypeMatchesLines(v any) bool { + st, ok := v.(*bill.Status) + if !ok || st == nil { + return true + } + for _, line := range st.Lines { + if line == nil { + continue + } + expected, ok := statusTypeForKey(line.Key) + if !ok { + continue + } + if expected != st.Type { + return false + } + } + return true +} + +// -- bill.Reason -------------------------------------------------------- + +// normalizeReason fills in the other side of the Reason.Key ↔ Ext +// relationship when exactly one side is set. +func normalizeReason(r *bill.Reason) { + if r == nil { + return + } + ext := r.Ext.Get(ExtKeyReasonCode).String() + switch { + case r.Key == "" && ext != "": + if key, ok := ReasonKeyFor(ext); ok { + r.Key = key + } + case r.Key != "" && ext == "": + if code, ok := CDARReasonCodeFor(r.Key); ok { + r.Ext = r.Ext.Set(ExtKeyReasonCode, cbc.Code(code)) + } + } +} + +func billReasonRules() *rules.Set { + return rules.For(new(bill.Reason), + rules.Field("key", + rules.AssertIfPresent("01", "reason key is not a recognised bill.ReasonKeys value", + is.In(reasonKeyAnySlice()...), + ), + ), + rules.Assert("02", "fr-ctc-reason-code must be a known CDAR code and its bucket must match reason.key", + is.Func("reason ext code consistent with key", reasonExtMatchesKey), + ), + ) +} + +var validReasonKeys = func() []cbc.Key { + keys := make([]cbc.Key, 0, len(bill.ReasonKeys)) + for _, def := range bill.ReasonKeys { + keys = append(keys, def.Key) + } + return keys +}() + +func reasonKeyAnySlice() []any { + out := make([]any, len(validReasonKeys)) + for i, k := range validReasonKeys { + out[i] = k + } + return out +} + +func reasonExtMatchesKey(v any) bool { + r, ok := v.(*bill.Reason) + if !ok || r == nil { + return true + } + ext := r.Ext.Get(ExtKeyReasonCode).String() + if ext == "" { + return true + } + bucket, ok := ReasonKeyFor(ext) + if !ok { + return false + } + if r.Key == "" { + return true + } + return bucket == r.Key +} + +// -- bill.Action -------------------------------------------------------- + +var validActionKeys = func() []cbc.Key { + keys := make([]cbc.Key, 0, len(bill.ActionKeys)) + for _, def := range bill.ActionKeys { + keys = append(keys, def.Key) + } + return keys +}() + +func actionKeyAnySlice() []any { + out := make([]any, len(validActionKeys)) + for i, k := range validActionKeys { + out[i] = k + } + return out +} + +func billActionRules() *rules.Set { + return rules.For(new(bill.Action), + rules.Field("key", + rules.AssertIfPresent("01", "action key is not a recognised bill.ActionKeys value", + is.In(actionKeyAnySlice()...), + ), + ), + ) +} diff --git a/addons/fr/ctc/bill_status_test.go b/addons/fr/ctc/bill_status_test.go new file mode 100644 index 000000000..370ea9d0d --- /dev/null +++ b/addons/fr/ctc/bill_status_test.go @@ -0,0 +1,611 @@ +package ctc + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/schema" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Helpers -------------------------------------------------------------- + +// statusSupplierParty returns a French supplier party with a SIREN +// identity carried via the iso-scheme-id extension. Used as the +// document-level Supplier on a bill.Status. +func statusSupplierParty() *org.Party { + return &org.Party{ + Name: "Test Platform SARL", + Identities: []*org.Identity{ + { + Code: "356000000", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: identitySchemeIDSIREN, + }), + }, + }, + } +} + +// issuerParty returns a buyer-side Issuer (BR-FR-CDV-CL-03 allowed: +// BY/AB/DL/SE/SR/PE/PR/II/IV) with a SIREN identity and inbox so +// BR-FR-CDV-08 is satisfied. +func issuerParty() *org.Party { + return &org.Party{ + Name: "ACHETEUR", + Identities: []*org.Identity{{ + Code: "200000008", + Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, identitySchemeIDSIREN), + }}, + Inboxes: []*org.Inbox{{Scheme: "0225", Code: "200000008_PEP"}}, + Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleBY), + } +} + +func statusCustomerParty() *org.Party { + return &org.Party{ + Name: "ACHETEUR", + Identities: []*org.Identity{{ + Code: "200000008", + Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, identitySchemeIDSIREN), + }}, + Inboxes: []*org.Inbox{{Scheme: "0225", Code: "200000008_PEP"}}, + Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleBY), + } +} + +// recipientParty returns the seller-end Recipient counterpart with an +// inbox so BR-FR-CDV-08 is satisfied. Carries the same SIREN as the +// document-level Supplier — both represent the same seller legal +// entity, just at different endpoints — so the normaliser's +// ensureSIRENOnSupplier no-ops instead of duplicating the identity. +func recipientParty() *org.Party { + return &org.Party{ + Name: "VENDEUR", + Identities: []*org.Identity{{ + Code: "356000000", + Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, identitySchemeIDSIREN), + }}, + Inboxes: []*org.Inbox{{Scheme: "0225", Code: "356000000_PEP"}}, + Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleSE), + } +} + +func testStatus(t *testing.T) *bill.Status { + t.Helper() + issued := cal.MakeDate(2026, 2, 1) + return &bill.Status{ + Regime: tax.WithRegime("FR"), + Addons: tax.WithAddons(V1), + IssueDate: cal.MakeDate(2026, 2, 2), + Code: "STA-2026-0001", + Supplier: statusSupplierParty(), + Customer: statusCustomerParty(), + Issuer: issuerParty(), + Recipient: recipientParty(), + Lines: []*bill.StatusLine{ + { + Key: bill.StatusEventAccepted, + Date: &issued, + Doc: &org.DocumentRef{ + Code: "INV-2026-001", + IssueDate: &issued, + }, + }, + }, + } +} + +// --- bill.Status validation ---------------------------------------------- + +func TestStatusHappyPath(t *testing.T) { + st := testStatus(t) + runNormalize(t, st) + require.NoError(t, rules.Validate(st)) + assert.Equal(t, bill.StatusTypeResponse, st.Type) +} + +func TestStatusRejectsSTCIdentityScheme(t *testing.T) { + st := testStatus(t) + // Add an STC (0231) identity on the supplier — admissible on a + // Flow 2 invoice but not on a Flow 6 CDV. + st.Supplier.Identities = append(st.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "Flow 6 allow-list") +} + +func TestStatusRejectsSystemType(t *testing.T) { + st := testStatus(t) + runNormalize(t, st) + st.Type = bill.StatusTypeSystem + err := rules.Validate(st) + assert.ErrorContains(t, err, "status type must be one of") +} + +func TestStatusSupplierSIRENRequired(t *testing.T) { + st := testStatus(t) + st.Supplier.Identities = nil + // Strip the SE party's identity too so the normaliser cannot + // auto-populate Supplier from it. + st.Recipient.Identities = nil + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "SIREN") +} + +func TestStatusSupplierSIRENFilledFromSEParty(t *testing.T) { + st := testStatus(t) + st.Supplier = nil // recipient is SE-roled with SIREN 356000000 + runNormalize(t, st) + require.NoError(t, rules.Validate(st)) + require.NotNil(t, st.Supplier) + require.Len(t, st.Supplier.Identities, 1) + assert.Equal(t, cbc.Code("356000000"), st.Supplier.Identities[0].Code) + assert.Equal(t, identitySchemeIDSIREN, + st.Supplier.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) +} + +func TestStatusKeyFilledFromStatusCodeExt(t *testing.T) { + st := testStatus(t) + st.Type = "" + st.Lines[0].Key = "" + st.Ext = st.Ext.Set(ExtKeyStatusCode, "205") + runNormalize(t, st) + require.NoError(t, rules.Validate(st)) + assert.Equal(t, bill.StatusEventAccepted, st.Lines[0].Key) + assert.Equal(t, bill.StatusTypeResponse, st.Type) +} + +func TestStatusTypeMismatchRejected(t *testing.T) { + st := testStatus(t) + runNormalize(t, st) + st.Type = bill.StatusTypeUpdate // accepted is a response code + err := rules.Validate(st) + assert.ErrorContains(t, err, "Status.Type must be a valid pair") +} + +func TestStatusRejectsMultipleLines(t *testing.T) { + st := testStatus(t) + issued := cal.MakeDate(2026, 2, 1) + st.Lines = append(st.Lines, &bill.StatusLine{ + Key: bill.StatusEventAccepted, + Date: &issued, + Doc: &org.DocumentRef{ + Code: "INV-2026-002", + IssueDate: &issued, + }, + }) + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "exactly one status line") +} + +func TestStatusRejectsZeroLines(t *testing.T) { + st := testStatus(t) + st.Lines = nil + err := rules.Validate(st) + assert.ErrorContains(t, err, "exactly one status line") +} + +func TestStatusHasExactlyOneLineWrongType(t *testing.T) { + assert.False(t, statusHasExactlyOneLine("x")) +} + +// --- StatusLine validation ----------------------------------------------- + +func TestStatusLineUnknownKeyRejected(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = cbc.Key("made-up") + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "recognised Flow 6 event") +} + +func TestStatusLineEmptyKeyRejected(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = "" + err := rules.Validate(st) + assert.Error(t, err) +} + +func TestStatusLineDocCodeRequired(t *testing.T) { + st := testStatus(t) + st.Lines[0].Doc.Code = "" + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "invoice code is required") +} + +func TestStatusLineDocIssueDateRequired(t *testing.T) { + st := testStatus(t) + st.Lines[0].Doc.IssueDate = nil + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "invoice issue date is required") +} + +// --- BR-FR-CDV-15: reason required on rejection-like statuses ----------- + +func TestStatusRejectedRequiresReason(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventRejected + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "require at least one reason") +} + +func TestStatusDisputedRequiresReason(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = StatusEventDisputed + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "require at least one reason") +} + +func TestStatusSuspendedRequiresReason(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventQuerying + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "require at least one reason") +} + +func TestStatusPartiallyAcceptedRequiresReason(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = StatusEventPartiallyAccepted + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "require at least one reason") +} + +func TestStatusErrorRequiresReason(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventError + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "require at least one reason") +} + +func TestStatusAcceptedDoesNotRequireReason(t *testing.T) { + st := testStatus(t) + runNormalize(t, st) + require.NoError(t, rules.Validate(st)) +} + +// --- Paid: MEN Characteristic required ----------------------------------- + +func TestStatusPaidRequiresAmount(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventPaid + st.Type = bill.StatusTypeResponse + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "MEN") +} + +func TestStatusPaidSatisfiedByComplement(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventPaid + st.Type = bill.StatusTypeResponse + obj, err := schema.NewObject(&Characteristic{ + TypeCode: TypeCodeAmountReceived, + Amount: ¤cy.Amount{ + Currency: "EUR", + Value: num.MakeAmount(125000, 2), + }, + }) + require.NoError(t, err) + st.Lines[0].Complements = []*schema.Object{obj} + runNormalize(t, st) + require.NoError(t, rules.Validate(st)) +} + +func TestStatusPaidWithoutMENFailsEvenWithOtherTypes(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventPaid + st.Type = bill.StatusTypeResponse + obj, err := schema.NewObject(&Characteristic{ + TypeCode: TypeCodeAmountPaid, + Amount: ¤cy.Amount{Currency: "EUR", Value: num.MakeAmount(100, 0)}, + }) + require.NoError(t, err) + st.Lines[0].Complements = []*schema.Object{obj} + runNormalize(t, st) + err = rules.Validate(st) + assert.ErrorContains(t, err, "MEN") +} + +func TestStatusPaidMENMissingCurrencyFails(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventPaid + st.Type = bill.StatusTypeResponse + obj, err := schema.NewObject(&Characteristic{ + TypeCode: TypeCodeAmountReceived, + Amount: ¤cy.Amount{Value: num.MakeAmount(100, 0)}, + }) + require.NoError(t, err) + st.Lines[0].Complements = []*schema.Object{obj} + runNormalize(t, st) + err = rules.Validate(st) + assert.ErrorContains(t, err, "MEN") +} + +// --- MDT-207 TypeCode whitelist ------------------------------------------ + +func TestStatusCharacteristicUnknownTypeCodeRejected(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventPaid + obj, err := schema.NewObject(&Characteristic{ + TypeCode: "BOGUS", + Amount: ¤cy.Amount{Currency: "EUR", Value: num.MakeAmount(100, 0)}, + }) + require.NoError(t, err) + st.Lines[0].Complements = []*schema.Object{obj} + runNormalize(t, st) + err = rules.Validate(st) + assert.ErrorContains(t, err, "MDT-207") +} + +// --- Characteristic ReasonCode link -------------------------------------- + +func TestStatusCharacteristicReasonLinkMismatch(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventRejected + st.Lines[0].Reasons = []*bill.Reason{{ + Key: bill.ReasonKeyItems, + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyReasonCode: "TX_TVA_ERR"}), + }} + obj, err := schema.NewObject(&Characteristic{ + ReasonCode: "QTE_ERR", + Name: "description", + Value: "wrong", + }) + require.NoError(t, err) + st.Lines[0].Complements = []*schema.Object{obj} + runNormalize(t, st) + err = rules.Validate(st) + assert.ErrorContains(t, err, "ReasonCode must match") +} + +func TestStatusCharacteristicReasonLinkMatch(t *testing.T) { + st := testStatus(t) + st.Lines[0].Key = bill.StatusEventRejected + st.Lines[0].Reasons = []*bill.Reason{{ + Key: bill.ReasonKeyLegal, + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyReasonCode: "TX_TVA_ERR"}), + }} + obj, err := schema.NewObject(&Characteristic{ + ReasonCode: "TX_TVA_ERR", + Name: "description", + Value: "corrected", + }) + require.NoError(t, err) + st.Lines[0].Complements = []*schema.Object{obj} + runNormalize(t, st) + require.NoError(t, rules.Validate(st)) +} + +// --- bill.Reason validation + normalization ------------------------------ + +func TestReasonNormalizerFillsKeyFromExt(t *testing.T) { + r := &bill.Reason{ + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyReasonCode: "QTE_ERR"}), + } + runNormalize(t, r) + assert.Equal(t, bill.ReasonKeyQuantity, r.Key) +} + +func TestReasonNormalizerFillsExtFromKey(t *testing.T) { + r := &bill.Reason{Key: bill.ReasonKeyItems} + runNormalize(t, r) + assert.Equal(t, "ART_ERR", r.Ext.Get(ExtKeyReasonCode).String()) +} + +func TestReasonNormalizerLeavesBothWhenSet(t *testing.T) { + r := &bill.Reason{ + Key: bill.ReasonKeyItems, + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyReasonCode: "ART_ERR"}), + } + runNormalize(t, r) + assert.Equal(t, bill.ReasonKeyItems, r.Key) + assert.Equal(t, "ART_ERR", r.Ext.Get(ExtKeyReasonCode).String()) +} + +func TestReasonNormalizerLeavesUnknownExtAlone(t *testing.T) { + r := &bill.Reason{ + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyReasonCode: "NOPE"}), + } + runNormalize(t, r) + assert.Equal(t, cbc.Key(""), r.Key) +} + +func TestReasonRulesRejectInconsistentExt(t *testing.T) { + r := &bill.Reason{ + Key: bill.ReasonKeyItems, + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyReasonCode: "QTE_ERR"}), + } + err := rules.Validate(r, addonContext()) + assert.ErrorContains(t, err, "must match reason.key") +} + +func TestReasonExtUnknownCodeRejected(t *testing.T) { + r := &bill.Reason{ + Key: bill.ReasonKeyItems, + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyReasonCode: "NOPE"}), + } + err := rules.Validate(r, addonContext()) + assert.ErrorContains(t, err, "must match reason.key") +} + +// --- Internal helper coverage (nil / wrong-type defensive branches) ----- + +func TestNormalizeStatusNilSafe(t *testing.T) { + assert.NotPanics(t, func() { normalizeStatus(nil) }) +} + +func TestNormalizeStatusAllLinesNil(t *testing.T) { + st := &bill.Status{Lines: []*bill.StatusLine{nil}} + normalizeStatus(st) + assert.Equal(t, cbc.Key(""), st.Type) +} + +func TestNormalizeReasonNilSafe(t *testing.T) { + assert.NotPanics(t, func() { normalizeReason(nil) }) +} + +func TestStatusPartyHasSIRENIdentityWrongType(t *testing.T) { + assert.False(t, statusPartyHasSIRENIdentity("not a party")) +} + +func TestStatusPartyHasSIRENIdentityNilParty(t *testing.T) { + assert.False(t, statusPartyHasSIRENIdentity((*org.Party)(nil))) +} + +func TestStatusPartyHasSIRENIdentityWithoutExt(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{Code: "X"}}} + assert.False(t, statusPartyHasSIRENIdentity(p)) +} + +func TestStatusLineKeyKnownWrongType(t *testing.T) { + assert.False(t, statusLineKeyKnown("x")) +} + +func TestStatusPaidResponseHasAmountWrongType(t *testing.T) { + assert.True(t, statusPaidResponseHasAmount(42)) +} + +func TestStatusPaidResponseHasAmountNonPaidLine(t *testing.T) { + st := &bill.Status{ + Type: bill.StatusTypeResponse, + Lines: []*bill.StatusLine{{Key: bill.StatusEventAccepted}}, + } + assert.True(t, statusPaidResponseHasAmount(st)) +} + +func TestStatusPaidResponseHasAmountUpdateSkips(t *testing.T) { + st := &bill.Status{ + Type: bill.StatusTypeUpdate, + Lines: []*bill.StatusLine{{Key: bill.StatusEventPaid}}, + } + assert.True(t, statusPaidResponseHasAmount(st)) +} + +func TestStatusLineTypeCodesKnownWrongType(t *testing.T) { + assert.True(t, statusLineTypeCodesKnown("x")) +} + +func TestStatusLineTypeCodesKnownEmptyLine(t *testing.T) { + assert.True(t, statusLineTypeCodesKnown(&bill.StatusLine{})) +} + +func TestStatusLineReasonLinksResolveWrongType(t *testing.T) { + assert.True(t, statusLineReasonLinksResolve("x")) +} + +func TestStatusLineReasonLinksResolveEmptyComplements(t *testing.T) { + assert.True(t, statusLineReasonLinksResolve(&bill.StatusLine{})) +} + +func TestStatusLineRequiresReasonWrongType(t *testing.T) { + assert.True(t, statusLineRequiresReason("x")) +} + +func TestStatusTypeMatchesLinesWrongType(t *testing.T) { + assert.True(t, statusTypeMatchesLines("x")) +} + +func TestStatusTypeMatchesLinesUnknownLineKey(t *testing.T) { + st := &bill.Status{ + Type: bill.StatusTypeResponse, + Lines: []*bill.StatusLine{{Key: "unknown"}}, + } + assert.True(t, statusTypeMatchesLines(st)) +} + +func TestLineHasReasonCodeNilReason(t *testing.T) { + line := &bill.StatusLine{Reasons: []*bill.Reason{nil}} + assert.False(t, lineHasReasonCode(line, "ART_ERR")) +} + +func TestReasonExtMatchesKeyWrongType(t *testing.T) { + assert.True(t, reasonExtMatchesKey("x")) +} + +// --- defensive coverage: nil / wrong-type / empty-slice guards -------- + +func TestSetPartyRoleDefaultNilParty(t *testing.T) { + assert.NotPanics(t, func() { setPartyRoleDefault(nil, RoleSE) }) +} + +func TestSetPartyRoleDefaultExistingNotOverridden(t *testing.T) { + p := &org.Party{Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyRole: RoleBY})} + setPartyRoleDefault(p, RoleSE) + assert.Equal(t, RoleBY, p.Ext.Get(ExtKeyRole)) +} + +func TestPartyHasRoleWrongType(t *testing.T) { + assert.False(t, partyHasRole("x")) +} + +func TestPartyHasRoleEmptyExt(t *testing.T) { + assert.False(t, partyHasRole(&org.Party{})) +} + +func TestPartyHasInboxWhenRequiredWrongType(t *testing.T) { + assert.True(t, partyHasInboxWhenRequired("x")) +} + +func TestPartyHasInboxWhenRequiredWKRole(t *testing.T) { + p := &org.Party{Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyRole: RoleWK})} + assert.True(t, partyHasInboxWhenRequired(p)) +} + +func TestStatusPartiesIdentitySchemesAllowedWrongType(t *testing.T) { + assert.True(t, statusPartiesIdentitySchemesAllowed("x")) +} + +func TestStatusReasonCodesAllowedWrongType(t *testing.T) { + assert.True(t, statusReasonCodesAllowed("x")) +} + +func TestStatusReasonCodesAllowedNilReason(t *testing.T) { + st := &bill.Status{ + Type: bill.StatusTypeResponse, + Lines: []*bill.StatusLine{{ + Key: bill.StatusEventRejected, + Reasons: []*bill.Reason{nil}, + }}, + } + assert.True(t, statusReasonCodesAllowed(st)) +} + +// TestEnsureSIRENOnSupplierAlreadyCarries covers the "supplier already +// carries the SIREN" early-return path that the happy-path tests don't +// reach (since the test fixture aligns supplier and recipient SIRENs). +func TestEnsureSIRENOnSupplierAlreadyCarries(t *testing.T) { + siren := &org.Identity{ + Code: "356000000", + Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "0002"}), + } + p := &org.Party{Identities: []*org.Identity{ + {Code: "356000000", Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "0002"})}, + }} + got := ensureSIRENOnSupplier(p, siren) + assert.Same(t, p, got) + assert.Len(t, got.Identities, 1) +} diff --git a/addons/fr/ctc/bill_test.go b/addons/fr/ctc/bill_test.go deleted file mode 100644 index eebeb9a42..000000000 --- a/addons/fr/ctc/bill_test.go +++ /dev/null @@ -1,2619 +0,0 @@ -package ctc_test - -import ( - "testing" - - "github.com/invopop/gobl/addons/fr/ctc" - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cal" - "github.com/invopop/gobl/catalogues/iso" - "github.com/invopop/gobl/catalogues/untdid" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/currency" - "github.com/invopop/gobl/num" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/pay" - "github.com/invopop/gobl/regimes/fr" - "github.com/invopop/gobl/rules" - "github.com/invopop/gobl/tax" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testInvoiceB2BStandard(t *testing.T) *bill.Invoice { - t.Helper() - i := &bill.Invoice{ - Regime: tax.WithRegime("FR"), - Addons: tax.WithAddons(ctc.Flow2V1), - Code: "FAC-2024-001", - Currency: "EUR", - Type: bill.InvoiceTypeStandard, - Tax: &bill.Tax{ - Ext: tax.ExtensionsOf(cbc.CodeMap{ - ctc.ExtKeyBillingMode: ctc.BillingModeS1, - untdid.ExtKeyDocumentType: "380", - }), - }, - Supplier: &org.Party{ - Name: "Test Supplier SARL", - TaxID: &tax.Identity{ - Country: "FR", - Code: "39356000000", // Valid French VAT number - }, - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "356000000", - }, - { - Type: fr.IdentityTypeSIRET, - Code: "35600000000011", - }, - }, - Addresses: []*org.Address{ - { - Street: "123 Rue de Test", - Code: "75001", - Locality: "Paris", - Country: "FR", - }, - }, - Inboxes: []*org.Inbox{ - { - Key: org.InboxKeyPeppol, - Scheme: cbc.Code("0225"), - Code: "356000000", - }, - }, - }, - Customer: &org.Party{ - Name: "Test Customer SAS", - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", // Valid French VAT number - }, - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "732829320", - }, - }, - Addresses: []*org.Address{ - { - Street: "456 Avenue du Client", - Code: "69001", - Locality: "Lyon", - Country: "FR", - }, - }, - Inboxes: []*org.Inbox{ - { - Key: org.InboxKeyPeppol, - Scheme: cbc.Code("0225"), - Code: "732829320", - }, - }, - }, - IssueDate: cal.MakeDate(2024, 6, 13), - Lines: []*bill.Line{ - { - Quantity: num.MakeAmount(10, 0), - Item: &org.Item{ - Name: "Test Service", - Price: num.NewAmount(10000, 2), - }, - Taxes: tax.Set{ - { - Category: "VAT", - Rate: "standard", - }, - }, - }, - }, - Payment: &bill.PaymentDetails{ - Terms: &pay.Terms{ - Key: pay.TermKeyDueDate, - DueDates: []*pay.DueDate{ - { - Date: cal.NewDate(2024, 7, 13), - Percent: num.NewPercentage(100, 3), - }, - }, - }, - Instructions: &pay.Instructions{ - Key: pay.MeansKeyCreditTransfer, - CreditTransfer: []*pay.CreditTransfer{ - { - IBAN: "FR7630006000011234567890189", - Name: "Test Supplier SARL", - }, - }, - }, - }, - Notes: []*org.Note{ - { - Key: org.NoteKeyPayment, - Text: "A fixed penalty of 40 EUR will apply to any late payment.", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "PMT", - }), - }, - { - Key: org.NoteKeyPaymentMethod, - Text: "Late payment penalties apply as per our general terms of sale.", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "PMD", - }), - }, - { - Key: org.NoteKeyPaymentTerm, - Text: "No discount offered for early payment.", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "AAB", - }), - }, - }, - } - return i -} - -func TestInvoiceValidation(t *testing.T) { - t.Run("basic B2B invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - require.NoError(t, rules.Validate(inv)) - }) - - t.Run("non-EUR currency without exchange rates", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Currency = "USD" - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "[GOBL-FR-CTC-FLOW2-BILL-INVOICE-42] invoice must be in EUR or provide exchange rate for conversion") - }) - - t.Run("non-EUR currency with exchange rates", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Currency = "USD" - inv.ExchangeRates = []*currency.ExchangeRate{ - { - From: "USD", - To: "EUR", - Amount: num.MakeAmount(875967, 6), - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invoice code too long", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Code = "THIS-IS-A-VERY-LONG-INVOICE-CODE-THAT-EXCEEDS-35-CHARACTERS" - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "BR-FR-01/02") - }) - - t.Run("invoice code normalized - special chars removed", func(t *testing.T) { - // Note: cbc.NormalizeCode removes invalid characters during Calculate - inv := testInvoiceB2BStandard(t) - inv.Code = "INV#2024@001" - require.NoError(t, inv.Calculate()) - // Code is normalized to remove # and @ - assert.Equal(t, "INV2024001", inv.Code.String()) - require.NoError(t, rules.Validate(inv)) - }) - - t.Run("invoice code valid special chars", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Code = "INV-2024+001_TEST/A" - require.NoError(t, inv.Calculate()) - require.NoError(t, rules.Validate(inv)) - }) - - t.Run("invoice date validation - valid dates", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Date is 2024, which is valid (2000-2099) - require.NoError(t, rules.Validate(inv)) - }) - - t.Run("duplicate note codes not allowed", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Add duplicate PMT note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyPayment, - Text: "Duplicate payment terms", - }) - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "duplicate note codes") - assert.ErrorContains(t, err, "BR-FR-06") - }) - - t.Run("supplier SIREN required (BR-FR-10)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Remove all identities from supplier (no SIREN, no SIRET) - inv.Supplier.Identities = []*org.Identity{} - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "BR-FR-10") - assert.ErrorContains(t, err, "SIREN") - }) - - t.Run("customer SIREN required for B2B (BR-FR-10)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // After Calculate, manually add B2B note to mark this as B2B transaction - // (bypassing UNTDID validation for test purposes) - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - // Remove all identities from customer (no SIREN, no SIRET) - inv.Customer.Identities = []*org.Identity{} - // Validate (skip Calculate to avoid note validation) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "BR-FR-10") - assert.ErrorContains(t, err, "SIREN") - assert.ErrorContains(t, err, "0002") - }) - - t.Run("Spanish tax categories rejected by UNTDID", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Manually add Spanish tax category to a line (after Calculate) - if len(inv.Lines) > 0 && len(inv.Lines[0].Taxes) > 0 { - inv.Lines[0].Taxes[0].Ext = tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTaxCategory: "L", // IGIC (Canary Islands) - }) - } - err := rules.Validate(inv) - // EN16931 rules validate VAT category codes - assert.ErrorContains(t, err, "VAT category code must be valid") - }) - - t.Run("valid BAR note - B2B", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid BAR note - B2BINT", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2BINT", - }) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid BAR note - B2C", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2C", - }) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid BAR note - OUTOFSCOPE", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "OUTOFSCOPE", - }) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid BAR note - ARCHIVEONLY", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "ARCHIVEONLY", - }) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invalid BAR note text", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "INVALID", - }) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "BAR note text must be one of") - assert.ErrorContains(t, err, "B2B") - assert.ErrorContains(t, err, "B2BINT") - }) - - t.Run("duplicate BAR note not allowed (BR-FR-30)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Add two BAR notes - inv.Notes = append(inv.Notes, - &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }, - &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "Additional BAR information", - }, - ) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "duplicate note codes found") - assert.ErrorContains(t, err, "BAR") - assert.ErrorContains(t, err, "BR-FR-30") - }) - - t.Run("B2B non-self-billed requires SIREN inbox (BR-FR-21)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Remove SIREN inbox from supplier - inv.Supplier.Inboxes = []*org.Inbox{ - { - Scheme: "0088", // GLIN - Code: "1234567890123", - }, - } - require.NoError(t, inv.Calculate()) - // Add B2B note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "party must have endpoint ID with scheme 0225") - assert.ErrorContains(t, err, "BR-FR-21") - }) - - t.Run("B2B non-self-billed SIREN inbox must start with SIREN (BR-FR-21)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Set wrong SIREN inbox - inv.Supplier.Inboxes = []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "999999999", // Wrong SIREN - }, - } - require.NoError(t, inv.Calculate()) - // Add B2B note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "party must have endpoint ID with scheme 0225") - assert.ErrorContains(t, err, "BR-FR-21") - }) - - t.Run("self-billed invoice does not require SIREN inbox", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set document type to self-billed after Calculate - if inv.Tax != nil && !inv.Tax.Ext.IsZero() { - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "389") // Self-billed invoice - } - // Remove SIREN inbox - inv.Supplier.Inboxes = []*org.Inbox{ - { - Scheme: "0088", - Code: "1234567890123", - }, - } - // Add B2B note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("B2C does not require SIREN inbox", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Remove SIREN inbox - inv.Supplier.Inboxes = []*org.Inbox{ - { - Scheme: "0088", - Code: "1234567890123", - }, - } - require.NoError(t, inv.Calculate()) - // No B2B note, so not a B2B transaction - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("B2B self-billed requires customer SIREN inbox (BR-FR-22)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set document type to self-billed - if inv.Tax != nil && !inv.Tax.Ext.IsZero() { - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "389") // Self-billed invoice - } - // Remove SIREN inbox from customer - inv.Customer.Inboxes = []*org.Inbox{ - { - Scheme: "0088", - Code: "1234567890123", - }, - } - // Add B2B note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "party must have endpoint ID with scheme 0225") - assert.ErrorContains(t, err, "BR-FR-21/22") - }) - - t.Run("B2B self-billed customer SIREN inbox must start with SIREN (BR-FR-22)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set document type to self-billed - if inv.Tax != nil && !inv.Tax.Ext.IsZero() { - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "389") // Self-billed invoice - } - // Set wrong SIREN inbox for customer - inv.Customer.Inboxes = []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "999999999", // Wrong SIREN - }, - } - // Add B2B note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "party must have endpoint ID with scheme 0225") - assert.ErrorContains(t, err, "BR-FR-21/22") - }) - - t.Run("B2B self-billed with correct customer SIREN inbox", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set document type to self-billed - if inv.Tax != nil && !inv.Tax.Ext.IsZero() { - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "389") // Self-billed invoice - } - // Customer already has correct SIREN inbox from testInvoiceB2BStandard - // Add B2B note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestDocumentTypeValidation(t *testing.T) { - t.Run("valid document type", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - assert.Equal(t, "380", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - require.NoError(t, rules.Validate(inv)) - }) - - t.Run("invalid document type", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "999") - err := rules.Validate(inv) - // CTC rules validate document type against allowed list - assert.ErrorContains(t, err, "BR-FR-04") - }) -} - -func TestDocumentTypeScenarios(t *testing.T) { - t.Run("standard invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - assert.Equal(t, "380", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) - - t.Run("factoring invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.SetTags(tax.TagFactoring) - require.NoError(t, inv.Calculate()) - assert.Equal(t, "393", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) - - t.Run("advance payment invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.SetTags(tax.TagPrepayment) - require.NoError(t, inv.Calculate()) - assert.Equal(t, "386", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) - - t.Run("self-billed invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.SetTags(tax.TagSelfBilled) - require.NoError(t, inv.Calculate()) - assert.Equal(t, "389", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) - - t.Run("credit note", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Type = bill.InvoiceTypeCreditNote - require.NoError(t, inv.Calculate()) - assert.Equal(t, "381", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) - - t.Run("self-billed credit note", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Type = bill.InvoiceTypeCreditNote - inv.SetTags(tax.TagSelfBilled) - require.NoError(t, inv.Calculate()) - assert.Equal(t, "261", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) - - t.Run("corrective invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Type = bill.InvoiceTypeCorrective - require.NoError(t, inv.Calculate()) - assert.Equal(t, "384", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) - - t.Run("factoring credit note", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Type = bill.InvoiceTypeCreditNote - inv.SetTags(tax.TagFactoring) - require.NoError(t, inv.Calculate()) - assert.Equal(t, "396", inv.Tax.Ext.Get(untdid.ExtKeyDocumentType).String()) - }) -} - -func TestBillingModeNormalization(t *testing.T) { - t.Run("user-specified billing mode preserved", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax = &bill.Tax{ - Ext: tax.ExtensionsOf(cbc.CodeMap{ - ctc.ExtKeyBillingMode: ctc.BillingModeS5, // Subcontractor - }), - } - require.NoError(t, inv.Calculate()) - assert.Equal(t, ctc.BillingModeS5.String(), inv.Tax.Ext.Get(ctc.ExtKeyBillingMode).String()) - }) - - t.Run("invalid billing mode rejected - B8", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax = &bill.Tax{ - Ext: tax.ExtensionsOf(cbc.CodeMap{ - ctc.ExtKeyBillingMode: cbc.Code("B8"), // Not allowed - }), - } - require.NoError(t, inv.Calculate()) - // Extension value validation is handled by the base GOBL validation pipeline - // (tax.Extensions.Validate), not by rules. The B8 billing mode is not in the - // allowed list defined in the extension definition. - err := rules.Validate(inv) - // rules.Validate does not perform extension value validation; - // that happens in the standard GOBL pipeline via envelope validation. - // Here we verify the extension definition doesn't include B8. - assert.Nil(t, err, "rules.Validate does not check extension values, but GOBL base validation does") - }) - - t.Run("invalid billing mode rejected - B5", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax = &bill.Tax{ - Ext: tax.ExtensionsOf(cbc.CodeMap{ - ctc.ExtKeyBillingMode: cbc.Code("B5"), // Not allowed - }), - } - require.NoError(t, inv.Calculate()) - // Extension value validation is handled by the base GOBL validation pipeline. - err := rules.Validate(inv) - assert.Nil(t, err, "rules.Validate does not check extension values, but GOBL base validation does") - }) -} - -func TestAttachmentValidation(t *testing.T) { - t.Run("valid attachment description - LISIBLE", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Attachments = []*org.Attachment{ - { - Code: "ATT001", - Description: "LISIBLE", - URL: "https://example.com/invoice.pdf", - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid attachment description - RIB", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Attachments = []*org.Attachment{ - { - Code: "ATT001", - Description: "RIB", - URL: "https://example.com/rib.pdf", - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invoice validation catches invalid attachment description", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Attachments = []*org.Attachment{ - { - Code: "ATT001", - Description: "INVALID_TYPE", - URL: "https://example.com/doc.pdf", - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-17") - }) - - t.Run("invoice validation catches multiple LISIBLE attachments", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Attachments = []*org.Attachment{ - { - Code: "ATT001", - Description: "LISIBLE", - URL: "https://example.com/invoice1.pdf", - }, - { - Code: "ATT002", - Description: "LISIBLE", - URL: "https://example.com/invoice2.pdf", - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "only one attachment with description 'LISIBLE' is allowed") - assert.ErrorContains(t, err, "BR-FR-18") - }) - - t.Run("rules validation skips nil attachments gracefully", func(t *testing.T) { - // Create an invoice with nil attachments to test rules validation behavior - inv := testInvoiceB2BStandard(t) - var att *org.Attachment - inv.Attachments = []*org.Attachment{ - att, - { - Code: "ATT001", - Description: "LISIBLE", - URL: "https://example.com/invoice.pdf", - }, - att, - { - Code: "ATT002", - Description: "RIB", - URL: "https://example.com/rib.pdf", - }, - } - - require.NoError(t, inv.Calculate()) - - // Rules validation should handle nil attachments gracefully - err := rules.Validate(inv) - assert.NoError(t, err, "rules validation should skip nil attachments when validating invoice") - }) -} - -func TestOrderingIdentitiesValidation(t *testing.T) { - t.Run("valid ordering with one AFL reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid ordering with one AWW reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AWW", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid ordering with one AFL and one AWW", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AWW", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invalid ordering with duplicate AFL reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "AFL") - assert.ErrorContains(t, err, "BR-FR-30") - }) - - t.Run("invalid ordering with duplicate AWW reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AWW", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AWW", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "AWW") - assert.ErrorContains(t, err, "BR-FR-30") - }) - - t.Run("valid ordering with other UNTDID references", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "CT", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "VN", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("ordering without identities is valid", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Ordering = &bill.Ordering{ - Code: "ORD-12345", - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestLineIdentifiersValidation(t *testing.T) { - t.Run("valid line with one AFL reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Lines = []*bill.Line{ - { - Quantity: num.MakeAmount(1, 0), - Item: &org.Item{ - Name: "Test Item", - Price: num.NewAmount(10000, 2), - }, - Identifier: &org.Identity{ - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid line with one AWW reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Lines = []*bill.Line{ - { - Quantity: num.MakeAmount(1, 0), - Item: &org.Item{ - Name: "Test Item", - Price: num.NewAmount(10000, 2), - }, - Identifier: &org.Identity{ - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AWW", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid lines with one AFL and one AWW", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Lines = []*bill.Line{ - { - Quantity: num.MakeAmount(1, 0), - Item: &org.Item{ - Name: "Test Item 1", - Price: num.NewAmount(10000, 2), - }, - Identifier: &org.Identity{ - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - }, - { - Quantity: num.MakeAmount(2, 0), - Item: &org.Item{ - Name: "Test Item 2", - Price: num.NewAmount(20000, 2), - }, - Identifier: &org.Identity{ - Code: "67890", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "AWW", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid lines with other UNTDID references", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Lines = []*bill.Line{ - { - Quantity: num.MakeAmount(1, 0), - Item: &org.Item{ - Name: "Test Item 1", - Price: num.NewAmount(10000, 2), - }, - Identifier: &org.Identity{ - Code: "12345", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "CT", - }), - }, - }, - { - Quantity: num.MakeAmount(2, 0), - Item: &org.Item{ - Name: "Test Item 2", - Price: num.NewAmount(20000, 2), - }, - Identifier: &org.Identity{ - Code: "67890", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyReference: "VN", - }), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("lines without identifiers are valid", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Lines = []*bill.Line{ - { - Quantity: num.MakeAmount(1, 0), - Item: &org.Item{ - Name: "Test Item", - Price: num.NewAmount(10000, 2), - }, - }, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func setDocumentType(inv *bill.Invoice, docType string) { - if inv.Tax == nil { - inv.Tax = &bill.Tax{} - } - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, cbc.Code(docType)) -} - -func TestConsolidatedCreditNoteValidation(t *testing.T) { - // BR-FR-CO-03: Document type 262 requires delivery period and ordering contracts - t.Run("valid consolidated credit note with delivery and contract (BR-FR-CO-03)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = &bill.DeliveryDetails{ - Period: &cal.Period{ - Start: cal.MakeDate(2024, 5, 1), - End: cal.MakeDate(2024, 5, 31), - }, - } - inv.Ordering = &bill.Ordering{ - Contracts: []*org.DocumentRef{ - { - Code: "CONTRACT-001", - }, - }, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "262") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("consolidated credit note without delivery is invalid (BR-FR-CO-03)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = nil - inv.Ordering = &bill.Ordering{ - Contracts: []*org.DocumentRef{ - { - Code: "CONTRACT-001", - }, - }, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "262") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "delivery details are required") - assert.ErrorContains(t, err, "BR-FR-CO-03") - }) - - t.Run("consolidated credit note without delivery period is invalid (BR-FR-CO-03)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = &bill.DeliveryDetails{} - inv.Ordering = &bill.Ordering{ - Contracts: []*org.DocumentRef{ - { - Code: "CONTRACT-001", - }, - }, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "262") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "delivery period is required") - assert.ErrorContains(t, err, "BR-FR-CO-03") - }) - - t.Run("consolidated credit note without ordering contracts is invalid (BR-FR-CO-03)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = &bill.DeliveryDetails{ - Period: &cal.Period{ - Start: cal.MakeDate(2024, 5, 1), - End: cal.MakeDate(2024, 5, 31), - }, - } - inv.Ordering = &bill.Ordering{ - Contracts: nil, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "262") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "at least one contract reference is required") - assert.ErrorContains(t, err, "BR-FR-CO-03") - }) - - t.Run("consolidated credit note with empty contracts array is invalid (BR-FR-CO-03)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = &bill.DeliveryDetails{ - Period: &cal.Period{ - Start: cal.MakeDate(2024, 5, 1), - End: cal.MakeDate(2024, 5, 31), - }, - } - inv.Ordering = &bill.Ordering{ - Contracts: []*org.DocumentRef{}, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "262") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "at least one contract reference is required") - assert.ErrorContains(t, err, "BR-FR-CO-03") - }) - - t.Run("non-consolidated credit note does not require delivery or contracts", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = nil - inv.Ordering = nil - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-001", - IssueDate: cal.NewDate(2024, 5, 1), - }, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "381") // Regular credit note, not 262 - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("consolidated credit note with nil ordering should fail (BR-FR-CO-03)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = &bill.DeliveryDetails{ - Period: &cal.Period{ - Start: cal.MakeDate(2024, 5, 1), - End: cal.MakeDate(2024, 5, 31), - }, - } - inv.Ordering = nil // No ordering at all - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "262") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "ordering") - assert.ErrorContains(t, err, "BR-FR-CO-03") - }) -} - -func TestSTCSupplierValidation(t *testing.T) { - // BR-FR-CO-14/CO-15: STC supplier requirements - t.Run("STC supplier requires ordering with seller (BR-FR-CO-15)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Add STC identity to supplier - inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ - Code: "12345678", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = &bill.Ordering{ - Seller: &org.Party{ - Name: "Assujetti Unique", - TaxID: inv.Supplier.TaxID, // Reuse supplier's valid tax ID - }, - } - // Add TXD note - inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "TXD"}), - }) - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("STC supplier seller missing tax ID (BR-FR-CO-15)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Add STC identity to supplier - inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ - Code: "12345678", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "ORDER-123", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0088", - }), - }, - }, - Seller: &org.Party{ - Name: "Assujetti Unique", - TaxID: nil, // Missing tax ID - }, - } - // Add TXD note - inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "TXD"}), - }) - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "tax ID is required when supplier is under STC scheme") - }) - - t.Run("STC supplier seller with empty tax ID code (BR-FR-CO-15)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Add STC identity to supplier - inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ - Code: "12345678", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "ORDER-123", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0088", - }), - }, - }, - Seller: &org.Party{ - Name: "Assujetti Unique", - TaxID: &tax.Identity{ - Country: "FR", - Code: "", // Empty code - }, - }, - } - // Add TXD note - inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "TXD"}), - }) - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "code is required when supplier is under STC scheme") - }) - - t.Run("STC supplier with nil ordering should fail (BR-FR-CO-15)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Add STC identity to supplier - inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ - Code: "12345678", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = nil - // Add TXD note - inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "TXD"}), - }) - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "ordering") - assert.ErrorContains(t, err, "BR-FR-CO-15") - }) - - t.Run("STC supplier requires TXD note (BR-FR-CO-14)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Add STC identity to supplier - inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ - Code: "12345678", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = &bill.Ordering{ - Seller: &org.Party{ - Name: "Assujetti Unique", - TaxID: inv.Supplier.TaxID, // Reuse supplier's valid tax ID - }, - } - // TXD note missing - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "TXD") - assert.ErrorContains(t, err, "MEMBRE_ASSUJETTI_UNIQUE") - }) -} - -func TestFinalInvoicePaymentValidation(t *testing.T) { - // BR-FR-CO-09: Final invoices require payment details - t.Run("final invoice B2 with nil payment should fail (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) - inv.Payment = nil - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "payment") - // May be caught by BR-CO-25 (EN16931) or BR-FR-CO-09, either is acceptable - }) - - t.Run("final invoice S2 with nil payment should fail (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS2) - inv.Payment = nil - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "payment") - // May be caught by BR-CO-25 (EN16931) or BR-FR-CO-09, either is acceptable - }) - - t.Run("final invoice M2 with nil payment should fail (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeM2) - inv.Payment = nil - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "payment") - // May be caught by BR-CO-25 (EN16931) or BR-FR-CO-09, either is acceptable - }) -} - -func TestPrecedingReferencesValidation(t *testing.T) { - // BR-FR-CO-04: Corrective invoices - t.Run("corrective invoice with exactly one preceding reference (BR-FR-CO-04)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-001", - IssueDate: cal.NewDate(2024, 5, 1), - }, - } - require.NoError(t, inv.Calculate()) - // Set document type to corrective invoice AFTER Calculate() so scenarios don't overwrite it - setDocumentType(inv, "384") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("corrective invoice with no preceding reference (BR-FR-CO-04)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = nil - require.NoError(t, inv.Calculate()) - // Set document type to corrective invoice AFTER Calculate() so scenarios don't overwrite it - setDocumentType(inv, "384") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "exactly one preceding invoice reference") - assert.ErrorContains(t, err, "BR-FR-CO-04") - }) - - t.Run("corrective invoice with multiple preceding references (BR-FR-CO-04)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-001", - IssueDate: cal.NewDate(2024, 5, 1), - }, - { - Code: "INV-002", - IssueDate: cal.NewDate(2024, 5, 2), - }, - } - require.NoError(t, inv.Calculate()) - // Set document type to corrective invoice AFTER Calculate() so scenarios don't overwrite it - setDocumentType(inv, "384") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "exactly one preceding invoice reference") - assert.ErrorContains(t, err, "BR-FR-CO-04") - }) - - t.Run("corrective invoice type 471 requires one preceding reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-001", - IssueDate: cal.NewDate(2024, 5, 1), - }, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "471") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - // BR-FR-CO-05: Credit notes - t.Run("credit note with at least one preceding reference (BR-FR-CO-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-001", - IssueDate: cal.NewDate(2024, 5, 1), - }, - } - require.NoError(t, inv.Calculate()) - // Set document type to credit note AFTER Calculate() so scenarios don't overwrite it - setDocumentType(inv, "381") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("credit note with multiple preceding references is valid (BR-FR-CO-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-001", - IssueDate: cal.NewDate(2024, 5, 1), - }, - { - Code: "INV-002", - IssueDate: cal.NewDate(2024, 5, 2), - }, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "381") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("credit note with no preceding reference (BR-FR-CO-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = nil - require.NoError(t, inv.Calculate()) - // Set document type to credit note AFTER Calculate() so scenarios don't overwrite it - setDocumentType(inv, "381") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "at least one preceding invoice reference") - assert.ErrorContains(t, err, "BR-FR-CO-05") - }) - - t.Run("credit note type 261 requires at least one preceding reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-001", - IssueDate: cal.NewDate(2024, 5, 1), - }, - } - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "261") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("credit note type 502 requires at least one preceding reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = nil - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "502") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "at least one preceding invoice reference") - assert.ErrorContains(t, err, "BR-FR-CO-05") - }) - - t.Run("standard invoice does not require preceding reference", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = nil - require.NoError(t, inv.Calculate()) - // Standard invoice type 380 is already set by scenarios, so this is redundant - // but included for clarity - setDocumentType(inv, "380") - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestPaymentDueDateValidation(t *testing.T) { - t.Run("valid due date on or after issue date (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 1) - inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 7, 1) // After issue date - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("valid due date same as issue date (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 1) - inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) // Same as issue date - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invalid due date before issue date (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 15) - inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) // Before issue date - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "due dates must not be before invoice issue date") - }) - - t.Run("advance payment type 386 allows due date before issue date (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 15) - inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) // Before issue date - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "386") // Advance payment - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("advance payment type 500 allows due date before issue date (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 15) - inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) // Before issue date - require.NoError(t, inv.Calculate()) - setDocumentType(inv, "500") // Self-billed advance payment - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("final invoice billing mode B2 allows due date before issue date (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 15) - inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) // Before issue date - require.NoError(t, inv.Calculate()) - // Set billing mode to B2 (final invoice) - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) - // Set up final invoice totals (BR-FR-CO-09) - totalWithTax := inv.Totals.TotalWithTax - inv.Totals.Advances = &totalWithTax - zero := num.MakeAmount(0, 2) - inv.Totals.Payable = zero - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("final invoice billing mode S2 allows due date before issue date (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 15) - inv.Payment.Terms.DueDates[0].Date = cal.NewDate(2024, 6, 1) // Before issue date - require.NoError(t, inv.Calculate()) - // Set billing mode to S2 (self-billed final invoice) - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS2) - // Set up final invoice totals (BR-FR-CO-09) - totalWithTax := inv.Totals.TotalWithTax - inv.Totals.Advances = &totalWithTax - zero := num.MakeAmount(0, 2) - inv.Totals.Payable = zero - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("no due date is valid (BR-FR-CO-07)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.IssueDate = cal.MakeDate(2024, 6, 1) - // Set notes instead of due dates - no due date means rule doesn't apply - inv.Payment.Terms.DueDates = nil - inv.Payment.Terms.Notes = "Payment on delivery" - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestBillingModeDocumentTypeCompatibility(t *testing.T) { - t.Run("factoring billing mode B4 with advance payment type 386 is invalid (BR-FR-CO-08)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set factoring billing mode B4 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB4) - // Set advance payment document type 386 - setDocumentType(inv, "386") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "advance payment document types not allowed") - }) - - t.Run("factoring billing mode S4 with advance payment type 500 is invalid (BR-FR-CO-08)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set factoring billing mode S4 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS4) - // Set advance payment document type 500 - setDocumentType(inv, "500") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "advance payment document types not allowed") - }) - - t.Run("factoring billing mode M4 with advance payment type 503 is invalid (BR-FR-CO-08)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set factoring billing mode M4 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeM4) - // Set advance payment document type 503 - setDocumentType(inv, "503") - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "advance payment document types not allowed") - }) - - t.Run("factoring billing mode B4 with standard invoice type 380 is valid (BR-FR-CO-08)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set factoring billing mode B4 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB4) - // Standard invoice type 380 is already set by scenarios - setDocumentType(inv, "380") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("non-factoring billing mode B2 with advance payment type 386 is valid (BR-FR-CO-08)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // Set non-factoring billing mode B2 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) - // Set up final invoice totals (BR-FR-CO-09) - totalWithTax := inv.Totals.TotalWithTax - inv.Totals.Advances = &totalWithTax - zero := num.MakeAmount(0, 2) - inv.Totals.Payable = zero - // Set advance payment document type 386 - setDocumentType(inv, "386") - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("standard billing mode B7 with standard invoice type 380 is valid (BR-FR-CO-08)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - // B7 and 380 are already set by scenarios - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestFinalInvoiceValidation(t *testing.T) { - t.Run("valid final invoice B2 - fully paid with correct amounts (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set billing mode to B2 (final invoice) - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) - - // Manually set the totals to simulate fully paid invoice - // Advance = TotalWithTax, Payable = 0 - totalWithTax := inv.Totals.TotalWithTax - inv.Totals.Advances = &totalWithTax - zero := num.MakeAmount(0, 2) - inv.Totals.Payable = zero - - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("final invoice B2 without advance amount is invalid (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set billing mode to B2 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) - - // No advance amount set - inv.Totals.Advances = nil - - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "advance amount is required for already-paid invoices") - }) - - t.Run("final invoice B2 with incorrect advance amount is invalid (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set billing mode to B2 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) - - // Set advance amount to something other than TotalWithTax - wrongAmount := num.MakeAmount(5000, 2) // Wrong amount - inv.Totals.Advances = &wrongAmount - - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "advance amount must equal total with tax") - }) - - t.Run("final invoice S2 with non-zero payable amount is invalid (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set billing mode to S2 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS2) - - // Set advance amount correctly - totalWithTax := inv.Totals.TotalWithTax - inv.Totals.Advances = &totalWithTax - // But set Due as non-zero (which should be 0 for final invoices) - nonZero := num.MakeAmount(100, 2) - inv.Totals.Due = &nonZero - - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "payable amount must be zero") - }) - - t.Run("final invoice M2 without due date is invalid (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set billing mode to M2 - if inv.Tax.Ext.IsZero() { - inv.Tax.Ext = tax.MakeExtensions() - } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeM2) - - // Set amounts correctly - totalWithTax := inv.Totals.TotalWithTax - inv.Totals.Advances = &totalWithTax - zero := num.MakeAmount(0, 2) - inv.Totals.Payable = zero - - // Remove due date - inv.Payment.Terms.DueDates = nil - inv.Payment.Terms.Notes = "Payment already made" - - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "at least one due date required") - }) - - t.Run("non-final invoice B7 does not require these validations (BR-FR-CO-09)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // B7 is the default billing mode, and normal totals - // No advance amount, non-zero payable - all OK for non-final invoices - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestSelfBilledInvoiceValidation(t *testing.T) { - t.Run("self-billed invoice skips supplier SIREN inbox validation", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Add B2B note to make it a B2B transaction - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyGeneral, - Text: "B2B", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - }) - - // Replace SIREN inbox with non-SIREN inbox - inv.Supplier.Inboxes = []*org.Inbox{ - { - Scheme: "0088", // GLN instead of SIREN - Code: "1234567890123", - }, - } - - // Normal B2B invoice requires supplier SIREN inbox (BR-FR-21/22) - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-21/22") - - // Set self-billed document type (389) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "389") - - // Self-billed invoices skip supplier SIREN inbox validation - err = rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestCorrectiveInvoiceValidation(t *testing.T) { - t.Run("corrective invoice requires exactly one preceding (BR-FR-CO-04)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set corrective document type (384) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "384") - - // Corrective invoices need exactly one preceding invoice - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-CO-04") - - // Add preceding document - inv.Preceding = []*org.DocumentRef{ - { - Code: "INV-123", - }, - } - err = rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("corrective invoice with multiple preceding fails (BR-FR-CO-04)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set corrective document type (384) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "384") - - // Add two preceding documents (should fail) - inv.Preceding = []*org.DocumentRef{ - {Code: "INV-123"}, - {Code: "INV-456"}, - } - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-CO-04") - }) -} - -func TestCreditNoteValidation(t *testing.T) { - t.Run("credit note requires at least one preceding (BR-FR-CO-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set credit note document type (381) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "381") - - // Credit notes need at least one preceding invoice - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-CO-05") - - // Add preceding document - inv.Preceding = []*org.DocumentRef{ - {Code: "INV-123"}, - } - err = rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("credit note with multiple preceding is valid", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set credit note document type (381) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "381") - - // Add multiple preceding documents (should be valid) - inv.Preceding = []*org.DocumentRef{ - {Code: "INV-123"}, - {Code: "INV-456"}, - } - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("factoring credit note (396) requires preceding", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set factoring credit note document type (396) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "396") - - // Should require preceding - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-CO-05") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - err = rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestConsolidatedCreditNoteTypes(t *testing.T) { - t.Run("consolidated credit note type 262", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set consolidated credit note type (262) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "262") - - // Should require delivery and contracts - err := rules.Validate(inv) - assert.Error(t, err) - }) -} - -func TestAdvancedInvoiceTypes(t *testing.T) { - t.Run("prepaid invoice type 386 is advance", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set prepaid invoice type - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "386") - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB1) - - require.NoError(t, inv.Calculate()) - - // Should validate as advance invoice - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("self-billed advance type 500", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set self-billed advance payment type - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "500") - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB1) - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestFinalInvoiceTypes(t *testing.T) { - t.Run("final invoice type 456", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set final invoice type and billing mode - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "456") - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeM4) - - require.NoError(t, inv.Calculate()) - - // Should validate as final invoice - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("self-billed final type 501", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set self-billed final type and billing mode - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "501") - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS4) - - require.NoError(t, inv.Calculate()) - - // Should validate as self-billed final invoice - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestInvoiceNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) - - t.Run("normalizes invoice with existing tax", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Normalize should set rounding to currency - ad.Normalizer(inv) - assert.Equal(t, tax.RoundingRuleCurrency, inv.Tax.Rounding) - }) - - t.Run("normalizes invoice without tax object", func(t *testing.T) { - inv := &bill.Invoice{ - Supplier: &org.Party{Name: "Test"}, - Customer: &org.Party{Name: "Customer"}, - } - - // Should create tax object and set rounding - ad.Normalizer(inv) - assert.NotNil(t, inv.Tax) - assert.Equal(t, tax.RoundingRuleCurrency, inv.Tax.Rounding) - }) - - t.Run("normalizes nil invoice", func(t *testing.T) { - var inv *bill.Invoice - ad.Normalizer(inv) - assert.Nil(t, inv) - }) -} - -func TestHelperFunctionEdgeCases(t *testing.T) { - t.Run("validate unsupported type returns nil", func(t *testing.T) { - // With the rules framework, validation is handled by registered rules. - // Unsupported types simply have no rules and pass validation. - type unsupported struct{} - err := rules.Validate(&unsupported{}) - assert.NoError(t, err) - }) - - t.Run("validate nil date", func(t *testing.T) { - err := rules.Validate((*cal.Date)(nil)) - assert.NoError(t, err) - }) - - t.Run("isCreditNote with nil invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Create a scenario where isCreditNote is called indirectly - // by setting an invoice that validates successfully - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "381") - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("isConsolidatedCreditNote with nil invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set consolidated credit note type - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "262") - inv.Delivery = &bill.DeliveryDetails{ - Period: &cal.Period{ - Start: cal.MakeDate(2024, 6, 1), - End: cal.MakeDate(2024, 6, 30), - }, - } - inv.Ordering = &bill.Ordering{ - Contracts: []*org.DocumentRef{{Code: "CONTRACT-001"}}, - } - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("getPartySIREN with party without identities", func(t *testing.T) { - // Test getPartySIREN indirectly through validation - party := &org.Party{ - Name: "Party without identities", - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", - }, - Inboxes: []*org.Inbox{ - { - Scheme: "0225", - Code: "123456789", - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) // Should pass, getPartySIREN returns empty string - }) - - t.Run("isCreditNote with invoice without extensions", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Create invoice with nil tax extensions - inv.Tax.Ext = tax.Extensions{} - - // Should handle nil gracefully - err := rules.Validate(inv) - assert.Error(t, err) // Will error for other reasons - }) - - t.Run("isAdvancedInvoice with missing billing mode", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Remove billing mode extension - inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) - - // Should not panic - err := rules.Validate(inv) - _ = err // May or may not error depending on other rules - }) -} - -func TestValidatePrecedingDocument(t *testing.T) { - t.Run("invoice with nil preceding is valid for standard invoices", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Preceding = nil - require.NoError(t, inv.Calculate()) - - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invoice with nil element in preceding array returns nil from CTC validation", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Add a nil element in the preceding array - // This tests the nil check in validatePrecedingDocument - inv.Preceding = []*org.DocumentRef{nil} - - require.NoError(t, inv.Calculate()) - - // CTC rules validation should handle nil document ref gracefully - err := rules.Validate(inv) - assert.NoError(t, err, "CTC rules should handle nil preceding document element") - }) - - t.Run("invoice with empty preceding code", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set credit note type that requires preceding - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "381") - - // Add preceding with nil code (should fail base validation) - inv.Preceding = []*org.DocumentRef{ - { - Code: "", // Empty code - }, - } - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.Error(t, err) // Should fail on empty code - }) -} - -func TestValidateCodeEdgeCases(t *testing.T) { - t.Run("invoice with valid code and series", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Code = "FAC-001" - inv.Series = "2024" - require.NoError(t, inv.Calculate()) - - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invoice with code containing series separator without series", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Code = "FAC-2024-001" // Contains '-' but no explicit series - inv.Series = "" - require.NoError(t, inv.Calculate()) - - err := rules.Validate(inv) - assert.NoError(t, err) // Should be valid - }) -} - -func TestSupplierValidationEdgeCases(t *testing.T) { - t.Run("supplier without inboxes fails BR-FR-13", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Remove all inboxes - inv.Supplier.Inboxes = nil - - err := rules.Validate(inv) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-13") - }) - - t.Run("supplier without SIREN identity", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Remove SIREN identity - inv.Supplier.Identities = []*org.Identity{ - { - Code: "OTHER-ID", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0088", - }), - }, - } - - // Should fail SIREN requirement - err := rules.Validate(inv) - assert.Error(t, err) - // The error is BR-FR-10/11 from regime validation - assert.ErrorContains(t, err, "BR-FR-10") - }) -} - -func TestCustomerValidationEdgeCases(t *testing.T) { - t.Run("B2B transaction customer without SIREN", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Add B2B note - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyGeneral, - Text: "B2B", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - }) - - // Remove customer SIREN - inv.Customer.Identities = []*org.Identity{ - { - Code: "OTHER-ID", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0088", - }), - }, - } - - err := rules.Validate(inv) - assert.Error(t, err) - // The error is from regime validation (BR-FR-10/11) - assert.ErrorContains(t, err, "BR-FR-10") - }) -} - -func TestDeliveryAndTotalsValidation(t *testing.T) { - t.Run("invoice without delivery for non-consolidated credit notes", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Delivery = nil - require.NoError(t, inv.Calculate()) - - // Standard invoice doesn't require delivery - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("invoice with zero payable", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - - // Set payable to zero - zero := num.MakeAmount(0, 2) - inv.Totals.Payable = zero - - // Should pass - zero payable is valid in some contexts - err := rules.Validate(inv) - // May error if context requires non-zero, but shouldn't panic - _ = err - }) -} - -func withAddonContext() rules.WithContext { - return func(rc *rules.Context) { - rc.Set(rules.ContextKey(ctc.Flow2V1), tax.AddonForKey(ctc.Flow2V1)) - } -} - -func TestAdditionalDocumentTypes(t *testing.T) { - t.Run("prepaid amount invoice type 471 with preceding", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set prepaid amount invoice (corrective type) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "471") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("stand-alone credit note type 473 with preceding", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set stand-alone credit note (both corrective and credit) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "473") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("self-billed corrective type 502 with preceding", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set self-billed corrective (both self-billed and credit) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "502") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("self-billed credit for claim type 503 with preceding", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set self-billed credit for claim - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "503") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("self-billed prepaid invoice type 472", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set self-billed prepaid amount - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "472") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("self-billed credit note type 261 with preceding", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set self-billed credit note - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "261") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -func TestAdditionalBillingModes(t *testing.T) { - t.Run("billing mode B4 is final", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set billing mode B4 (final) - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB4) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "456") - - require.NoError(t, inv.Calculate()) - - // Should validate as final invoice - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("billing mode S4 is final", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set billing mode S4 (self-billed final) - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS4) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "501") - - require.NoError(t, inv.Calculate()) - - // Should validate as final invoice - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("billing mode M4 is final", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set billing mode M4 (mixed final) - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeM4) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "456") - - require.NoError(t, inv.Calculate()) - - // Should validate as final invoice - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("billing mode S5 credit note dispute", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set billing mode S5 - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS5) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "381") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("billing mode S6 self-billed corrective", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set billing mode S6 - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS6) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "502") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("billing mode B7 self-billed for claim", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set billing mode B7 - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB7) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "503") - - // Add preceding - inv.Preceding = []*org.DocumentRef{{Code: "INV-123"}} - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) - - t.Run("billing mode S7 commercial invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - - // Set billing mode S7 - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS7) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "380") - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} -func TestMissingRequiredNoteCodes(t *testing.T) { - t.Run("missing PMT note code (BR-FR-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Notes = []*org.Note{ - {Key: org.NoteKeyPaymentMethod, Text: "PMD text", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMD"})}, - {Key: org.NoteKeyPaymentTerm, Text: "AAB text", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "AAB"})}, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "missing required note codes") - assert.ErrorContains(t, err, "BR-FR-05") - }) - - t.Run("missing PMD note code (BR-FR-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Notes = []*org.Note{ - {Key: org.NoteKeyPayment, Text: "PMT text", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMT"})}, - {Key: org.NoteKeyPaymentTerm, Text: "AAB text", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "AAB"})}, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "missing required note codes") - assert.ErrorContains(t, err, "BR-FR-05") - }) - - t.Run("missing AAB note code (BR-FR-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Notes = []*org.Note{ - {Key: org.NoteKeyPayment, Text: "PMT text", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMT"})}, - {Key: org.NoteKeyPaymentMethod, Text: "PMD text", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMD"})}, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "missing required note codes") - assert.ErrorContains(t, err, "BR-FR-05") - }) - - t.Run("missing multiple note codes (BR-FR-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Notes = []*org.Note{ - {Key: org.NoteKeyPayment, Text: "PMT text", Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyTextSubject: "PMT"})}, - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "missing required note codes") - assert.ErrorContains(t, err, "BR-FR-05") - }) -} - -func TestNilCodeValidation(t *testing.T) { - t.Run("invoice with empty code returns nil from CTC rules validation", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Code = "" - - // Calculate to ensure invoice is normalized and amounts are computed - require.NoError(t, inv.Calculate()) - - // CTC rules validation should return nil for empty code (invoiceCodeValid returns true for empty) - // Base GOBL validation will catch the missing code - err := rules.Validate(inv) - assert.NoError(t, err, "CTC rules should return nil for empty code, letting base validation handle it") - }) - - t.Run("invoice with empty code fails base validation", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Code = "" - require.NoError(t, inv.Calculate()) - - // Full validation should catch the missing code - // (This may or may not fail depending on signing context) - _ = rules.Validate(inv) // Code is only required for signing - }) -} - -func TestValidationNilChecks(t *testing.T) { - t.Run("invoice with nil payment terms", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Set a standard billing mode (not advance or final invoice) - // so due date validation will be triggered - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS1) - inv.Payment.Terms = nil // Nil terms should be handled gracefully - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - // Should not crash, may have other validation errors but shouldn't panic on nil - _ = err - }) - - t.Run("invoice with nil payment due dates array element", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // First calculate with valid data - require.NoError(t, inv.Calculate()) - - // Then set nil due date after calculation - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS1) - var nilDueDate *pay.DueDate - inv.Payment.Terms.DueDates = []*pay.DueDate{nilDueDate} - - // CTC rules validation should handle nil due date gracefully - err := rules.Validate(inv) - _ = err - }) - - t.Run("final invoice with nil totals returns error", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Set to final invoice billing mode to trigger totals validation - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) - inv.Totals = nil - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - // Will error because totals are required for final invoices - assert.Error(t, err) - }) - - t.Run("consolidated credit note with nil delivery", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Set to consolidated credit note to trigger delivery validation - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "262") - inv.Delivery = nil - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - // Rules should handle nil gracefully and produce validation errors - _ = err - }) - - t.Run("self-billed invoice helper with nil invoice", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax = nil - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - // Tax is required, but the helpers should handle nil gracefully - assert.Error(t, err) - }) -} diff --git a/addons/fr/ctc/codes.go b/addons/fr/ctc/codes.go new file mode 100644 index 000000000..b338c3501 --- /dev/null +++ b/addons/fr/ctc/codes.go @@ -0,0 +1,323 @@ +package ctc + +import ( + "slices" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" +) + +// Extended bill.StatusLine.Key values added by Flow 6. We reuse stock +// GOBL keys wherever the (key, Status.Type) pair is unambiguous — +// notably `paid + update` (CDV-211 Paiement Transmis) and +// `paid + response` (CDV-212 Encaissée), which share the "paid" +// semantic but distinguish transmission vs treatment phase via Type. +// +// Stock keys carrying CDAR codes: +// +// issued + update → 200 (Déposée) +// issued + response → 201 (Émise par la plateforme) +// acknowledged + response → 202 (Reçue par PA) +// processing + response → 204 (Prise en charge) +// accepted + response → 205 (Approuvée) +// querying + response → 208 (Suspendue) — "buyer will not proceed +// without additional info" +// rejected + response → 210 (Refusée) +// paid + update → 211 (Paiement transmis) +// paid + response → 212 (Encaissée) +// error + response → 213 (Rejetée sémantique) +// +// The keys below cover Flow 6 events that have no stock equivalent. +const ( + StatusEventMadeAvailable cbc.Key = "made-available" + StatusEventPartiallyAccepted cbc.Key = "partially-accepted" + StatusEventDisputed cbc.Key = "disputed" + StatusEventCompleted cbc.Key = "completed" +) + +// processEntry pairs a bill.StatusLine.Key with the bill.Status.Type +// the CDV expects, alongside the wire ProcessConditionCode. +type processEntry struct { + Key cbc.Key + Type cbc.Key + Code string +} + +// processTable is the authoritative ProcessConditionCode mapping for +// Flow 6 CDAR messages. Order is stable and matches the spec table. +var processTable = []processEntry{ + {bill.StatusEventIssued, bill.StatusTypeUpdate, "200"}, + {bill.StatusEventIssued, bill.StatusTypeResponse, "201"}, + {bill.StatusEventAcknowledged, bill.StatusTypeResponse, "202"}, + {StatusEventMadeAvailable, bill.StatusTypeResponse, "203"}, + {bill.StatusEventProcessing, bill.StatusTypeResponse, "204"}, + {bill.StatusEventAccepted, bill.StatusTypeResponse, "205"}, + {StatusEventPartiallyAccepted, bill.StatusTypeResponse, "206"}, + {StatusEventDisputed, bill.StatusTypeResponse, "207"}, + {bill.StatusEventQuerying, bill.StatusTypeResponse, "208"}, + {StatusEventCompleted, bill.StatusTypeResponse, "209"}, + {bill.StatusEventRejected, bill.StatusTypeResponse, "210"}, + {bill.StatusEventPaid, bill.StatusTypeUpdate, "211"}, + {bill.StatusEventPaid, bill.StatusTypeResponse, "212"}, + {bill.StatusEventError, bill.StatusTypeResponse, "213"}, +} + +// CDARProcessCodeFor returns the CDAR ProcessConditionCode for a bill +// StatusLine.Key + Status.Type pair. +func CDARProcessCodeFor(key cbc.Key, typ cbc.Key) (string, bool) { + for _, e := range processTable { + if e.Key == key && e.Type == typ { + return e.Code, true + } + } + return "", false +} + +// StatusKeyFor returns the (StatusLine.Key, Status.Type) pair for a CDAR +// ProcessConditionCode. +func StatusKeyFor(code string) (cbc.Key, cbc.Key, bool) { + for _, e := range processTable { + if e.Code == code { + return e.Key, e.Type, true + } + } + return "", "", false +} + +// statusTypeForKey returns the Status.Type associated with a +// StatusLine.Key for Flow 6 *if the key has exactly one*. +func statusTypeForKey(key cbc.Key) (cbc.Key, bool) { + var found cbc.Key + for _, e := range processTable { + if e.Key != key { + continue + } + if found != "" && found != e.Type { + return "", false + } + found = e.Type + } + if found == "" { + return "", false + } + return found, true +} + +// statusKeyKnown reports whether the key appears in the Flow 6 +// process table at least once. +func statusKeyKnown(key cbc.Key) bool { + for _, e := range processTable { + if e.Key == key { + return true + } + } + return false +} + +// reasonEntry pairs a CDAR ReasonCode with its bucket bill.Reason.Key +// and flags whether this code is the default emitted when the caller +// has not pinned an exact ReasonCode via the extension. +type reasonEntry struct { + Code string + Key cbc.Key + IsDefault bool +} + +// reasonTable lists all 45 French CDAR reason codes and the bill.Reason +// bucket they roll up to. IsDefault marks the code the generator should +// emit when the caller only sets Reason.Key. +var reasonTable = []reasonEntry{ + // Business rejection reasons (codes carried on 206 / 207 / 208 / 210). + {"NON_TRANSMISE", bill.ReasonKeyUnknownReceiver, false}, + {"JUSTIF_ABS", bill.ReasonKeyReferences, false}, + {"ROUTAGE_ERR", bill.ReasonKeyUnknownReceiver, false}, + {"AUTRE", bill.ReasonKeyOther, true}, + {"COORD_BANC_ERR", bill.ReasonKeyFinanceTerms, true}, + {"TX_TVA_ERR", bill.ReasonKeyLegal, false}, + {"MONTANTTOTAL_ERR", bill.ReasonKeyPrices, false}, + {"CALCUL_ERR", bill.ReasonKeyPrices, false}, + {"NON_CONFORME", bill.ReasonKeyLegal, true}, + {"DOUBLON", bill.ReasonKeyNotRecognized, true}, + {"DEST_INC", bill.ReasonKeyUnknownReceiver, true}, + {"DEST_ERR", bill.ReasonKeyReferences, false}, + {"TRANSAC_INC", bill.ReasonKeyNotRecognized, false}, + {"EMMET_INC", bill.ReasonKeyNotRecognized, false}, + {"CONTRAT_TERM", bill.ReasonKeyNotRecognized, false}, + {"DOUBLE_FACT", bill.ReasonKeyNotRecognized, false}, + {"CMD_ERR", bill.ReasonKeyReferences, true}, + {"ADR_ERR", bill.ReasonKeyReferences, false}, + {"SIRET_ERR", bill.ReasonKeyReferences, false}, + {"CODE_ROUTAGE_ERR", bill.ReasonKeyReferences, false}, + {"REF_CT_ABSENT", bill.ReasonKeyReferences, false}, + {"REF_ERR", bill.ReasonKeyReferences, false}, + {"PU_ERR", bill.ReasonKeyPrices, true}, + {"REM_ERR", bill.ReasonKeyPrices, false}, + {"QTE_ERR", bill.ReasonKeyQuantity, true}, + {"ART_ERR", bill.ReasonKeyItems, true}, + {"MODPAI_ERR", bill.ReasonKeyPaymentTerms, true}, + {"QUALITE_ERR", bill.ReasonKeyQuality, true}, + {"LIVR_INCOMP", bill.ReasonKeyDelivery, true}, + + // Technical / platform rejection reasons (code 213 only). + {"REJ_SEMAN", bill.ReasonKeyLegal, false}, + {"REJ_UNI", bill.ReasonKeyNotRecognized, false}, + {"REJ_COH", bill.ReasonKeyLegal, false}, + {"REJ_ADR", bill.ReasonKeyReferences, false}, + {"REJ_CONT_B2G", bill.ReasonKeyLegal, false}, + {"REJ_REF_PJ", bill.ReasonKeyReferences, false}, + {"REJ_ASS_PJ", bill.ReasonKeyReferences, false}, + {"IRR_VIDE_F", bill.ReasonKeyLegal, false}, + {"IRR_TYPE_F", bill.ReasonKeyLegal, false}, + {"IRR_SYNTAX", bill.ReasonKeyLegal, false}, + {"IRR_TAILLE_PJ", bill.ReasonKeyLegal, false}, + {"IRR_NOM_PJ", bill.ReasonKeyLegal, false}, + {"IRR_VID_PJ", bill.ReasonKeyLegal, false}, + {"IRR_EXT_DOC", bill.ReasonKeyLegal, false}, + {"IRR_TAILLE_F", bill.ReasonKeyLegal, false}, + {"IRR_ANTIVIRUS", bill.ReasonKeyLegal, false}, +} + +// CDARReasonCodeFor returns the default CDAR ReasonCode for a +// bill.Reason.Key. +func CDARReasonCodeFor(key cbc.Key) (string, bool) { + for _, e := range reasonTable { + if e.Key == key && e.IsDefault { + return e.Code, true + } + } + return "", false +} + +// ReasonKeyFor returns the bucket bill.Reason.Key for a CDAR +// ReasonCode. +func ReasonKeyFor(code string) (cbc.Key, bool) { + for _, e := range reasonTable { + if e.Code == code { + return e.Key, true + } + } + return "", false +} + +// actionTable maps CDAR RequestedActionCode 1:1 to bill.Action.Key. +var actionTable = []struct { + Code string + Key cbc.Key +}{ + {"NOA", bill.ActionKeyNone}, + {"PIN", bill.ActionKeyProvide}, + {"NIN", bill.ActionKeyReissue}, + {"CNF", bill.ActionKeyCreditFull}, + {"CNP", bill.ActionKeyCreditPartial}, + {"CNA", bill.ActionKeyCreditAmount}, + {"OTH", bill.ActionKeyOther}, +} + +// CDVSide reports which end-party plays the Issuer role on a CDV +// message of the given process code. +type CDVSide string + +const ( + // CDVSideBuyer — the buyer-side end-party issues the CDV + // (Issuer = Customer, Recipient = Supplier). + CDVSideBuyer CDVSide = "buyer" + // CDVSideSeller — the seller-side end-party issues the CDV + // (Issuer = Supplier, Recipient = Customer). + CDVSideSeller CDVSide = "seller" + // CDVSidePlatform — the message is issued by a platform (PA-E, + // PA-R) or addressed to the PPF, so neither end-party plays the + // issuer role. + CDVSidePlatform CDVSide = "platform" +) + +// SideForCode returns which end-party issues a CDV with the given +// CDAR ProcessConditionCode (per Annexe A "Acteurs CDV", treatment +// phase). +func SideForCode(code string) CDVSide { + switch code { + case "204", "205", "206", "207", "208", "210", "211": + return CDVSideBuyer + case "209", "212": + return CDVSideSeller + case "200", "201", "202", "203", "213": + return CDVSidePlatform + } + return CDVSidePlatform +} + +// SideForKeyType is a convenience wrapper around SideForCode that +// looks up the process code for a (StatusLine.Key, Status.Type) pair +// first. +func SideForKeyType(key, typ cbc.Key) CDVSide { + if code, ok := CDARProcessCodeFor(key, typ); ok { + return SideForCode(code) + } + return CDVSidePlatform +} + +// allowedReasonsByProcessCode is the BR-FR-CDV-CL-09 table — for each +// CDAR process code that admits Reasons, the set of CDAR ReasonCodes +// the schematron will accept. +var allowedReasonsByProcessCode = map[string][]string{ + "200": {"NON_TRANSMISE"}, + "206": { + "AUTRE", "CMD_ERR", "SIRET_ERR", "CODE_ROUTAGE_ERR", + "REF_CT_ABSENT", "REF_ERR", "PU_ERR", "REM_ERR", "QTE_ERR", + "ART_ERR", "MODPAI_ERR", "QUALITE_ERR", "LIVR_INCOMP", + }, + "207": { + "AUTRE", "COORD_BANC_ERR", "TX_TVA_ERR", "MONTANTTOTAL_ERR", + "CALCUL_ERR", "NON_CONFORME", "DOUBLON", "DEST_ERR", + "TRANSAC_INC", "EMMET_INC", "CONTRAT_TERM", "DOUBLE_FACT", + "CMD_ERR", "ADR_ERR", "SIRET_ERR", "CODE_ROUTAGE_ERR", + "REF_CT_ABSENT", "REF_ERR", "PU_ERR", "REM_ERR", "QTE_ERR", + "ART_ERR", "MODPAI_ERR", "QUALITE_ERR", "LIVR_INCOMP", + }, + "208": { + "JUSTIF_ABS", "COORD_BANC_ERR", "CMD_ERR", "SIRET_ERR", + "CODE_ROUTAGE_ERR", "REF_CT_ABSENT", "REF_ERR", + }, + "210": { + "TX_TVA_ERR", "MONTANTTOTAL_ERR", "CALCUL_ERR", "NON_CONFORME", + "DOUBLON", "DEST_ERR", "TRANSAC_INC", "EMMET_INC", "CONTRAT_TERM", + "DOUBLE_FACT", "CMD_ERR", "ADR_ERR", "REF_CT_ABSENT", + }, + "213": { + "MONTANTTOTAL_ERR", "CALCUL_ERR", "DOUBLON", "ADR_ERR", + "REJ_SEMAN", "REJ_UNI", "REJ_COH", "REJ_ADR", "REJ_CONT_B2G", + "REJ_REF_PJ", "REJ_ASS_PJ", + }, +} + +// ReasonCodeAllowedForProcessCode reports whether the given CDAR +// ReasonCode is permitted on a status line whose ProcessConditionCode is +// processCode. +func ReasonCodeAllowedForProcessCode(reasonCode, processCode string) bool { + allowed, ok := allowedReasonsByProcessCode[processCode] + if !ok { + return true + } + return slices.Contains(allowed, reasonCode) +} + +// CDARActionCodeFor returns the CDAR RequestedActionCode for a +// bill.Action.Key. +func CDARActionCodeFor(key cbc.Key) (string, bool) { + for _, e := range actionTable { + if e.Key == key { + return e.Code, true + } + } + return "", false +} + +// ActionKeyFor returns the bill.Action.Key for a CDAR +// RequestedActionCode. +func ActionKeyFor(code string) (cbc.Key, bool) { + for _, e := range actionTable { + if e.Code == code { + return e.Key, true + } + } + return "", false +} diff --git a/addons/fr/ctc/codes_test.go b/addons/fr/ctc/codes_test.go new file mode 100644 index 000000000..5a6cf2e62 --- /dev/null +++ b/addons/fr/ctc/codes_test.go @@ -0,0 +1,367 @@ +package ctc + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/stretchr/testify/assert" +) + +// assertProcessRoundTrip verifies that a CDAR ProcessConditionCode +// resolves to the expected (key, type) pair and that the pair resolves +// back to the same code. +func assertProcessRoundTrip(t *testing.T, code string, wantKey, wantType cbc.Key) { + t.Helper() + key, typ, ok := StatusKeyFor(code) + assert.True(t, ok, "StatusKeyFor should resolve") + assert.Equal(t, wantKey, key) + assert.Equal(t, wantType, typ) + got, ok := CDARProcessCodeFor(key, typ) + assert.True(t, ok, "CDARProcessCodeFor should resolve") + assert.Equal(t, code, got) +} + +func TestProcessCode200Issued(t *testing.T) { + assertProcessRoundTrip(t, "200", bill.StatusEventIssued, bill.StatusTypeUpdate) +} + +func TestProcessCode201IssuedByPlatform(t *testing.T) { + assertProcessRoundTrip(t, "201", bill.StatusEventIssued, bill.StatusTypeResponse) +} + +func TestProcessCode202Acknowledged(t *testing.T) { + assertProcessRoundTrip(t, "202", bill.StatusEventAcknowledged, bill.StatusTypeResponse) +} + +func TestProcessCode203MadeAvailable(t *testing.T) { + assertProcessRoundTrip(t, "203", StatusEventMadeAvailable, bill.StatusTypeResponse) +} + +func TestProcessCode204Processing(t *testing.T) { + assertProcessRoundTrip(t, "204", bill.StatusEventProcessing, bill.StatusTypeResponse) +} + +func TestProcessCode205Accepted(t *testing.T) { + assertProcessRoundTrip(t, "205", bill.StatusEventAccepted, bill.StatusTypeResponse) +} + +func TestProcessCode206PartiallyAccepted(t *testing.T) { + assertProcessRoundTrip(t, "206", StatusEventPartiallyAccepted, bill.StatusTypeResponse) +} + +func TestProcessCode207Disputed(t *testing.T) { + assertProcessRoundTrip(t, "207", StatusEventDisputed, bill.StatusTypeResponse) +} + +func TestProcessCode208Querying(t *testing.T) { + assertProcessRoundTrip(t, "208", bill.StatusEventQuerying, bill.StatusTypeResponse) +} + +func TestProcessCode209Completed(t *testing.T) { + assertProcessRoundTrip(t, "209", StatusEventCompleted, bill.StatusTypeResponse) +} + +func TestProcessCode210Rejected(t *testing.T) { + assertProcessRoundTrip(t, "210", bill.StatusEventRejected, bill.StatusTypeResponse) +} + +func TestProcessCode211PaidUpdate(t *testing.T) { + assertProcessRoundTrip(t, "211", bill.StatusEventPaid, bill.StatusTypeUpdate) +} + +func TestProcessCode212PaidResponse(t *testing.T) { + assertProcessRoundTrip(t, "212", bill.StatusEventPaid, bill.StatusTypeResponse) +} + +func TestProcessCode213Error(t *testing.T) { + assertProcessRoundTrip(t, "213", bill.StatusEventError, bill.StatusTypeResponse) +} + +func TestProcessCodeUnknownReturnsFalse(t *testing.T) { + _, _, ok := StatusKeyFor("999") + assert.False(t, ok) +} + +func TestProcessKeyTypeMismatchReturnsFalse(t *testing.T) { + _, ok := CDARProcessCodeFor(bill.StatusEventAccepted, bill.StatusTypeUpdate) + assert.False(t, ok) +} + +func TestProcessKeyPaidPairsWithBothTypes(t *testing.T) { + code, ok := CDARProcessCodeFor(bill.StatusEventPaid, bill.StatusTypeUpdate) + assert.True(t, ok) + assert.Equal(t, "211", code) + code, ok = CDARProcessCodeFor(bill.StatusEventPaid, bill.StatusTypeResponse) + assert.True(t, ok) + assert.Equal(t, "212", code) +} + +// assertActionRoundTrip verifies that an action code resolves to the +// expected bill.Action.Key and round-trips back. +func assertActionRoundTrip(t *testing.T, code string, wantKey cbc.Key) { + t.Helper() + key, ok := ActionKeyFor(code) + assert.True(t, ok) + assert.Equal(t, wantKey, key) + got, ok := CDARActionCodeFor(key) + assert.True(t, ok) + assert.Equal(t, code, got) +} + +func TestActionNOA(t *testing.T) { assertActionRoundTrip(t, "NOA", bill.ActionKeyNone) } +func TestActionPIN(t *testing.T) { assertActionRoundTrip(t, "PIN", bill.ActionKeyProvide) } +func TestActionNIN(t *testing.T) { assertActionRoundTrip(t, "NIN", bill.ActionKeyReissue) } +func TestActionCNF(t *testing.T) { assertActionRoundTrip(t, "CNF", bill.ActionKeyCreditFull) } +func TestActionCNP(t *testing.T) { assertActionRoundTrip(t, "CNP", bill.ActionKeyCreditPartial) } +func TestActionCNA(t *testing.T) { assertActionRoundTrip(t, "CNA", bill.ActionKeyCreditAmount) } +func TestActionOTH(t *testing.T) { assertActionRoundTrip(t, "OTH", bill.ActionKeyOther) } + +func TestActionUnknownCodeMisses(t *testing.T) { + _, ok := ActionKeyFor("XYZ") + assert.False(t, ok) +} + +func TestActionUnknownKeyMisses(t *testing.T) { + _, ok := CDARActionCodeFor("never-heard-of") + assert.False(t, ok) +} + +// assertReasonBucket verifies that a CDAR reason code buckets into the +// expected bill.Reason.Key. +func assertReasonBucket(t *testing.T, code string, wantKey cbc.Key) { + t.Helper() + got, ok := ReasonKeyFor(code) + assert.True(t, ok) + assert.Equal(t, wantKey, got) +} + +func TestReasonNON_TRANSMISE(t *testing.T) { + assertReasonBucket(t, "NON_TRANSMISE", bill.ReasonKeyUnknownReceiver) +} +func TestReasonJUSTIF_ABS(t *testing.T) { + assertReasonBucket(t, "JUSTIF_ABS", bill.ReasonKeyReferences) +} +func TestReasonROUTAGE_ERR(t *testing.T) { + assertReasonBucket(t, "ROUTAGE_ERR", bill.ReasonKeyUnknownReceiver) +} +func TestReasonAUTRE(t *testing.T) { + assertReasonBucket(t, "AUTRE", bill.ReasonKeyOther) +} +func TestReasonCOORD_BANC_ERR(t *testing.T) { + assertReasonBucket(t, "COORD_BANC_ERR", bill.ReasonKeyFinanceTerms) +} +func TestReasonTX_TVA_ERR(t *testing.T) { + assertReasonBucket(t, "TX_TVA_ERR", bill.ReasonKeyLegal) +} +func TestReasonMONTANTTOTAL_ERR(t *testing.T) { + assertReasonBucket(t, "MONTANTTOTAL_ERR", bill.ReasonKeyPrices) +} +func TestReasonCALCUL_ERR(t *testing.T) { + assertReasonBucket(t, "CALCUL_ERR", bill.ReasonKeyPrices) +} +func TestReasonNON_CONFORME(t *testing.T) { + assertReasonBucket(t, "NON_CONFORME", bill.ReasonKeyLegal) +} +func TestReasonDOUBLON(t *testing.T) { + assertReasonBucket(t, "DOUBLON", bill.ReasonKeyNotRecognized) +} +func TestReasonDEST_INC(t *testing.T) { + assertReasonBucket(t, "DEST_INC", bill.ReasonKeyUnknownReceiver) +} +func TestReasonDEST_ERR(t *testing.T) { + assertReasonBucket(t, "DEST_ERR", bill.ReasonKeyReferences) +} +func TestReasonTRANSAC_INC(t *testing.T) { + assertReasonBucket(t, "TRANSAC_INC", bill.ReasonKeyNotRecognized) +} +func TestReasonEMMET_INC(t *testing.T) { + assertReasonBucket(t, "EMMET_INC", bill.ReasonKeyNotRecognized) +} +func TestReasonCONTRAT_TERM(t *testing.T) { + assertReasonBucket(t, "CONTRAT_TERM", bill.ReasonKeyNotRecognized) +} +func TestReasonDOUBLE_FACT(t *testing.T) { + assertReasonBucket(t, "DOUBLE_FACT", bill.ReasonKeyNotRecognized) +} +func TestReasonCMD_ERR(t *testing.T) { + assertReasonBucket(t, "CMD_ERR", bill.ReasonKeyReferences) +} +func TestReasonADR_ERR(t *testing.T) { + assertReasonBucket(t, "ADR_ERR", bill.ReasonKeyReferences) +} +func TestReasonSIRET_ERR(t *testing.T) { + assertReasonBucket(t, "SIRET_ERR", bill.ReasonKeyReferences) +} +func TestReasonCODE_ROUTAGE_ERR(t *testing.T) { + assertReasonBucket(t, "CODE_ROUTAGE_ERR", bill.ReasonKeyReferences) +} +func TestReasonREF_CT_ABSENT(t *testing.T) { + assertReasonBucket(t, "REF_CT_ABSENT", bill.ReasonKeyReferences) +} +func TestReasonREF_ERR(t *testing.T) { + assertReasonBucket(t, "REF_ERR", bill.ReasonKeyReferences) +} +func TestReasonPU_ERR(t *testing.T) { + assertReasonBucket(t, "PU_ERR", bill.ReasonKeyPrices) +} +func TestReasonREM_ERR(t *testing.T) { + assertReasonBucket(t, "REM_ERR", bill.ReasonKeyPrices) +} +func TestReasonQTE_ERR(t *testing.T) { + assertReasonBucket(t, "QTE_ERR", bill.ReasonKeyQuantity) +} +func TestReasonART_ERR(t *testing.T) { + assertReasonBucket(t, "ART_ERR", bill.ReasonKeyItems) +} +func TestReasonMODPAI_ERR(t *testing.T) { + assertReasonBucket(t, "MODPAI_ERR", bill.ReasonKeyPaymentTerms) +} +func TestReasonQUALITE_ERR(t *testing.T) { + assertReasonBucket(t, "QUALITE_ERR", bill.ReasonKeyQuality) +} +func TestReasonLIVR_INCOMP(t *testing.T) { + assertReasonBucket(t, "LIVR_INCOMP", bill.ReasonKeyDelivery) +} + +func TestReasonREJ_SEMAN(t *testing.T) { + assertReasonBucket(t, "REJ_SEMAN", bill.ReasonKeyLegal) +} +func TestReasonREJ_UNI(t *testing.T) { + assertReasonBucket(t, "REJ_UNI", bill.ReasonKeyNotRecognized) +} +func TestReasonREJ_COH(t *testing.T) { + assertReasonBucket(t, "REJ_COH", bill.ReasonKeyLegal) +} +func TestReasonREJ_ADR(t *testing.T) { + assertReasonBucket(t, "REJ_ADR", bill.ReasonKeyReferences) +} +func TestReasonREJ_CONT_B2G(t *testing.T) { + assertReasonBucket(t, "REJ_CONT_B2G", bill.ReasonKeyLegal) +} +func TestReasonREJ_REF_PJ(t *testing.T) { + assertReasonBucket(t, "REJ_REF_PJ", bill.ReasonKeyReferences) +} +func TestReasonREJ_ASS_PJ(t *testing.T) { + assertReasonBucket(t, "REJ_ASS_PJ", bill.ReasonKeyReferences) +} +func TestReasonIRR_VIDE_F(t *testing.T) { + assertReasonBucket(t, "IRR_VIDE_F", bill.ReasonKeyLegal) +} +func TestReasonIRR_TYPE_F(t *testing.T) { + assertReasonBucket(t, "IRR_TYPE_F", bill.ReasonKeyLegal) +} +func TestReasonIRR_SYNTAX(t *testing.T) { + assertReasonBucket(t, "IRR_SYNTAX", bill.ReasonKeyLegal) +} +func TestReasonIRR_TAILLE_PJ(t *testing.T) { + assertReasonBucket(t, "IRR_TAILLE_PJ", bill.ReasonKeyLegal) +} +func TestReasonIRR_NOM_PJ(t *testing.T) { + assertReasonBucket(t, "IRR_NOM_PJ", bill.ReasonKeyLegal) +} +func TestReasonIRR_VID_PJ(t *testing.T) { + assertReasonBucket(t, "IRR_VID_PJ", bill.ReasonKeyLegal) +} +func TestReasonIRR_EXT_DOC(t *testing.T) { + assertReasonBucket(t, "IRR_EXT_DOC", bill.ReasonKeyLegal) +} +func TestReasonIRR_TAILLE_F(t *testing.T) { + assertReasonBucket(t, "IRR_TAILLE_F", bill.ReasonKeyLegal) +} +func TestReasonIRR_ANTIVIRUS(t *testing.T) { + assertReasonBucket(t, "IRR_ANTIVIRUS", bill.ReasonKeyLegal) +} + +func TestReasonUnknownCodeMisses(t *testing.T) { + _, ok := ReasonKeyFor("NONEXISTENT") + assert.False(t, ok) +} + +func TestReasonDefaultForUnknownReceiver(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyUnknownReceiver) + assert.True(t, ok) + assert.Equal(t, "DEST_INC", got) +} + +func TestReasonDefaultForReferences(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyReferences) + assert.True(t, ok) + assert.Equal(t, "CMD_ERR", got) +} + +func TestReasonDefaultForOther(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyOther) + assert.True(t, ok) + assert.Equal(t, "AUTRE", got) +} + +func TestReasonDefaultForFinanceTerms(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyFinanceTerms) + assert.True(t, ok) + assert.Equal(t, "COORD_BANC_ERR", got) +} + +func TestReasonDefaultForLegal(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyLegal) + assert.True(t, ok) + assert.Equal(t, "NON_CONFORME", got) +} + +func TestReasonDefaultForPrices(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyPrices) + assert.True(t, ok) + assert.Equal(t, "PU_ERR", got) +} + +func TestReasonDefaultForNotRecognized(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyNotRecognized) + assert.True(t, ok) + assert.Equal(t, "DOUBLON", got) +} + +func TestReasonDefaultForQuantity(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyQuantity) + assert.True(t, ok) + assert.Equal(t, "QTE_ERR", got) +} + +func TestReasonDefaultForItems(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyItems) + assert.True(t, ok) + assert.Equal(t, "ART_ERR", got) +} + +func TestReasonDefaultForPaymentTerms(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyPaymentTerms) + assert.True(t, ok) + assert.Equal(t, "MODPAI_ERR", got) +} + +func TestReasonDefaultForQuality(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyQuality) + assert.True(t, ok) + assert.Equal(t, "QUALITE_ERR", got) +} + +func TestReasonDefaultForDelivery(t *testing.T) { + got, ok := CDARReasonCodeFor(bill.ReasonKeyDelivery) + assert.True(t, ok) + assert.Equal(t, "LIVR_INCOMP", got) +} + +func TestReasonDefaultForKeyUnknownMisses(t *testing.T) { + _, ok := CDARReasonCodeFor("made-up-key") + assert.False(t, ok) +} + +func TestStatusTypeForKeyUnknown(t *testing.T) { + _, ok := statusTypeForKey("unknown") + assert.False(t, ok) +} + +func TestReasonCodeAllowedForProcessCodeUnknownProcess(t *testing.T) { + // Unknown process code → no allow-list, falls through to true (the + // bucket-consistency rule still catches mismatches). + assert.True(t, ReasonCodeAllowedForProcessCode("DEST_INC", "999")) +} diff --git a/addons/fr/ctc/complements.go b/addons/fr/ctc/complements.go new file mode 100644 index 000000000..28c2e1567 --- /dev/null +++ b/addons/fr/ctc/complements.go @@ -0,0 +1,109 @@ +package ctc + +import ( + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/num" +) + +// Characteristic mirrors the CDAR SpecifiedDocumentCharacteristic +// element (MDT-207 and friends) used on Flow 6 lifecycle messages. +// It is attached to a bill.StatusLine via Complements and carries +// either: +// +// 1. A payment-related amount on a paid / partially-accepted / +// completed line — e.g. TypeCode=MEN with Amount set for the +// montant encaissé, MPA for amount paid, RAP for remaining. +// +// 2. A field-level correction on a rejected / disputed / +// partially-accepted line, with ReasonCode pointing at the +// sibling bill.Reason (via its fr-ctc-reason-code extension). +// +// The shape is intentionally close to CDAR so the converter can +// round-trip losslessly; most fields are optional. +type Characteristic struct { + // ID optionally identifies the characteristic. + ID string `json:"id,omitempty" jsonschema:"title=ID"` + + // TypeCode is the CDAR CharacteristicTypeCode. + TypeCode cbc.Code `json:"type_code,omitempty" jsonschema:"title=Type Code"` + + // ReasonCode links this characteristic to a sibling bill.Reason + // via its fr-ctc-reason-code extension value. + ReasonCode cbc.Code `json:"reason_code,omitempty" jsonschema:"title=Reason Code"` + + // Description is a free-form human-readable explanation. + Description string `json:"description,omitempty" jsonschema:"title=Description"` + + // Changed signals whether the reported value represents a + // correction (true) or is being reported unchanged (false). + Changed *bool `json:"changed,omitempty" jsonschema:"title=Changed"` + + // Direction carries the CDAR AdjustmentDirectionCode. + Direction cbc.Code `json:"direction,omitempty" jsonschema:"title=Direction"` + + // Name is the semantic label of the field the characteristic + // refers to. + Name string `json:"name,omitempty" jsonschema:"title=Name"` + + // Location is a locator (XPath, JSON pointer, etc.) into the + // referenced invoice identifying the specific field. + Location string `json:"location,omitempty" jsonschema:"title=Location"` + + // Value carries a free-form string value when the field is textual. + Value string `json:"value,omitempty" jsonschema:"title=Value"` + + // Code carries a coded value when the field is itself a code. + Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"` + + // Percent holds a percentage value (e.g. a VAT rate correction). + Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"` + + // Amount holds a monetary value paired with its currency. + Amount *currency.Amount `json:"amount,omitempty" jsonschema:"title=Amount"` + + // Numeric holds a plain numeric value without currency. + Numeric *num.Amount `json:"numeric,omitempty" jsonschema:"title=Numeric"` + + // Quantity holds a quantity value, optionally qualified by Measure. + Quantity *num.Amount `json:"quantity,omitempty" jsonschema:"title=Quantity"` + + // Measure optionally describes the unit of Quantity or Numeric. + Measure string `json:"measure,omitempty" jsonschema:"title=Measure"` + + // DateTime holds a date-time value. + DateTime *cal.DateTime `json:"date_time,omitempty" jsonschema:"title=Date Time"` +} + +// Characteristic.TypeCode values (MDT-207). +const ( + // Payment-related amounts + TypeCodeAmountReceived cbc.Code = "MEN" // Montant encaissé (TTC) + TypeCodeAmountPaid cbc.Code = "MPA" // Montant payé + TypeCodeAmountRemaining cbc.Code = "RAP" // Reste à payer (paiement partiel) + TypeCodeDiscount cbc.Code = "ESC" // Escompte accordé + TypeCodeRebate cbc.Code = "RAB" // Rabais accordé + TypeCodeReduction cbc.Code = "REM" // Remise accordée + TypeCodeAmountApproved cbc.Code = "MAP" // Montant HT approuvé + TypeCodeAmountApprovedTTC cbc.Code = "MAPTTC" // Montant TTC approuvé + TypeCodeAmountRejected cbc.Code = "MNA" // Montant HT non approuvé + TypeCodeAmountRejectedTTC cbc.Code = "MNATTC" // Montant TTC non approuvé + + // Rejection / correction markers + TypeCodeBankDetailsUpdate cbc.Code = "CBB" // Coordonnées bancaires bénéficiaire à modifier + TypeCodeInvalidData cbc.Code = "DIV" // Donnée invalide + TypeCodeExpectedData cbc.Code = "DVA" // Donnée valide attendue + TypeCodeOverrideData cbc.Code = "MAJ" // Donnée à prendre en compte à la place de celle présente dans la facture +) + +// typeCodes lists all accepted Characteristic.TypeCode values; used by +// validation to reject codes outside the controlled MDT-207 set. +var typeCodes = []cbc.Code{ + TypeCodeAmountReceived, TypeCodeAmountPaid, TypeCodeAmountRemaining, + TypeCodeDiscount, TypeCodeRebate, TypeCodeReduction, + TypeCodeAmountApproved, TypeCodeAmountApprovedTTC, + TypeCodeAmountRejected, TypeCodeAmountRejectedTTC, + TypeCodeBankDetailsUpdate, TypeCodeInvalidData, + TypeCodeExpectedData, TypeCodeOverrideData, +} diff --git a/addons/fr/ctc/ctc.go b/addons/fr/ctc/ctc.go index 18f67c882..f5d580d5b 100644 --- a/addons/fr/ctc/ctc.go +++ b/addons/fr/ctc/ctc.go @@ -1,9 +1,20 @@ -// Package ctc handles the extensions and validation rules for the French -// CTC (Cycle de Traitement de la Commande) Flow 2 B2B e-invoicing requirements. +// Package ctc bundles the French CTC (Continuous Transaction Control) +// e-invoicing and e-reporting addon. It covers: +// +// - Flow 2: domestic B2B clearance (cleared between two French parties). +// - Flow 10: e-reporting for B2C, cross-border or other transactions +// that fall outside the Flow 2 clearance perimeter. +// - Flow 6: lifecycle status messages (Cycle de Vie) on bill.Status +// documents exchanged between registered platforms. +// +// The invoice rule set is dispatched at validation time: an invoice +// whose supplier and customer both resolve as French (SIREN identity or +// French tax ID) runs the Flow 2 rule set; everything else runs the +// Flow 10 reporting rule set. Flow 6 operates on a separate document +// type (bill.Status) and does not need a predicate. package ctc import ( - "github.com/invopop/gobl/addons/eu/en16931" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" @@ -11,27 +22,32 @@ import ( "github.com/invopop/gobl/pkg/here" "github.com/invopop/gobl/rules" "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/schema" "github.com/invopop/gobl/tax" ) const ( - // Flow2Key identifies the French CTC Flow 2 addon family. Individual - // versions append a suffix; the family key is used as the fault-code - // namespace so that rules that carry across versions keep stable codes. - // Flow 1 is a separate rule family, not a prior version of Flow 2. - Flow2Key cbc.Key = "fr-ctc-flow2" + // Key identifies the French CTC addon family. + Key cbc.Key = "fr-ctc" - // Flow2V1 is the key for the French CTC addon - Flow2V1 cbc.Key = Flow2Key + "-v1" + // V1 is the key for the first version of the French CTC addon. + V1 cbc.Key = Key + "-v1" ) func init() { tax.RegisterAddonDef(newAddon()) + schema.Register(schema.GOBL.Add("addons/fr/ctc"), + Characteristic{}, + ) rules.RegisterWithGuard( - Flow2Key.String(), - rules.GOBL.Add("FR-CTC-FLOW2"), - is.InContext(tax.AddonIn(Flow2V1)), + Key.String(), + rules.GOBL.Add("FR-CTC"), + is.InContext(tax.AddonIn(V1)), billInvoiceRules(), + billPaymentRules(), + billStatusRules(), + billReasonRules(), + billActionRules(), orgPartyRules(), orgIdentityRules(), orgInboxRules(), @@ -41,52 +57,56 @@ func init() { func newAddon() *tax.AddonDef { return &tax.AddonDef{ - Key: Flow2V1, + Key: V1, Name: i18n.String{ - i18n.EN: "France CTC Flow 2", - i18n.FR: "France CTC Flux 2", - }, - Requires: []cbc.Key{ - en16931.V2017, + i18n.EN: "France CTC", + i18n.FR: "France CTC", }, + // eu-en16931-v2017 is required only when the dispatcher selects + // Flow 2 (domestic French B2B clearance); the Flow 2 ruleset + // enforces it. Flow 10 and Flow 6 work standalone. Description: i18n.String{ i18n.EN: here.Doc(` - Support for the French CTC (Continuous Transaction Control) Flow 2 B2B - e-invoicing requirements from the French electronic invoicing reform. - - This addon provides the necessary structures and validations to ensure compliance - with the French CTC specifications for B2B electronic invoicing. + Support for the French CTC (Continuous Transaction Control) + e-invoicing and e-reporting reform. - It requires the EN16931 addon as it extends the European standard with French-specific - requirements for the e-invoicing reform. + The addon covers three of the flows defined by the French + specification: - This addon is required for regulated invoice. This refers to invoices between two parties - registered for VAT in France. This addon should not be used for invoices which should be reported. + - Flow 2 ("facturation"): domestic B2B clearance, applied + to invoices issued between two parties identifiable as + French (SIREN or French VAT ID on both sides). + - Flow 10 ("e-reporting"): reporting of transactions that + fall outside Flow 2 clearance — B2C sales, cross-border + B2B, and payment receipts subject to e-reporting. + - Flow 6 ("cycle de vie"): lifecycle status messages + (bill.Status) exchanged between registered platforms. - Note on currency conversion (BR-FR-CO-12): When an invoice is issued in a non-EUR - currency, the gobl.ubl library will automatically handle the conversion to EUR and - present the invoice with both the original currency and EUR equivalents for tax - amounts, ensuring compliance with French accounting requirements. + The invoice ruleset is dispatched at validation time based + on whether both parties resolve as French. There is no + caller-facing switch: identify the parties correctly and + the right flow runs. `), i18n.FR: here.Doc(` - Support pour le CTC (Contrôle Continu des Transactions) français Flux 2 - pour les exigences de facturation électronique B2B de la réforme française. + Support pour la réforme française CTC (Contrôle Continu + des Transactions) de la facturation et du e-reporting. - Cet addon fournit les structures et validations nécessaires pour assurer la - conformité avec les spécifications CTC françaises pour la facturation électronique B2B. + L'addon couvre trois flux du cahier des charges : - Il nécessite l'addon EN16931 car il étend le standard européen avec des exigences - spécifiques françaises pour la réforme de la facturation électronique. + - Flux 2 (« facturation ») : clearance B2B domestique, + appliqué aux factures émises entre deux parties + identifiables comme françaises (SIREN ou numéro de TVA + français des deux côtés). + - Flux 10 (« e-reporting ») : déclaration des transactions + hors flux 2 — ventes B2C, B2B transfrontalières et + encaissements soumis au e-reporting. + - Flux 6 (« cycle de vie ») : statuts cycle de vie + (bill.Status) échangés entre plateformes agréées. - Cet addon est requis pour les factures réglementées. Cela concerne les factures entre - deux parties assujetties à la TVA en France. Cet addon ne doit pas être utilisé pour - les factures qui doivent être déclarées. - - Note sur la conversion de devises (BR-FR-CO-12) : Lorsqu'une facture est émise dans - une devise autre que l'EUR, la bibliothèque gobl.ubl gère automatiquement la conversion - en EUR et présente la facture avec à la fois la devise d'origine et les équivalents en - EUR pour les montants de TVA, garantissant la conformité avec les exigences comptables - françaises. + Le jeu de règles applicable aux factures est sélectionné + au moment de la validation selon que les deux parties + sont françaises ou non. Aucun commutateur explicite n'est + exposé : il suffit d'identifier correctement les parties. `), }, Sources: []*cbc.Source{ @@ -99,9 +119,7 @@ func newAddon() *tax.AddonDef { }, }, Extensions: extensions, - Tags: []*tax.TagSet{ - invoiceTags, - }, + Scenarios: scenarios, Normalizer: normalize, } } @@ -110,6 +128,10 @@ func normalize(doc any) { switch obj := doc.(type) { case *bill.Invoice: normalizeInvoice(obj) + case *bill.Status: + normalizeStatus(obj) + case *bill.Reason: + normalizeReason(obj) case *org.Party: normalizeParty(obj) case *org.Identity: diff --git a/addons/fr/ctc/ctc_test.go b/addons/fr/ctc/ctc_test.go new file mode 100644 index 000000000..36370d3f5 --- /dev/null +++ b/addons/fr/ctc/ctc_test.go @@ -0,0 +1,98 @@ +package ctc + +import ( + "testing" + + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/fr" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" +) + +// addonContext activates the fr-ctc rule guard so the addon's +// validators fire on standalone objects (bill.Reason / org.Party / +// org.Identity) that do not themselves carry the addon. +func addonContext() rules.WithContext { + return func(rc *rules.Context) { + rc.Set(rules.ContextKey(V1), tax.AddonForKey(V1)) + } +} + +// runNormalize invokes the addon's registered normalizer on the given +// object, matching what tax.Normalize would do during Calculate. +func runNormalize(t *testing.T, doc any) { + t.Helper() + tax.Normalize([]tax.Normalizer{tax.AddonForKey(V1).Normalizer}, doc) +} + +// frPartyWithSIREN returns a French supplier party with a SIREN +// identity, suitable for invoice-supplier and payment-supplier slots. +func frPartyWithSIREN() *org.Party { + return &org.Party{ + Name: "Supplier SARL", + TaxID: &tax.Identity{ + Country: "FR", + Code: "39356000000", + }, + Identities: []*org.Identity{ + { + Type: fr.IdentityTypeSIREN, + Code: "356000000", + Scope: org.IdentityScopeLegal, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: identitySchemeIDSIREN, + }), + }, + }, + Addresses: []*org.Address{{Country: "FR"}}, + } +} + +// frCustomerWithSIREN returns a French customer party with a SIREN +// identity. Used to trigger the Flow 2 dispatcher (both parties +// resolve as French). +func frCustomerWithSIREN() *org.Party { + return &org.Party{ + Name: "Customer SAS", + TaxID: &tax.Identity{ + Country: "FR", + Code: "44732829320", + }, + Identities: []*org.Identity{ + { + Type: fr.IdentityTypeSIREN, + Code: "732829320", + Scope: org.IdentityScopeLegal, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: identitySchemeIDSIREN, + }), + }, + }, + Addresses: []*org.Address{{Country: "FR"}}, + } +} + +// deCustomerWithVATID returns a German customer party with an EU-VAT +// identity (ICD scheme 0223). Used to trigger the Flow 10 dispatcher +// branch (at least one party is non-French). +func deCustomerWithVATID() *org.Party { + return &org.Party{ + Name: "Kunde Deutschland GmbH", + TaxID: &tax.Identity{ + Country: "DE", + Code: "111111125", + }, + Identities: []*org.Identity{ + { + Code: "DE111111125", + Scope: org.IdentityScopeLegal, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: identitySchemeIDEUVAT, + }), + }, + }, + Addresses: []*org.Address{{Country: "DE"}}, + } +} diff --git a/addons/fr/ctc/extensions.go b/addons/fr/ctc/extensions.go index 6c7bc5bb1..da89aecd2 100644 --- a/addons/fr/ctc/extensions.go +++ b/addons/fr/ctc/extensions.go @@ -4,48 +4,82 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/tax" ) -// French CTC extension keys for B2B e-invoicing +// French CTC extension keys. const ( - // ExtKeyBillingMode defines the billing framework mode (B1-B8, S1-S8, M1-M8) + // ExtKeyBillingMode defines the billing framework mode (B1-B8, S1-S8, M1-M8). + // Applies to both Flow 2 invoices and Flow 10 B2B reporting invoices. ExtKeyBillingMode cbc.Key = "fr-ctc-billing-mode" + + // ExtKeyB2CCategory classifies the B2C transaction for PPF reporting + // (G1.68). Required on Flow 10 B2C invoices. + ExtKeyB2CCategory cbc.Key = "fr-ctc-b2c-category" + + // ExtKeyRole carries the CDAR RoleCode for a party (UNCL 3035 subset) + // on a Flow 6 bill.Status message. + ExtKeyRole cbc.Key = "fr-ctc-role" + + // ExtKeyReasonCode pins the exact CDAR ReasonCode for a bill.Reason + // on a Flow 6 message. + ExtKeyReasonCode cbc.Key = "fr-ctc-reason-code" + + // ExtKeyStatusCode surfaces the CDAR ProcessConditionCode (MDT-9) + // on a Flow 6 bill.Status. + ExtKeyStatusCode cbc.Key = "fr-ctc-status-code" ) -// Billing mode codes (Cadre de Facturation) -// The prefix indicates the invoice nature: -// - B: Goods invoice (Biens) -// - S: Services invoice -// - M: Mixed/dual invoice (goods and services that are not accessory to each other) +// B2C transaction category codes (G1.68). +const ( + // B2CCategoryGoods — deliveries of goods subject to VAT. + B2CCategoryGoods cbc.Code = "TLB1" + // B2CCategoryServices — services subject to VAT. + B2CCategoryServices cbc.Code = "TPS1" + // B2CCategoryNotTaxable — deliveries / services not subject to VAT in + // France, including intra-EU distance sales under CGI art. 258 A / 259 B. + B2CCategoryNotTaxable cbc.Code = "TNT1" + // B2CCategoryMargin — operations under the VAT-on-margin regime + // (CGI art. 266-1-e, 268, 297 A). + B2CCategoryMargin cbc.Code = "TMA1" +) + +// Billing mode codes (Cadre de Facturation). Prefix denotes invoice +// nature (B/S/M); numeric suffix encodes payment context. const ( - // BillingModeB1: Deposit of a goods invoice BillingModeB1 cbc.Code = "B1" - // BillingModeB2: Deposit of an already paid goods invoice BillingModeB2 cbc.Code = "B2" - // BillingModeB4: Deposit of a final invoice (after down payment) for goods BillingModeB4 cbc.Code = "B4" - // BillingModeB7: Deposit of a goods invoice subject to e-reporting (VAT already collected) BillingModeB7 cbc.Code = "B7" - // BillingModeS1: Deposit of a service invoice BillingModeS1 cbc.Code = "S1" - // BillingModeS2: Deposit of an already paid service invoice BillingModeS2 cbc.Code = "S2" - // BillingModeS4: Deposit of a final invoice (after down payment) for services BillingModeS4 cbc.Code = "S4" - // BillingModeS5: Deposit by a subcontractor of a service invoice BillingModeS5 cbc.Code = "S5" - // BillingModeS6: Deposit by a co-contractor of a service invoice BillingModeS6 cbc.Code = "S6" - // BillingModeS7: Deposit of a service invoice subject to e-reporting (VAT already collected) BillingModeS7 cbc.Code = "S7" - // BillingModeM1: Deposit of a dual invoice (goods and services) BillingModeM1 cbc.Code = "M1" - // BillingModeM2: Deposit of an already paid dual invoice BillingModeM2 cbc.Code = "M2" - // BillingModeM4: Deposit of a final invoice (after down payment) - dual BillingModeM4 cbc.Code = "M4" ) +// Flow 6 party role codes — UNCL 3035 subset repurposed by CDAR +// (MDT-158). Labels follow the French specification, NOT the generic +// UNCL 3035 names: WK and DFH in particular have CDAR-specific +// meanings (dematerialisation platform / French public portal). +const ( + RoleBY cbc.Code = "BY" // Acheteur (Buyer) + RoleDL cbc.Code = "DL" // Affactureur (Factor) + RoleSE cbc.Code = "SE" // Vendeur (Seller) + RoleAB cbc.Code = "AB" // Agent d'acheteur (Buyer's agent) + RoleSR cbc.Code = "SR" // Agent de vendeur (Seller's agent) + RoleWK cbc.Code = "WK" // Plateforme / opérateur de dématérialisation + RoleDFH cbc.Code = "DFH" // Portail public de facturation (PPF) + RolePE cbc.Code = "PE" // Bénéficiaire (Payee) + RolePR cbc.Code = "PR" // Payeur (Payer) + RoleII cbc.Code = "II" // Invoicer (issuer of invoice) + RoleIV cbc.Code = "IV" // Invoicee +) + var extensions = []*cbc.Definition{ { Key: ExtKeyBillingMode, @@ -80,97 +114,185 @@ var extensions = []*cbc.Definition{ `), }, Values: []*cbc.Definition{ - { - Code: BillingModeB1, - Name: i18n.String{ - i18n.EN: "Goods - Deposit invoice", - i18n.FR: "Biens - Facture de dépôt", - }, - }, - { - Code: BillingModeB2, - Name: i18n.String{ - i18n.EN: "Goods - Already paid invoice", - i18n.FR: "Biens - Facture déjà payée", - }, - }, - { - Code: BillingModeB4, - Name: i18n.String{ - i18n.EN: "Goods - Final invoice (after down payment)", - i18n.FR: "Biens - Facture définitive (après acompte)", - }, - }, - { - Code: BillingModeB7, - Name: i18n.String{ - i18n.EN: "Goods - E-reporting (VAT already collected)", - i18n.FR: "Biens - E-reporting (TVA déjà collectée)", - }, - }, - { - Code: BillingModeS1, - Name: i18n.String{ - i18n.EN: "Services - Deposit invoice", - i18n.FR: "Services - Facture de dépôt", - }, - }, - { - Code: BillingModeS2, - Name: i18n.String{ - i18n.EN: "Services - Already paid invoice", - i18n.FR: "Services - Facture déjà payée", - }, - }, - { - Code: BillingModeS4, - Name: i18n.String{ - i18n.EN: "Services - Final invoice (after down payment)", - i18n.FR: "Services - Facture définitive (après acompte)", - }, - }, - { - Code: BillingModeS5, - Name: i18n.String{ - i18n.EN: "Services - Subcontractor invoice", - i18n.FR: "Services - Facture de sous-traitance", - }, - }, - { - Code: BillingModeS6, - Name: i18n.String{ - i18n.EN: "Services - Co-contractor invoice", - i18n.FR: "Services - Facture de cotraitance", - }, - }, - { - Code: BillingModeS7, - Name: i18n.String{ - i18n.EN: "Services - E-reporting (VAT already collected)", - i18n.FR: "Services - E-reporting (TVA déjà collectée)", - }, - }, - { - Code: BillingModeM1, - Name: i18n.String{ - i18n.EN: "Mixed - Deposit invoice", - i18n.FR: "Mixte - Facture de dépôt", - }, - }, - { - Code: BillingModeM2, - Name: i18n.String{ - i18n.EN: "Mixed - Already paid invoice", - i18n.FR: "Mixte - Facture déjà payée", - }, - }, - { - Code: BillingModeM4, - Name: i18n.String{ - i18n.EN: "Mixed - Final invoice (after down payment)", - i18n.FR: "Mixte - Facture définitive (après acompte)", - }, - }, + {Code: BillingModeB1, Name: i18n.String{i18n.EN: "Goods - Deposit invoice", i18n.FR: "Biens - Facture de dépôt"}}, + {Code: BillingModeB2, Name: i18n.String{i18n.EN: "Goods - Already paid invoice", i18n.FR: "Biens - Facture déjà payée"}}, + {Code: BillingModeB4, Name: i18n.String{i18n.EN: "Goods - Final invoice (after down payment)", i18n.FR: "Biens - Facture définitive (après acompte)"}}, + {Code: BillingModeB7, Name: i18n.String{i18n.EN: "Goods - E-reporting (VAT already collected)", i18n.FR: "Biens - E-reporting (TVA déjà collectée)"}}, + {Code: BillingModeS1, Name: i18n.String{i18n.EN: "Services - Deposit invoice", i18n.FR: "Services - Facture de dépôt"}}, + {Code: BillingModeS2, Name: i18n.String{i18n.EN: "Services - Already paid invoice", i18n.FR: "Services - Facture déjà payée"}}, + {Code: BillingModeS4, Name: i18n.String{i18n.EN: "Services - Final invoice (after down payment)", i18n.FR: "Services - Facture définitive (après acompte)"}}, + {Code: BillingModeS5, Name: i18n.String{i18n.EN: "Services - Subcontractor invoice", i18n.FR: "Services - Facture de sous-traitance"}}, + {Code: BillingModeS6, Name: i18n.String{i18n.EN: "Services - Co-contractor invoice", i18n.FR: "Services - Facture de cotraitance"}}, + {Code: BillingModeS7, Name: i18n.String{i18n.EN: "Services - E-reporting (VAT already collected)", i18n.FR: "Services - E-reporting (TVA déjà collectée)"}}, + {Code: BillingModeM1, Name: i18n.String{i18n.EN: "Mixed - Deposit invoice", i18n.FR: "Mixte - Facture de dépôt"}}, + {Code: BillingModeM2, Name: i18n.String{i18n.EN: "Mixed - Already paid invoice", i18n.FR: "Mixte - Facture déjà payée"}}, + {Code: BillingModeM4, Name: i18n.String{i18n.EN: "Mixed - Final invoice (after down payment)", i18n.FR: "Mixte - Facture définitive (après acompte)"}}, + }, + }, + { + Key: ExtKeyB2CCategory, + Name: i18n.String{ + i18n.EN: "B2C Transaction Category", + i18n.FR: "Catégorie de transaction B2C", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Classifies a B2C transaction for French e-reporting to the PPF + (G1.68). Required on Flow 10 B2C invoices. + + - TLB1: Goods deliveries subject to VAT. + - TPS1: Services subject to VAT. + - TNT1: Goods / services not subject to French VAT, including + intra-EU distance sales per CGI articles 258 A and 259 B. + - TMA1: Operations under the VAT-on-margin regime + (CGI articles 266-1-e, 268, 297 A). + `), + i18n.FR: here.Doc(` + Catégorie de transaction pour le e-reporting au PPF (G1.68). + Obligatoire sur les factures B2C en Flux 10. + + - TLB1 : Livraisons de biens soumises à la TVA. + - TPS1 : Prestations de services soumises à la TVA. + - TNT1 : Livraisons et prestations non soumises à la TVA en + France, dont les ventes à distance intracommunautaires + (CGI art. 258 A et 259 B). + - TMA1 : Opérations relevant du régime de TVA sur la marge + (CGI art. 266-1-e, 268, 297 A). + `), + }, + Values: []*cbc.Definition{ + {Code: B2CCategoryGoods, Name: i18n.String{i18n.EN: "Goods subject to VAT", i18n.FR: "Livraisons de biens soumises à la TVA"}}, + {Code: B2CCategoryServices, Name: i18n.String{i18n.EN: "Services subject to VAT", i18n.FR: "Prestations de services soumises à la TVA"}}, + {Code: B2CCategoryNotTaxable, Name: i18n.String{i18n.EN: "Not subject to French VAT", i18n.FR: "Non soumis à la TVA en France"}}, + {Code: B2CCategoryMargin, Name: i18n.String{i18n.EN: "VAT-on-margin regime", i18n.FR: "Régime de TVA sur la marge"}}, + }, + }, + { + Key: ExtKeyRole, + Name: i18n.String{ + i18n.EN: "Party Role Code", + i18n.FR: "Code rôle partie", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNCL 3035 role code carried as the CDAR RoleCode (MDT-158) + for each populated party on a Flow 6 lifecycle message. + Labels follow the French CTC specification, which assigns + CDAR-specific meanings to WK (dematerialisation platform / + operator) and DFH (Portail Public de Facturation). + The normalizer fills the obvious defaults (Supplier → SE, + Customer → BY) and leaves the rest for the caller to set + explicitly. + `), + }, + Values: []*cbc.Definition{ + {Code: RoleBY, Name: i18n.String{i18n.EN: "Buyer", i18n.FR: "Acheteur"}}, + {Code: RoleDL, Name: i18n.String{i18n.EN: "Factor", i18n.FR: "Affactureur"}}, + {Code: RoleSE, Name: i18n.String{i18n.EN: "Seller", i18n.FR: "Vendeur"}}, + {Code: RoleAB, Name: i18n.String{i18n.EN: "Buyer's agent", i18n.FR: "Agent d'acheteur"}}, + {Code: RoleSR, Name: i18n.String{i18n.EN: "Seller's agent", i18n.FR: "Agent de vendeur"}}, + {Code: RoleWK, Name: i18n.String{i18n.EN: "Dematerialisation platform or operator", i18n.FR: "Plateforme ou opérateur de dématérialisation"}}, + {Code: RoleDFH, Name: i18n.String{i18n.EN: "Portail Public de Facturation (PPF)", i18n.FR: "Portail Public de Facturation"}}, + {Code: RolePE, Name: i18n.String{i18n.EN: "Payee", i18n.FR: "Bénéficiaire"}}, + {Code: RolePR, Name: i18n.String{i18n.EN: "Payer", i18n.FR: "Payeur"}}, + {Code: RoleII, Name: i18n.String{i18n.EN: "Invoicer", i18n.FR: "Émetteur de la facture"}}, + {Code: RoleIV, Name: i18n.String{i18n.EN: "Invoicee", i18n.FR: "Destinataire de la facture"}}, }, }, + { + Key: ExtKeyReasonCode, + Name: i18n.String{ + i18n.EN: "CDAR Reason Code", + i18n.FR: "Code motif CDAR", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Exact CDAR ReasonCode pinned on a bill.Reason for Flow 6 + lifecycle messages. The CDAR ReasonCode dimension is 1:N + with bill.Reason.Key: this extension lets the caller pick + the precise code within a bucket. When absent, the + converter falls back to the default_for_key code for + Reason.Key. + `), + }, + Values: reasonCodeDefinitions(), + }, + { + Key: ExtKeyStatusCode, + Name: i18n.String{ + i18n.EN: "CDAR Process Condition Code", + i18n.FR: "Code condition processus CDAR", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + CDAR ProcessConditionCode (MDT-9) identifying the lifecycle + event reported by the Flow 6 message. The normalizer derives + it from the (StatusLine.Key, Status.Type) pair; callers can + pre-set it to pin a specific code (e.g. when round-tripping + a parsed CDV). + `), + }, + Values: statusCodeDefinitions(), + }, +} + +// extValue unwraps a tax.Extensions value whether the rules engine has +// passed it to us by value or by pointer. +func extValue(v any) tax.Extensions { + switch e := v.(type) { + case tax.Extensions: + return e + case *tax.Extensions: + if e == nil { + return tax.Extensions{} + } + return *e + } + return tax.Extensions{} +} + +// reasonCodeDefinitions builds the value list for the fr-ctc-reason-code +// extension from the authoritative reasonTable — avoids drift between +// the helper table and the extension's accepted value set. +func reasonCodeDefinitions() []*cbc.Definition { + out := make([]*cbc.Definition, len(reasonTable)) + for i, e := range reasonTable { + out[i] = &cbc.Definition{ + Code: cbc.Code(e.Code), + Name: i18n.String{i18n.EN: string(e.Key)}, + } + } + return out +} + +// processCodeLabels carries the official CDAR libellé for each +// ProcessConditionCode. +var processCodeLabels = map[string]string{ + "200": "Déposée", + "201": "Émise par la plateforme", + "202": "Reçue par PA", + "203": "Mise à disposition", + "204": "Prise en charge", + "205": "Approuvée", + "206": "Approuvée partiellement", + "207": "En litige", + "208": "Suspendue", + "209": "Complétée", + "210": "Refusée", + "211": "Paiement transmis", + "212": "Encaissée", + "213": "Rejetée sémantique", +} + +// statusCodeDefinitions builds the value list for fr-ctc-status-code +// from the authoritative processTable. +func statusCodeDefinitions() []*cbc.Definition { + out := make([]*cbc.Definition, 0, len(processTable)) + for _, e := range processTable { + out = append(out, &cbc.Definition{ + Code: cbc.Code(e.Code), + Name: i18n.String{i18n.EN: processCodeLabels[e.Code]}, + }) + } + return out } diff --git a/addons/fr/ctc/extensions_test.go b/addons/fr/ctc/extensions_test.go new file mode 100644 index 000000000..f5f11d8d1 --- /dev/null +++ b/addons/fr/ctc/extensions_test.go @@ -0,0 +1,27 @@ +package ctc + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestExtValueNilPointer(t *testing.T) { + assert.True(t, extValue((*tax.Extensions)(nil)).IsZero()) +} + +func TestExtValueUnknownType(t *testing.T) { + assert.True(t, extValue(42).IsZero()) +} + +func TestExtValueFromValue(t *testing.T) { + e := tax.ExtensionsOf(cbc.CodeMap{"k": "v"}) + assert.False(t, extValue(e).IsZero()) +} + +func TestExtValueFromPointer(t *testing.T) { + e := tax.ExtensionsOf(cbc.CodeMap{"k": "v"}) + assert.False(t, extValue(&e).IsZero()) +} diff --git a/addons/fr/ctc/item_test.go b/addons/fr/ctc/item_test.go deleted file mode 100644 index 10eacc9fc..000000000 --- a/addons/fr/ctc/item_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package ctc_test - -import ( - "testing" - - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/rules" - "github.com/stretchr/testify/assert" -) - -func TestItemMetaValidation(t *testing.T) { - t.Run("valid item with meta values", func(t *testing.T) { - item := &org.Item{ - Name: "Test Item", - Meta: cbc.Meta{ - "order-id": "12345", - "batch-code": "ABC-123", - }, - } - err := rules.Validate(item, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("item with blank meta value", func(t *testing.T) { - item := &org.Item{ - Name: "Test Item", - Meta: cbc.Meta{ - "order-id": "12345", - "batch-code": "", - }, - } - err := rules.Validate(item, withAddonContext()) - assert.Error(t, err) - assert.ErrorContains(t, err, "cannot be blank") - }) - - t.Run("item with whitespace-only meta value", func(t *testing.T) { - item := &org.Item{ - Name: "Test Item", - Meta: cbc.Meta{ - "order-id": "12345", - "batch-code": " ", - }, - } - err := rules.Validate(item, withAddonContext()) - assert.Error(t, err) - assert.ErrorContains(t, err, "cannot be blank") - }) - - t.Run("item without meta", func(t *testing.T) { - item := &org.Item{ - Name: "Test Item", - } - err := rules.Validate(item, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("item with empty meta map", func(t *testing.T) { - item := &org.Item{ - Name: "Test Item", - Meta: cbc.Meta{}, - } - err := rules.Validate(item, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("multiple blank values", func(t *testing.T) { - item := &org.Item{ - Name: "Test Item", - Meta: cbc.Meta{ - "order-id": "", - "batch-code": "ABC-123", - }, - } - err := rules.Validate(item, withAddonContext()) - assert.Error(t, err) - assert.ErrorContains(t, err, "cannot be blank") - }) - - t.Run("nil item", func(t *testing.T) { - err := rules.Validate((*org.Item)(nil), withAddonContext()) - assert.NoError(t, err) - }) -} diff --git a/addons/fr/ctc/org.go b/addons/fr/ctc/org.go index 0ea9d3df8..109871041 100644 --- a/addons/fr/ctc/org.go +++ b/addons/fr/ctc/org.go @@ -2,42 +2,173 @@ package ctc import ( "regexp" + "slices" + "strings" "github.com/invopop/gobl/catalogues/iso" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/fr" "github.com/invopop/gobl/tax" ) -// Inbox validation patterns -var sirenInboxFormatRegex = regexp.MustCompile(`^[A-Za-z0-9+\-_/]+$`) - +// Inbox / identity scheme constants used across the addon. const ( - // inboxSchemeSIREN is the scheme code for SIREN-based addresses (ISO/IEC 6523) + // inboxSchemeSIREN is the scheme code for SIREN-based addresses (ISO/IEC 6523). inboxSchemeSIREN cbc.Code = "0225" - // identitySchemeIDSIREN is the ISO scheme ID for SIREN identities + + // identitySchemeIDSIREN is the ISO scheme ID for SIREN identities. identitySchemeIDSIREN = "0002" + // identitySchemeIDSIRET is the ISO scheme ID for SIRET identities. + identitySchemeIDSIRET = "0009" + // identitySchemeIDEUVAT is the ISO scheme ID for EU (non-French) intra-community VAT. + identitySchemeIDEUVAT = "0223" + // identitySchemeIDNonEU is the ISO scheme ID for non-EU party identifiers. + identitySchemeIDNonEU = "0227" + // identitySchemeIDRIDET is the ISO scheme ID for New Caledonia RIDET. + identitySchemeIDRIDET = "0228" + // identitySchemeIDTAHITI is the ISO scheme ID for French Polynesia TAHITI. + identitySchemeIDTAHITI = "0229" - // identityKeyPrivateID is the key for private ID identities + // identityKeyPrivateID is the key for private ID identities. identityKeyPrivateID cbc.Key = "private-id" - // identitySchemeIDPrivate is the ISO scheme ID for identities requiring alphanumeric format + // identitySchemeIDPrivate is the ISO scheme ID for identities requiring + // alphanumeric format (CTC-specific 0224 private ID). identitySchemeIDPrivate = "0224" ) +// sirenInboxFormatRegex enforces the alphanumeric + `-+_/` format +// shared by SIREN-scope inboxes and private-id identity codes. +var sirenInboxFormatRegex = regexp.MustCompile(`^[A-Za-z0-9+\-_/]+$`) + +// allowedPartySchemeIDs lists the scheme IDs permitted for the legal +// identity of a Flow 10 B2B party (supplier or customer), per G2.19. +var allowedPartySchemeIDs = []string{ + identitySchemeIDSIREN, + identitySchemeIDEUVAT, + identitySchemeIDNonEU, + identitySchemeIDRIDET, + identitySchemeIDTAHITI, +} + +// schemeIDsRequiringVAT are the scheme IDs for which party.TaxID must +// also be present (G2.33): SIREN (French) and EU non-French VAT. +var schemeIDsRequiringVAT = []string{ + identitySchemeIDSIREN, + identitySchemeIDEUVAT, +} + +// allowedFlow6IdentitySchemes is the ICD 6523 subset CDAR accepts on +// Flow 6 (CDV lifecycle) party identities. STC (0231 — assujetti +// unique) is intentionally absent: it is a Flow 2 invoice concept +// and must not appear on a CDV. Used by bill_status rule 22; not +// applied addon-wide because Flow 10 cross-border B2B may legitimately +// carry foreign identifier schemes outside this list. +var allowedFlow6IdentitySchemes = []string{ + "0002", // SIREN + "0009", // SIRET + "0223", // EU VAT + "0224", // Private ID + "0226", // European VAT + "0227", // Non-EU + "0228", // RIDET (New Caledonia) + "0229", // TAHITI (French Polynesia) + "0238", // Peppol participant ID +} + +// allowedRoleCodes is the UNCL 3035 subset that the fr-ctc-role +// extension accepts. +var allowedRoleCodes = []cbc.Code{ + RoleSE, RoleBY, RoleWK, RoleDFH, RoleAB, RoleSR, + RoleDL, RolePE, RolePR, RoleII, RoleIV, +} + func normalizeParty(party *org.Party) { if party == nil { return } - // Normalize identities + // Derive identities from TaxID for Flow 10 reporting (SIREN for + // French, EU-VAT identity for other EU countries). The Flow 2 + // SIREN-from-SIRET path is handled below in normalizeIdentities. + normalizePartyFromTaxID(party) + + // Normalize identities (SIREN-from-SIRET, legal scope). normalizeIdentities(party) - // Normalize inboxes + // Normalize inboxes (peppol key on SIREN inbox). normalizeInboxes(party) } -// normalizeIdentities handles all identity-related normalizations +// normalizePartyFromTaxID attempts to derive a legal identity from the +// party's TaxID when no matching identity is present. Mirrors the +// pre-merge flow10 behaviour: French TaxID → SIREN identity; other-EU +// TaxID → EU-VAT identity. +func normalizePartyFromTaxID(party *org.Party) { + if party.TaxID == nil { + return + } + country := l10n.Code(party.TaxID.Country) + code := string(party.TaxID.Code) + if code == "" { + return + } + switch { + case country == l10n.FR: + ensureIdentity(party, fr.IdentityTypeSIREN, cbc.Code(sirenFromFrenchTaxID(code, party)), identitySchemeIDSIREN) + case isEUNonFrance(country): + ensureIdentity(party, "", cbc.Code(country.String()+code), identitySchemeIDEUVAT) + } +} + +// sirenFromFrenchTaxID extracts the 9-digit SIREN from a French TaxID. +// Prefers the first 9 digits of a present SIRET identity, otherwise +// falls back to the last 9 digits of the TaxID code. +func sirenFromFrenchTaxID(taxCode string, party *org.Party) string { + for _, id := range party.Identities { + if id != nil && id.Type == fr.IdentityTypeSIRET { + s := string(id.Code) + if len(s) == 14 { + return s[:9] + } + } + } + digits := strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' { + return r + } + return -1 + }, taxCode) + if len(digits) >= 9 { + return digits[len(digits)-9:] + } + return digits +} + +// ensureIdentity adds an identity matching the given scheme ID if none +// is already present. +func ensureIdentity(party *org.Party, typ cbc.Code, code cbc.Code, schemeID string) { + if code == "" { + return + } + for _, id := range party.Identities { + if id != nil && !id.Ext.IsZero() && id.Ext.Get(iso.ExtKeySchemeID).String() == schemeID { + return + } + } + party.Identities = append(party.Identities, &org.Identity{ + Type: typ, + Code: code, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + iso.ExtKeySchemeID: cbc.Code(schemeID), + }), + Scope: org.IdentityScopeLegal, + }) +} + +// normalizeIdentities handles SIRET → SIREN derivation and legal-scope +// assignment. func normalizeIdentities(party *org.Party) { if party == nil || len(party.Identities) == 0 { return @@ -46,36 +177,27 @@ func normalizeIdentities(party *org.Party) { var siret, siren *org.Identity hasLegalScope := false - // First pass: normalize each identity and collect information for _, id := range party.Identities { if id == nil { continue } - - // Normalize individual identity (sets type from scheme ID, private-id scheme) normalizeIdentity(id) - // Check for SIRET and SIREN (after normalization may have set the type) if id.Type == fr.IdentityTypeSIRET { siret = id } if id.Type == fr.IdentityTypeSIREN { siren = id } - - // Check for legal scope if id.Scope == org.IdentityScopeLegal { hasLegalScope = true } } - // BR-FR-09/10: Generate SIREN from SIRET if needed + // BR-FR-09/10: Generate SIREN from SIRET if needed. if siret != nil && siren == nil { siretCode := string(siret.Code) if len(siretCode) == 14 { - // Create SIREN identity from first 9 digits of SIRET. - // We must set the ISO scheme ID here because EN16931 has already - // processed the identities slice before FR CTC runs normalizeParty. sirenCode := siretCode[:9] siren = &org.Identity{ Type: fr.IdentityTypeSIREN, @@ -88,48 +210,135 @@ func normalizeIdentities(party *org.Party) { } } - // Set SIREN scope to legal if no other identity has legal scope + // Set SIREN scope to legal if no other identity has legal scope. if siren != nil && !hasLegalScope { siren.Scope = org.IdentityScopeLegal } } -// normalizeIdentity handles normalization for a single identity +// normalizeIdentity handles per-identity normalization: maps the +// private-id key to scheme 0224 and the SIREN/SIRET identity types +// to schemes 0002/0009 respectively. The fr-ctc addon owns this +// mapping so it works even when eu-en16931 is not declared (Flow 6 +// or standalone Flow 10 callers). func normalizeIdentity(id *org.Identity) { if id == nil { return } - - // Set ISO scheme ID 0224 for private-id key (CTC-specific) if id.Key == identityKeyPrivateID { id.Ext = id.Ext.Set(iso.ExtKeySchemeID, identitySchemeIDPrivate) } - // Note: Type ↔ ISO scheme ID mapping for SIREN/SIRET is handled by EN16931 addon + if id.Type == fr.IdentityTypeSIREN && id.Ext.Get(iso.ExtKeySchemeID) == "" { + id.Ext = id.Ext.Set(iso.ExtKeySchemeID, identitySchemeIDSIREN) + } + if id.Type == fr.IdentityTypeSIRET && id.Ext.Get(iso.ExtKeySchemeID) == "" { + id.Ext = id.Ext.Set(iso.ExtKeySchemeID, identitySchemeIDSIRET) + } } -// normalizeInboxes handles all inbox-related normalizations +// normalizeInboxes flags the SIREN-scope inbox with the peppol key +// when no other inbox already carries it. func normalizeInboxes(party *org.Party) { if party == nil || len(party.Inboxes) == 0 { return } - // Check if any inbox already has the peppol key hasPeppol := false var sirenInbox *org.Inbox for _, inbox := range party.Inboxes { if inbox == nil { continue } - if inbox.Key == "peppol" { + if inbox.Key == org.InboxKeyPeppol { hasPeppol = true } if inbox.Scheme == inboxSchemeSIREN { sirenInbox = inbox } } - - // If no inbox has peppol key and we have a SIREN inbox, set it if !hasPeppol && sirenInbox != nil { - sirenInbox.Key = "peppol" + sirenInbox.Key = org.InboxKeyPeppol + } +} + +// -- Predicates used across addon files --------------------------------- + +// partyLegalSchemeID returns the ICD 6523 scheme ID of the identity the +// party presents as its legal identifier. Prefers an identity scoped as +// "legal"; failing that, the first identity that declares a known +// scheme ID. +func partyLegalSchemeID(party *org.Party) string { + if party == nil { + return "" + } + var fallback string + for _, id := range party.Identities { + if id == nil || id.Ext.IsZero() { + continue + } + scheme := id.Ext.Get(iso.ExtKeySchemeID).String() + if scheme == "" { + continue + } + if id.Scope == org.IdentityScopeLegal { + return scheme + } + if fallback == "" && slices.Contains(allowedPartySchemeIDs, scheme) { + fallback = scheme + } + } + return fallback +} + +func isEUNonFrance(c l10n.Code) bool { + if c == l10n.FR || c == "" { + return false + } + eu := l10n.Union(l10n.EU) + return eu != nil && eu.HasMember(c) +} + +// partyHasSIREN reports whether the party carries a SIREN-scheme +// (0002) identity. +func partyHasSIREN(v any) bool { + party, ok := v.(*org.Party) + if !ok || party == nil { + return false + } + return partyCarriesSIREN(party) +} + +func partyCarriesSIREN(party *org.Party) bool { + if party == nil { + return false + } + for _, id := range party.Identities { + if id == nil { + continue + } + if id.Type == fr.IdentityTypeSIREN { + return true + } + if !id.Ext.IsZero() && id.Ext.Get(iso.ExtKeySchemeID).String() == identitySchemeIDSIREN { + return true + } + } + return false +} + +// partyIsFrench returns true when the party is identifiable as French — +// either it carries a SIREN identity or its TaxID is registered under +// the French regime. Used by the invoice-rule dispatcher to pick the +// Flow 2 ruleset. +func partyIsFrench(party *org.Party) bool { + if party == nil { + return false + } + if partyCarriesSIREN(party) { + return true + } + if party.TaxID != nil && l10n.Code(party.TaxID.Country) == l10n.FR { + return true } + return false } diff --git a/addons/fr/ctc/org_party.go b/addons/fr/ctc/org_party.go index ff0eb0e12..ad0eed385 100644 --- a/addons/fr/ctc/org_party.go +++ b/addons/fr/ctc/org_party.go @@ -3,6 +3,7 @@ package ctc import ( "errors" "fmt" + "slices" "strings" "github.com/invopop/gobl/catalogues/iso" @@ -15,17 +16,22 @@ import ( func orgPartyRules() *rules.Set { return rules.For(new(org.Party), + rules.Field("ext", + rules.Assert("01", "fr-ctc-role must be one of the UNCL 3035 subset: SE, BY, WK, DFH, AB, SR, DL, PE, PR, II, IV", + is.Func("known fr-ctc-role", partyRoleKnown), + ), + ), rules.Field("identities", - rules.Assert("01", "SIRET and SIREN must be coherent (BR-FR-09/10)", + rules.Assert("02", "SIRET and SIREN must be coherent (BR-FR-09/10)", is.Func("SIRET/SIREN coherent", identitiesSIRETSIRENCoherent), ), - rules.Assert("02", "identity scheme format invalid (BR-FR-CO-10)", + rules.Assert("03", "identity scheme format invalid (BR-FR-CO-10)", is.FuncError("valid scheme format", identitiesSchemeFormatValid), ), ), rules.Field("inboxes", rules.Each( - rules.Assert("03", "inbox code format invalid", + rules.Assert("05", "inbox code format invalid", is.Func("valid inbox", inboxCodeValid), ), ), @@ -77,6 +83,15 @@ func orgItemRules() *rules.Set { // --- Helper functions --- +func partyRoleKnown(v any) bool { + ext := extValue(v) + role := ext.Get(ExtKeyRole) + if role == "" { + return true + } + return slices.Contains(allowedRoleCodes, role) +} + func identitiesSIRETSIRENCoherent(val any) bool { identities, ok := val.([]*org.Identity) if !ok || len(identities) == 0 { diff --git a/addons/fr/ctc/org_test.go b/addons/fr/ctc/org_test.go index f2a3b295e..424997bf5 100644 --- a/addons/fr/ctc/org_test.go +++ b/addons/fr/ctc/org_test.go @@ -1,12 +1,12 @@ -package ctc_test +package ctc import ( "strings" "testing" - "github.com/invopop/gobl/addons/fr/ctc" "github.com/invopop/gobl/catalogues/iso" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/fr" "github.com/invopop/gobl/rules" @@ -14,22 +14,21 @@ import ( "github.com/stretchr/testify/assert" ) +// withAddonContext is the historical name for addonContext kept so the +// ported flow2 tests read unchanged. +func withAddonContext() rules.WithContext { return addonContext() } + +// --- Inbox / SIREN inbox validation (BR-FR-CO-10 + BR-FR-13) ------------ + func TestElectronicAddressValidation(t *testing.T) { t.Run("valid SIREN inbox matching VAT", func(t *testing.T) { party := &org.Party{ - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", // 2 check digits + 9 digit SIREN - }, + TaxID: &tax.Identity{Country: "FR", Code: "44732829320"}, Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "732829320", // Starts with SIREN from VAT - }, + {Scheme: cbc.Code("0225"), Code: "732829320"}, }, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) t.Run("valid SIREN inbox matching SIREN identity", func(t *testing.T) { @@ -38,65 +37,40 @@ func TestElectronicAddressValidation(t *testing.T) { { Type: fr.IdentityTypeSIREN, Code: "123456789", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0002", // ISO scheme ID required by BR-FR-CO-10 - }), - }, - }, - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "123456789", // Starts with SIREN identity + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"}), }, }, + Inboxes: []*org.Inbox{{Scheme: cbc.Code("0225"), Code: "123456789"}}, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) - t.Run("SIREN inbox with additional routing info (cbc.Code limits apply)", func(t *testing.T) { + t.Run("SIREN inbox with + character rejected by cbc.Code base rule", func(t *testing.T) { party := &org.Party{ - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", - }, + TaxID: &tax.Identity{Country: "FR", Code: "44732829320"}, Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "732829320+routing", // Contains + which isn't in cbc.Code allowed chars - }, + {Scheme: cbc.Code("0225"), Code: "732829320+routing"}, }, } err := rules.Validate(party, withAddonContext()) - // cbc.Code base validation doesn't allow + character assert.Error(t, err) assert.ErrorContains(t, err, "code") }) t.Run("SIREN inbox with any valid format is accepted", func(t *testing.T) { party := &org.Party{ - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", - }, + TaxID: &tax.Identity{Country: "FR", Code: "44732829320"}, Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "999999999", // Format check only, not SIREN match - }, + {Scheme: cbc.Code("0225"), Code: "999999999"}, }, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) t.Run("SIREN inbox invalid characters", func(t *testing.T) { party := &org.Party{ Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "123456789@invalid", // @ not allowed - }, + {Scheme: cbc.Code("0225"), Code: "123456789@invalid"}, }, } err := rules.Validate(party, withAddonContext()) @@ -104,60 +78,38 @@ func TestElectronicAddressValidation(t *testing.T) { }) t.Run("SIREN inbox without party context is valid", func(t *testing.T) { - // When party has no SIREN/VAT, any valid format is accepted party := &org.Party{ Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "123456789", - }, + {Scheme: cbc.Code("0225"), Code: "123456789"}, }, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) - t.Run("SIREN inbox with allowed cbc.Code separators", func(t *testing.T) { + t.Run("SIREN inbox with allowed separators", func(t *testing.T) { party := &org.Party{ Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "123456789-test", // cbc.Code allows separators between alphanumeric - }, + {Scheme: cbc.Code("0225"), Code: "123456789-test"}, }, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) t.Run("SIREN inbox at cbc.Code max length (64 characters)", func(t *testing.T) { - // cbc.Code has max length of 64, not 125 - longCode := "1234567890123456789012345678901234567890123456789012345678901234" - assert.Equal(t, 64, len(longCode)) - + longCode := strings.Repeat("1", 64) party := &org.Party{ Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: cbc.Code(longCode), - }, + {Scheme: cbc.Code("0225"), Code: cbc.Code(longCode)}, }, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) t.Run("SIREN inbox exceeds cbc.Code max length (65 characters)", func(t *testing.T) { - // cbc.Code max is 64, so 65 should fail - tooLongCode := "12345678901234567890123456789012345678901234567890123456789012345" - assert.Equal(t, 65, len(tooLongCode)) - + tooLong := strings.Repeat("1", 65) party := &org.Party{ Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: cbc.Code(tooLongCode), - }, + {Scheme: cbc.Code("0225"), Code: cbc.Code(tooLong)}, }, } err := rules.Validate(party, withAddonContext()) @@ -165,388 +117,274 @@ func TestElectronicAddressValidation(t *testing.T) { }) } +// --- Peppol key normalisation (normalizeInboxes) ------------------------ + func TestPeppolKeyNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(V1) t.Run("peppol key set on SIREN inbox when none exist", func(t *testing.T) { party := &org.Party{ - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", - }, + TaxID: &tax.Identity{Country: "FR", Code: "44732829320"}, Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "732829320", - }, - }, - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "732829320", - }, + {Type: fr.IdentityTypeSIREN, Code: "732829320"}, }, + Inboxes: []*org.Inbox{{Scheme: cbc.Code("0225"), Code: "732829320"}}, } ad.Normalizer(party) - // Check that peppol key was set - assert.Equal(t, "peppol", party.Inboxes[0].Key.String()) + assert.Equal(t, org.InboxKeyPeppol, party.Inboxes[0].Key) }) - t.Run("peppol key not set if another inbox already has it", func(t *testing.T) { + t.Run("peppol key not duplicated when another inbox already has it", func(t *testing.T) { party := &org.Party{ - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", - }, + TaxID: &tax.Identity{Country: "FR", Code: "44732829320"}, Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "732829320", - }, + {Type: fr.IdentityTypeSIREN, Code: "732829320"}, }, Inboxes: []*org.Inbox{ - { - Key: "peppol", - Scheme: "0088", - Code: "1234567890123", - }, - { - Scheme: cbc.Code("0225"), - Code: "732829320", - }, + {Key: org.InboxKeyPeppol, Scheme: "0088", Code: "1234567890123"}, + {Scheme: cbc.Code("0225"), Code: "732829320"}, }, } ad.Normalizer(party) - // Check that SIREN inbox does not have peppol key - assert.NotEqual(t, "peppol", party.Inboxes[1].Key.String()) - assert.Equal(t, "", party.Inboxes[1].Key.String()) + assert.NotEqual(t, org.InboxKeyPeppol, party.Inboxes[1].Key) }) t.Run("peppol key set even for non-French party", func(t *testing.T) { party := &org.Party{ - TaxID: &tax.Identity{ - Country: "DE", - Code: "123456789", - }, - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "123456789", - }, - }, + TaxID: &tax.Identity{Country: "DE", Code: "123456789"}, + Inboxes: []*org.Inbox{{Scheme: cbc.Code("0225"), Code: "123456789"}}, } ad.Normalizer(party) - // Check that peppol key was set (addon usage implies French context) - assert.Equal(t, "peppol", party.Inboxes[0].Key.String()) + assert.Equal(t, org.InboxKeyPeppol, party.Inboxes[0].Key) }) t.Run("peppol key not set if no SIREN inbox", func(t *testing.T) { party := &org.Party{ - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", - }, + TaxID: &tax.Identity{Country: "FR", Code: "44732829320"}, Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "732829320", - }, - }, - Inboxes: []*org.Inbox{ - { - Scheme: "0088", - Code: "1234567890123", - }, + {Type: fr.IdentityTypeSIREN, Code: "732829320"}, }, + Inboxes: []*org.Inbox{{Scheme: "0088", Code: "1234567890123"}}, } ad.Normalizer(party) - // Check that inbox does not have peppol key - assert.Equal(t, "", party.Inboxes[0].Key.String()) + assert.Equal(t, cbc.Key(""), party.Inboxes[0].Key) }) } +// --- Identity scheme format (BR-FR-CO-10) ------------------------------- + func TestIdentitySchemeFormatValidation(t *testing.T) { - t.Run("valid identity with scheme 0224 - alphanumeric", func(t *testing.T) { + t.Run("valid 0224 alphanumeric", func(t *testing.T) { party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123XYZ", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - "iso-scheme-id": "0224", - }), - }, - }, + Identities: []*org.Identity{{ + Code: "ABC123XYZ", + Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "0224"}), + }}, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) - t.Run("valid identity with scheme 0224 - with allowed special characters", func(t *testing.T) { + t.Run("valid 0224 with allowed special characters", func(t *testing.T) { party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123-info_data/route", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - "iso-scheme-id": "0224", - }), - }, - }, + Identities: []*org.Identity{{ + Code: "ABC123-info_data/route", + Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "0224"}), + }}, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) - t.Run("invalid identity with scheme 0224 - special chars not allowed", func(t *testing.T) { + t.Run("invalid 0224 special chars rejected", func(t *testing.T) { party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123@invalid", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - "iso-scheme-id": "0224", - }), - }, - }, + Identities: []*org.Identity{{ + Code: "ABC123@invalid", + Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "0224"}), + }}, } err := rules.Validate(party, withAddonContext()) assert.ErrorContains(t, err, "must be in a valid format") }) - t.Run("identity with other scheme ID not validated by CTC", func(t *testing.T) { + t.Run("scheme 0002 not subject to 0224 alphanumeric rules", func(t *testing.T) { party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123", // Valid cbc.Code format, different scheme - Ext: tax.ExtensionsOf(cbc.CodeMap{ - "iso-scheme-id": "0002", - }), - }, - }, + Identities: []*org.Identity{{ + Code: "ABC123", + Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "0002"}), + }}, } - err := rules.Validate(party, withAddonContext()) - // Should not fail for scheme 0002 (CTC-specific 0224 rules don't apply) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) t.Run("identity without scheme ID rejected (BR-FR-CO-10)", func(t *testing.T) { party := &org.Party{ - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "123456789", // Valid code but missing ISO scheme ID - }, - }, + Identities: []*org.Identity{{ + Type: fr.IdentityTypeSIREN, + Code: "123456789", + }}, } err := rules.Validate(party, withAddonContext()) - // BR-FR-CO-10: All identities must have an ISO scheme ID assert.ErrorContains(t, err, "BR-FR-CO-10") }) - t.Run("identity with scheme 0224 at cbc.Code max length (64 characters)", func(t *testing.T) { - // cbc.Code base rules limit to 64 characters; CTC allows up to 100 - // but cbc.Code rules take precedence - longCode := "1234567890123456789012345678901234567890123456789012345678901234" - assert.Equal(t, 64, len(longCode)) - + t.Run("0224 at cbc.Code max length (64)", func(t *testing.T) { + longCode := strings.Repeat("1", 64) party := &org.Party{ - Identities: []*org.Identity{ - { - Code: cbc.Code(longCode), - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0224", - }), - }, - }, + Identities: []*org.Identity{{ + Code: cbc.Code(longCode), + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0224"}), + }}, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(party, withAddonContext())) }) - t.Run("identity with scheme 0224 exceeds cbc.Code max length (65 characters)", func(t *testing.T) { - // cbc.Code base rules limit to 64 characters - tooLongCode := "12345678901234567890123456789012345678901234567890123456789012345" - assert.Equal(t, 65, len(tooLongCode)) - + t.Run("0224 exceeds cbc.Code max length (65)", func(t *testing.T) { + tooLong := strings.Repeat("1", 65) party := &org.Party{ - Identities: []*org.Identity{ - { - Code: cbc.Code(tooLongCode), - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0224", - }), - }, - }, + Identities: []*org.Identity{{ + Code: cbc.Code(tooLong), + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0224"}), + }}, } err := rules.Validate(party, withAddonContext()) assert.ErrorContains(t, err, "no longer than 64") }) } +// --- Private ID normalization (private-id key → 0224) ------------------- + func TestPrivateIDNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(V1) - t.Run("private-id key sets ISO scheme ID 0224", func(t *testing.T) { + t.Run("private-id key sets scheme 0224", func(t *testing.T) { party := &org.Party{ Identities: []*org.Identity{ - { - Key: cbc.Key("private-id"), - Code: "ABC123XYZ", - }, + {Key: cbc.Key("private-id"), Code: "ABC123XYZ"}, }, } ad.Normalizer(party) - // Check that ISO scheme ID was set assert.Equal(t, "0224", party.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) }) - t.Run("private-id key sets ISO scheme ID 0224 with existing extensions", func(t *testing.T) { + t.Run("private-id keeps pre-existing extensions", func(t *testing.T) { party := &org.Party{ - Identities: []*org.Identity{ - { - Key: cbc.Key("private-id"), - Code: "ABC123XYZ", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - "other-key": "other-value", - }), - }, - }, + Identities: []*org.Identity{{ + Key: cbc.Key("private-id"), + Code: "ABC123XYZ", + Ext: tax.ExtensionsOf(cbc.CodeMap{"other-key": "other-value"}), + }}, } ad.Normalizer(party) - // Check that ISO scheme ID was set and other extensions preserved assert.Equal(t, "0224", party.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) assert.Equal(t, "other-value", party.Identities[0].Ext.Get("other-key").String()) }) - t.Run("identity without private-id key not modified", func(t *testing.T) { + t.Run("SIREN identity gets scheme 0002 from normaliser", func(t *testing.T) { party := &org.Party{ Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "123456789", - }, + {Type: fr.IdentityTypeSIREN, Code: "123456789"}, }, } ad.Normalizer(party) - // Check that no ISO scheme ID was set + assert.Equal(t, "0002", party.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) + }) + + t.Run("untyped identity without private-id key not modified", func(t *testing.T) { + party := &org.Party{ + Identities: []*org.Identity{{Code: "ABC123"}}, + } + ad.Normalizer(party) assert.True(t, party.Identities[0].Ext.IsZero()) }) - t.Run("existing ISO scheme ID not overwritten", func(t *testing.T) { + t.Run("private-id overrides pre-existing scheme ID", func(t *testing.T) { party := &org.Party{ - Identities: []*org.Identity{ - { - Key: cbc.Key("private-id"), - Code: "ABC123XYZ", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "9999", // Pre-existing value - }), - }, - }, + Identities: []*org.Identity{{ + Key: cbc.Key("private-id"), + Code: "ABC123XYZ", + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "9999"}), + }}, } ad.Normalizer(party) - // Check that ISO scheme ID was overwritten to 0224 assert.Equal(t, "0224", party.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) }) } +// --- SIREN-from-SIRET normalization ------------------------------------- + func TestSIRENGenerationFromSIRET(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(V1) - t.Run("generated SIREN from SIRET", func(t *testing.T) { + t.Run("SIREN generated from SIRET", func(t *testing.T) { party := &org.Party{ Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - }, + {Type: fr.IdentityTypeSIRET, Code: "12345678901234"}, }, } ad.Normalizer(party) - // Should have generated a SIREN identity assert.Len(t, party.Identities, 2) - // Find the SIREN identity - var sirenIdentity *org.Identity + var siren *org.Identity for _, id := range party.Identities { if id.Type == fr.IdentityTypeSIREN { - sirenIdentity = id - break + siren = id } } - assert.NotNil(t, sirenIdentity) - assert.Equal(t, "123456789", sirenIdentity.Code.String()) - // ISO scheme ID must be set here because EN16931 already ran over the - // identities slice before FR CTC appends the new SIREN identity. - assert.Equal(t, "0002", sirenIdentity.Ext.Get(iso.ExtKeySchemeID).String()) + assert.NotNil(t, siren) + assert.Equal(t, "123456789", siren.Code.String()) + assert.Equal(t, "0002", siren.Ext.Get(iso.ExtKeySchemeID).String()) }) t.Run("generated SIREN gets legal scope", func(t *testing.T) { party := &org.Party{ Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - }, + {Type: fr.IdentityTypeSIRET, Code: "12345678901234"}, }, } ad.Normalizer(party) - // Find the SIREN identity - var sirenIdentity *org.Identity + var siren *org.Identity for _, id := range party.Identities { if id.Type == fr.IdentityTypeSIREN { - sirenIdentity = id - break + siren = id } } - assert.NotNil(t, sirenIdentity) - assert.Equal(t, org.IdentityScopeLegal, sirenIdentity.Scope) + assert.NotNil(t, siren) + assert.Equal(t, org.IdentityScopeLegal, siren.Scope) }) - t.Run("SIREN not generated if already exists", func(t *testing.T) { + t.Run("SIREN not regenerated when already present", func(t *testing.T) { party := &org.Party{ Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - }, - { - Type: fr.IdentityTypeSIREN, - Code: "123456789", - }, + {Type: fr.IdentityTypeSIRET, Code: "12345678901234"}, + {Type: fr.IdentityTypeSIREN, Code: "123456789"}, }, } ad.Normalizer(party) - // Should not add another SIREN assert.Len(t, party.Identities, 2) }) } -// Additional edge cases for better coverage +// --- Identity edge cases ------------------------------------------------ + func TestValidateIdentityEdgeCases(t *testing.T) { t.Run("nil identity returns nil", func(t *testing.T) { err := rules.Validate((*org.Identity)(nil), withAddonContext()) assert.NoError(t, err) }) - t.Run("identity with ISO scheme 0224 and code over 100 chars", func(t *testing.T) { + t.Run("0224 code over 100 chars rejected", func(t *testing.T) { id := &org.Identity{ Code: cbc.Code(strings.Repeat("A", 101)), - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0224", - }), + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0224"}), } err := rules.Validate(id, withAddonContext()) - assert.Error(t, err) assert.ErrorContains(t, err, "must be no more than 100") }) - t.Run("identity with ISO scheme 0224 and valid code", func(t *testing.T) { + t.Run("0224 valid code", func(t *testing.T) { id := &org.Identity{ Code: "VALID-CODE_123", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0224", - }), + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0224"}), } - err := rules.Validate(id, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(id, withAddonContext())) }) } @@ -556,39 +394,31 @@ func TestValidatePartyEdgeCases(t *testing.T) { assert.NoError(t, err) }) - t.Run("party with SIRET but mismatching SIREN", func(t *testing.T) { + t.Run("SIRET with mismatching SIREN rejected (BR-FR-09/10)", func(t *testing.T) { party := &org.Party{ Name: "Test Party", Identities: []*org.Identity{ { Type: fr.IdentityTypeSIRET, Code: "12345678901234", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0009", - }), + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0009"}), }, { Type: fr.IdentityTypeSIREN, Code: "999999999", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0002", - }), + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"}), }, }, } err := rules.Validate(party, withAddonContext()) - assert.Error(t, err) assert.ErrorContains(t, err, "BR-FR-09/10") }) - t.Run("party with invalid inbox scheme 0225 code", func(t *testing.T) { + t.Run("inbox scheme 0225 code over 125 chars rejected", func(t *testing.T) { party := &org.Party{ Name: "Test Party", Inboxes: []*org.Inbox{ - { - Scheme: "0225", - Code: cbc.Code(strings.Repeat("A", 126)), - }, + {Scheme: "0225", Code: cbc.Code(strings.Repeat("A", 126))}, }, } err := rules.Validate(party, withAddonContext()) @@ -597,41 +427,29 @@ func TestValidatePartyEdgeCases(t *testing.T) { } func TestNormalizePartyEdgeCases(t *testing.T) { - t.Run("normalize nil party", func(_ *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(V1) + + t.Run("nil party is safe", func(_ *testing.T) { ad.Normalizer((*org.Party)(nil)) - // Should not crash }) - t.Run("normalize party without identities", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - } - ad := tax.AddonForKey(ctc.Flow2V1) + t.Run("party without identities", func(t *testing.T) { + party := &org.Party{Name: "Test Party"} ad.Normalizer(party) assert.Len(t, party.Identities, 0) }) - t.Run("normalize party with nil identity in array", func(t *testing.T) { - var id *org.Identity + t.Run("party with nil identity in array", func(t *testing.T) { + var nilID *org.Identity party := &org.Party{ Name: "Test Party", Identities: []*org.Identity{ - id, // Nil identity should be skipped via continue - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - }, + nilID, + {Type: fr.IdentityTypeSIRET, Code: "12345678901234"}, }, } - ad := tax.AddonForKey(ctc.Flow2V1) ad.Normalizer(party) - - // Should have generated SIREN from SIRET, plus the original SIRET, plus 1 nil - // Total: 3 elements (1 nil + SIRET + generated SIREN) assert.Len(t, party.Identities, 3) - - // Count non-nil identities nonNilCount := 0 var hasSIREN, hasSIRET bool for _, id := range party.Identities { @@ -645,112 +463,9 @@ func TestNormalizePartyEdgeCases(t *testing.T) { } } } - - assert.Equal(t, 2, nonNilCount, "should have 2 non-nil identities (SIRET + generated SIREN)") - assert.True(t, hasSIREN, "should have generated SIREN") - assert.True(t, hasSIRET, "should have original SIRET") - }) - - t.Run("normalize party with SIRET generates SIREN with legal scope", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0009", - }), - }, - }, - } - ad := tax.AddonForKey(ctc.Flow2V1) - ad.Normalizer(party) - - // Should have generated SIREN - assert.Len(t, party.Identities, 2) - - // Find the generated SIREN - var siren *org.Identity - for _, id := range party.Identities { - if id.Type == fr.IdentityTypeSIREN { - siren = id - } - } - assert.NotNil(t, siren) - assert.Equal(t, cbc.Code("123456789"), siren.Code) - assert.Equal(t, org.IdentityScopeLegal, siren.Scope) - }) - - t.Run("normalize party with SIRET and existing SIREN with legal scope", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0009", - }), - }, - { - Type: fr.IdentityTypeSIREN, - Code: "123456789", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0002", - }), - Scope: org.IdentityScopeLegal, - }, - }, - } - ad := tax.AddonForKey(ctc.Flow2V1) - ad.Normalizer(party) - - // Should not generate duplicate SIREN - var sirenCount int - for _, id := range party.Identities { - if id.Type == fr.IdentityTypeSIREN { - sirenCount++ - } - } - assert.Equal(t, 1, sirenCount) - }) - - t.Run("normalize inbox with SIREN scheme sets peppol key", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Inboxes: []*org.Inbox{ - { - Scheme: "0225", - Code: "123456789:test", - }, - }, - } - ad := tax.AddonForKey(ctc.Flow2V1) - ad.Normalizer(party) - assert.Equal(t, cbc.Key("peppol"), party.Inboxes[0].Key) - }) - - t.Run("normalize inbox does not override existing peppol key", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Inboxes: []*org.Inbox{ - { - Key: "peppol", - Scheme: "0088", - Code: "existing", - }, - { - Scheme: "0225", - Code: "123456789:test", - }, - }, - } - ad := tax.AddonForKey(ctc.Flow2V1) - ad.Normalizer(party) - // First inbox should keep its peppol key, second should not get it - assert.Equal(t, cbc.Key("peppol"), party.Inboxes[0].Key) - assert.NotEqual(t, cbc.Key("peppol"), party.Inboxes[1].Key) + assert.Equal(t, 2, nonNilCount) + assert.True(t, hasSIREN) + assert.True(t, hasSIRET) }) t.Run("normalize inbox with nil element in array", func(t *testing.T) { @@ -758,58 +473,35 @@ func TestNormalizePartyEdgeCases(t *testing.T) { party := &org.Party{ Name: "Test Party", Inboxes: []*org.Inbox{ - nilInbox, // Nil inbox should be skipped via continue - { - Scheme: "0225", - Code: "123456789:test", - }, - nilInbox, // Another nil for good measure + nilInbox, + {Scheme: "0225", Code: "123456789-test"}, + nilInbox, }, } - ad := tax.AddonForKey(ctc.Flow2V1) ad.Normalizer(party) - - // Should still have 3 elements (2 nils + 1 valid inbox) assert.Len(t, party.Inboxes, 3) - - // Count non-nil inboxes and verify peppol key was set - nonNilCount := 0 - hasPeppol := false + var hasPeppol bool for _, inbox := range party.Inboxes { - if inbox != nil { - nonNilCount++ - if inbox.Key == "peppol" { - hasPeppol = true - } + if inbox != nil && inbox.Key == org.InboxKeyPeppol { + hasPeppol = true } } - - assert.Equal(t, 1, nonNilCount, "should have 1 non-nil inbox") - assert.True(t, hasPeppol, "SIREN inbox should have peppol key set") + assert.True(t, hasPeppol) }) } func TestValidateIdentitySchemeFormatEdgeCases(t *testing.T) { t.Run("empty identities returns nil", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{}, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) + party := &org.Party{Name: "Test Party", Identities: []*org.Identity{}} + assert.NoError(t, rules.Validate(party, withAddonContext())) }) t.Run("identity without ext returns error", func(t *testing.T) { party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - { - Code: "123", - }, - }, + Name: "Test Party", + Identities: []*org.Identity{{Code: "123"}}, } err := rules.Validate(party, withAddonContext()) - assert.Error(t, err) assert.ErrorContains(t, err, "BR-FR-CO-10") }) @@ -817,22 +509,11 @@ func TestValidateIdentitySchemeFormatEdgeCases(t *testing.T) { party := &org.Party{ Name: "Test Party", Identities: []*org.Identity{ - { - Code: "123", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0002", - }), - }, - { - Code: "456", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0002", - }), - }, + {Code: "123", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"})}, + {Code: "456", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"})}, }, } err := rules.Validate(party, withAddonContext()) - assert.Error(t, err) assert.ErrorContains(t, err, "BR-FR-CO-10") }) @@ -841,40 +522,23 @@ func TestValidateIdentitySchemeFormatEdgeCases(t *testing.T) { party := &org.Party{ Name: "Test Party", Identities: []*org.Identity{ - nilID, // Nil identity should be skipped via continue - { - Code: "123", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0002", - }), - }, + nilID, + {Code: "123", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"})}, }, } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err, "validation should skip nil identity and succeed with valid identity") + assert.NoError(t, rules.Validate(party, withAddonContext())) }) - t.Run("private-id (0224) with empty code", func(t *testing.T) { + t.Run("0224 with empty code rejected by base identity rules", func(t *testing.T) { party := &org.Party{ Name: "Test Party", Identities: []*org.Identity{ - { - Code: "", // Empty code - base Identity rules require code - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0224", // private-id scheme - }), - }, - { - Code: "valid-id", - Ext: tax.ExtensionsOf(cbc.CodeMap{ - iso.ExtKeySchemeID: "0002", - }), - }, + {Code: "", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0224"})}, + {Code: "valid-id", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"})}, }, } err := rules.Validate(party, withAddonContext()) - // Base org.Identity rules require code to be present - assert.Error(t, err, "base identity rules require code") + assert.Error(t, err) }) } @@ -884,21 +548,236 @@ func TestValidateInboxEdgeCases(t *testing.T) { assert.NoError(t, err) }) - t.Run("inbox with scheme 0225 and valid code", func(t *testing.T) { - inbox := &org.Inbox{ - Scheme: "0225", - Code: "123456789-valid-code", + t.Run("0225 scheme with valid code", func(t *testing.T) { + inbox := &org.Inbox{Scheme: "0225", Code: "123456789-valid-code"} + assert.NoError(t, rules.Validate(inbox, withAddonContext())) + }) + + t.Run("non-0225 scheme not validated", func(t *testing.T) { + inbox := &org.Inbox{Scheme: "9999", Code: "ANY-CODE-FORMAT"} + assert.NoError(t, rules.Validate(inbox, withAddonContext())) + }) +} + +func TestItemMetaValidation(t *testing.T) { + t.Run("valid item with meta values", func(t *testing.T) { + item := &org.Item{ + Name: "Test Item", + Meta: cbc.Meta{"order-id": "12345", "batch-code": "ABC-123"}, } - err := rules.Validate(inbox, withAddonContext()) - assert.NoError(t, err) + assert.NoError(t, rules.Validate(item, withAddonContext())) }) - t.Run("inbox with different scheme is not validated", func(t *testing.T) { - inbox := &org.Inbox{ - Scheme: "9999", - Code: "ANY-CODE-FORMAT", // Different scheme, CTC doesn't validate it + t.Run("blank meta value rejected", func(t *testing.T) { + item := &org.Item{ + Name: "Test Item", + Meta: cbc.Meta{"order-id": "12345", "batch-code": ""}, } - err := rules.Validate(inbox, withAddonContext()) - assert.NoError(t, err) + err := rules.Validate(item, withAddonContext()) + assert.ErrorContains(t, err, "cannot be blank") }) + + t.Run("whitespace-only meta value rejected", func(t *testing.T) { + item := &org.Item{ + Name: "Test Item", + Meta: cbc.Meta{"order-id": "12345", "batch-code": " "}, + } + err := rules.Validate(item, withAddonContext()) + assert.ErrorContains(t, err, "cannot be blank") + }) + + t.Run("item without meta", func(t *testing.T) { + assert.NoError(t, rules.Validate(&org.Item{Name: "Test"}, withAddonContext())) + }) + + t.Run("empty meta map", func(t *testing.T) { + item := &org.Item{Name: "Test", Meta: cbc.Meta{}} + assert.NoError(t, rules.Validate(item, withAddonContext())) + }) + + t.Run("nil item", func(t *testing.T) { + assert.NoError(t, rules.Validate((*org.Item)(nil), withAddonContext())) + }) +} + +// --- Flow 10 normalizeParty edge cases ---------------------------------- + +func TestIsEUNonFranceEmpty(t *testing.T) { + assert.False(t, isEUNonFrance("")) +} + +func TestIsEUNonFranceFrance(t *testing.T) { + assert.False(t, isEUNonFrance(l10n.FR)) +} + +func TestIsEUNonFranceSpain(t *testing.T) { + assert.True(t, isEUNonFrance(l10n.ES)) +} + +func TestIsEUNonFranceUSA(t *testing.T) { + assert.False(t, isEUNonFrance(l10n.US)) +} + +func TestNormalizePartyNilSafe(t *testing.T) { + assert.NotPanics(t, func() { normalizeParty(nil) }) +} + +func TestNormalizePartyNoTaxIDNoChange(t *testing.T) { + p := &org.Party{Name: "Solo"} + normalizeParty(p) + assert.Empty(t, p.Identities) +} + +func TestNormalizePartyEmptyTaxIDCodeNoChange(t *testing.T) { + p := &org.Party{TaxID: &tax.Identity{Country: "FR"}} + normalizeParty(p) + assert.Empty(t, p.Identities) +} + +func TestNormalizePartyNonEUNonFRNoChange(t *testing.T) { + p := &org.Party{TaxID: &tax.Identity{Country: "US", Code: "12-3456789"}} + normalizeParty(p) + assert.Empty(t, p.Identities) +} + +func TestSirenFromFrenchTaxIDSIRETFallback(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{Code: "35600000000011"}}} + got := sirenFromFrenchTaxID("FR39356000000", p) + assert.Len(t, got, 9) +} + +func TestSirenFromFrenchTaxIDSIRETWrongLength(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{Code: "1234"}}} + got := sirenFromFrenchTaxID("FR39356000000", p) + assert.Equal(t, "356000000", got) +} + +func TestSirenFromFrenchTaxIDShortInput(t *testing.T) { + got := sirenFromFrenchTaxID("FR12", &org.Party{}) + assert.Equal(t, "12", got) +} + +func TestEnsureIdentityEmptyCode(t *testing.T) { + p := &org.Party{} + ensureIdentity(p, "", "", "0002") + assert.Empty(t, p.Identities) +} + +func TestEnsureIdentityExistingSchemeLeftUntouched(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{ + Code: "existing", + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"}), + }}} + ensureIdentity(p, "", "new", "0002") + assert.Len(t, p.Identities, 1) + assert.Equal(t, cbc.Code("existing"), p.Identities[0].Code) +} + +func TestPartyLegalSchemeIDNil(t *testing.T) { + assert.Equal(t, "", partyLegalSchemeID(nil)) +} + +func TestPartyLegalSchemeIDNoSchemeExt(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{Code: "X"}}} + assert.Equal(t, "", partyLegalSchemeID(p)) +} + +func TestPartyLegalSchemeIDLegalScopeWins(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{ + {Code: "A", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0227"})}, + {Code: "B", Scope: org.IdentityScopeLegal, Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"})}, + }} + assert.Equal(t, "0002", partyLegalSchemeID(p)) +} + +func TestPartyLegalSchemeIDFallbackUsed(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{ + {Code: "A", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "9999"})}, + {Code: "B", Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0002"})}, + }} + assert.Equal(t, "0002", partyLegalSchemeID(p)) +} + +// --- Flow 6 role / scheme validation ------------------------------------ + +func TestPartyUnknownRoleRejected(t *testing.T) { + p := &org.Party{ + Name: "Agent", + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyRole: "XXX"}), + } + err := rules.Validate(p, addonContext()) + assert.ErrorContains(t, err, "UNCL 3035") +} + +func TestPartyKnownRoleAccepted(t *testing.T) { + p := &org.Party{ + Name: "Platform", + Ext: tax.ExtensionsOf(cbc.CodeMap{ExtKeyRole: RoleWK}), + } + assert.NoError(t, rules.Validate(p, addonContext())) +} + +// TestPartyForeignIdentitySchemeAccepted confirms that an addon-wide +// party check does NOT reject foreign / unknown iso-scheme-ids on +// secondary identities — required for Flow 10 cross-border B2B, where +// a foreign customer may legitimately declare an identifier in a +// scheme outside the French / Flow 6 allow-list. Flow-specific +// constraints (Flow 6 rule 22 on bill.Status, Flow 10 rules 53/58 on +// legal identities) handle stricter checks at their level. +func TestPartyForeignIdentitySchemeAccepted(t *testing.T) { + p := &org.Party{ + Name: "Foreign Counterparty", + Identities: []*org.Identity{{ + Code: "X", + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "9999"}), + }}, + } + assert.NoError(t, rules.Validate(p, addonContext())) +} + +func TestPartyRoleKnownEmptyExtPasses(t *testing.T) { + assert.True(t, partyRoleKnown(tax.Extensions{})) +} + +// --- defensive coverage: nil / wrong-type guards ---------------------- + +func TestSirenFromFrenchTaxIDNilSIRETEntry(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{nil}} + got := sirenFromFrenchTaxID("FR39356000000", p) + assert.Equal(t, "356000000", got) +} + +func TestPartyCarriesSIRENNilParty(t *testing.T) { + assert.False(t, partyCarriesSIREN(nil)) +} + +func TestPartyCarriesSIRENNilIdentity(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{nil}} + assert.False(t, partyCarriesSIREN(p)) +} + +// TestNormalizeIdentityMapsSIRENTypeToScheme confirms the addon +// normaliser sets iso-scheme-id=0002 on a SIREN-typed identity even +// without eu-en16931 declared. Downstream validators can therefore +// rely on the scheme-id ext being present after normalisation. +func TestNormalizeIdentityMapsSIRENTypeToScheme(t *testing.T) { + id := &org.Identity{Type: "SIREN", Code: "356000000"} + normalizeIdentity(id) + assert.Equal(t, identitySchemeIDSIREN, id.Ext.Get(iso.ExtKeySchemeID).String()) +} + +func TestNormalizeIdentityMapsSIRETTypeToScheme(t *testing.T) { + id := &org.Identity{Type: "SIRET", Code: "35600000000011"} + normalizeIdentity(id) + assert.Equal(t, identitySchemeIDSIRET, id.Ext.Get(iso.ExtKeySchemeID).String()) +} + +func TestNormalizeIdentityPreservesExistingScheme(t *testing.T) { + id := &org.Identity{ + Type: "SIREN", + Code: "356000000", + Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "9999"}), + } + normalizeIdentity(id) + assert.Equal(t, "9999", id.Ext.Get(iso.ExtKeySchemeID).String()) } diff --git a/addons/fr/ctc/scenarios.go b/addons/fr/ctc/scenarios.go new file mode 100644 index 000000000..c533f2e55 --- /dev/null +++ b/addons/fr/ctc/scenarios.go @@ -0,0 +1,161 @@ +package ctc + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +// scenarios is a self-contained UNTDID 1001 document-type mapping for +// French CTC invoices. It does not assume eu-en16931 is also declared +// (en16931 is only mandatory for Flow 2 clearance and is enforced via +// the rule set, not as an addon dependency), so we duplicate the +// document-type rows for the codes that en16931 also covers. When both +// addons are listed the rows merge with the same value — last-write +// is harmless because the value is identical. +var scenarios = []*tax.ScenarioSet{ + { + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // Simple invoices --------------------------------------------------- + { + // 380 — Sales invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "380", + }), + }, + { + // 389 — Self-billed invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagSelfBilled}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "389", + }), + }, + { + // 393 — Factored invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagFactoring}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "393", + }), + }, + { + // 501 — Self-invoiced factored invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagSelfBilled, tax.TagFactoring}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "501", + }), + }, + + // Deposit invoices -------------------------------------------------- + { + // 386 — Deposit invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagPrepayment}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "386", + }), + }, + { + // 500 — Self-invoiced deposit invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagSelfBilled, tax.TagPrepayment}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "500", + }), + }, + + // Corrective invoices ----------------------------------------------- + { + // 384 — Corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "384", + }), + }, + { + // 471 — Self-billed corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Tags: []cbc.Key{tax.TagSelfBilled}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "471", + }), + }, + { + // 472 — Factored corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Tags: []cbc.Key{tax.TagFactoring}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "472", + }), + }, + { + // 473 — Self-billed factored corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Tags: []cbc.Key{tax.TagSelfBilled, tax.TagFactoring}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "473", + }), + }, + + // Credit memos ------------------------------------------------------ + { + // 381 — Credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "381", + }), + }, + { + // 261 — Self-billed credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Tags: []cbc.Key{tax.TagSelfBilled}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "261", + }), + }, + { + // 396 — Factored credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Tags: []cbc.Key{tax.TagFactoring}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "396", + }), + }, + { + // 502 — Self-invoiced and factored credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Tags: []cbc.Key{tax.TagSelfBilled, tax.TagFactoring}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "502", + }), + }, + { + // 503 — Down-payment invoice credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Tags: []cbc.Key{tax.TagPrepayment}, + Ext: tax.ExtensionsOf(cbc.CodeMap{ + untdid.ExtKeyDocumentType: "503", + }), + }, + }, + }, +} + +// allowedInvoiceDocumentTypes is the whitelist of UNTDID 1001 codes +// permitted on a French CTC invoice (covers both Flow 2 and Flow 10). +// Kept as a flat list because the rule that consumes it checks for +// presence/absence rather than the type+tag combination. +var allowedInvoiceDocumentTypes = []cbc.Code{ + "380", "389", "393", "501", + "386", "500", + "384", "471", "472", "473", + "381", "261", "396", "502", "503", + // Flow 2-only: consolidated credit note. Not driven by a scenario; + // the caller sets the extension explicitly. + "262", +} diff --git a/addons/fr/ctc/tags.go b/addons/fr/ctc/tags.go deleted file mode 100644 index 4ca3760e5..000000000 --- a/addons/fr/ctc/tags.go +++ /dev/null @@ -1,47 +0,0 @@ -package ctc - -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/tax" -) - -// French CTC-specific tag keys -const ( - // TagFinal indicates a final invoice after advance payments - TagFinal cbc.Key = "final" - - // TagB2BINT indicates an international B2B invoice requiring e-reporting - TagB2BINT cbc.Key = "b2b-int" - - // TagArchiveOnly indicates a credit note for internal cancellation only - TagArchiveOnly cbc.Key = "archive-only" -) - -var invoiceTags = &tax.TagSet{ - Schema: bill.ShortSchemaInvoice, - List: []*cbc.Definition{ - { - Key: TagFinal, - Name: i18n.String{ - i18n.EN: "Final Invoice", - i18n.FR: "Facture définitive", - }, - }, - { - Key: TagB2BINT, - Name: i18n.String{ - i18n.EN: "International B2B", - i18n.FR: "B2B International", - }, - }, - { - Key: TagArchiveOnly, - Name: i18n.String{ - i18n.EN: "Archive Only", - i18n.FR: "Archivage uniquement", - }, - }, - }, -} diff --git a/data/addons/fr-ctc-flow2-v1.json b/data/addons/fr-ctc-flow2-v1.json deleted file mode 100644 index 2c88c4ba8..000000000 --- a/data/addons/fr-ctc-flow2-v1.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/tax/addon-def", - "key": "fr-ctc-flow2-v1", - "requires": [ - "eu-en16931-v2017" - ], - "name": { - "en": "France CTC Flow 2", - "fr": "France CTC Flux 2" - }, - "description": { - "en": "Support for the French CTC (Continuous Transaction Control) Flow 2 B2B\ne-invoicing requirements from the French electronic invoicing reform.\n\nThis addon provides the necessary structures and validations to ensure compliance\nwith the French CTC specifications for B2B electronic invoicing.\n\nIt requires the EN16931 addon as it extends the European standard with French-specific\nrequirements for the e-invoicing reform.\n\nThis addon is required for regulated invoice. This refers to invoices between two parties\nregistered for VAT in France. This addon should not be used for invoices which should be reported.\n\nNote on currency conversion (BR-FR-CO-12): When an invoice is issued in a non-EUR\ncurrency, the gobl.ubl library will automatically handle the conversion to EUR and\npresent the invoice with both the original currency and EUR equivalents for tax\namounts, ensuring compliance with French accounting requirements.", - "fr": "Support pour le CTC (Contrôle Continu des Transactions) français Flux 2\npour les exigences de facturation électronique B2B de la réforme française.\n\nCet addon fournit les structures et validations nécessaires pour assurer la\nconformité avec les spécifications CTC françaises pour la facturation électronique B2B.\n\nIl nécessite l'addon EN16931 car il étend le standard européen avec des exigences\nspécifiques françaises pour la réforme de la facturation électronique.\n\nCet addon est requis pour les factures réglementées. Cela concerne les factures entre\n \t\t\tdeux parties assujetties à la TVA en France. Cet addon ne doit pas être utilisé pour\n \tles factures qui doivent être déclarées.\n\nNote sur la conversion de devises (BR-FR-CO-12) : Lorsqu'une facture est émise dans\nune devise autre que l'EUR, la bibliothèque gobl.ubl gère automatiquement la conversion\nen EUR et présente la facture avec à la fois la devise d'origine et les équivalents en\nEUR pour les montants de TVA, garantissant la conformité avec les exigences comptables\nfrançaises." - }, - "sources": [ - { - "title": { - "en": "External Specifications", - "fr": "Spécifications Externes" - }, - "url": "https://www.impots.gouv.fr/specifications-externes-b2b" - } - ], - "extensions": [ - { - "key": "fr-ctc-billing-mode", - "name": { - "en": "Billing Mode", - "fr": "Cadre de Facturation" - }, - "desc": { - "en": "Code used to describe the billing framework of the invoice. The billing mode\nindicates the nature of goods/services and the payment context.\n\nCode prefixes indicate the invoice nature:\n- \"B\": Goods invoice (Biens)\n- \"S\": Services invoice\n- \"M\": Mixed/dual invoice (goods and services that are not accessory to each other)\n\nThe numeric suffix indicates the payment type (1=deposit, 2=already paid,\n4=final after down payment, 5=subcontractor, 6=co-contractor, 7=e-reporting).", - "fr": "Code utilisé pour décrire le cadre de facturation de la facture. Le mode de\nfacturation indique la nature des biens/services et le contexte de paiement.\n\nLes préfixes de code indiquent la nature de la facture :\n- \"B\" : Facture de biens\n- \"S\" : Facture de services\n- \"M\" : Facture mixte (biens et services qui ne sont pas accessoires l'un de l'autre)\n\nLe suffixe numérique indique le type de paiement (1=dépôt, 2=déjà payée,\n4=définitive après acompte, 5=sous-traitant, 6=cotraitant, 7=e-reporting)." - }, - "values": [ - { - "code": "B1", - "name": { - "en": "Goods - Deposit invoice", - "fr": "Biens - Facture de dépôt" - } - }, - { - "code": "B2", - "name": { - "en": "Goods - Already paid invoice", - "fr": "Biens - Facture déjà payée" - } - }, - { - "code": "B4", - "name": { - "en": "Goods - Final invoice (after down payment)", - "fr": "Biens - Facture définitive (après acompte)" - } - }, - { - "code": "B7", - "name": { - "en": "Goods - E-reporting (VAT already collected)", - "fr": "Biens - E-reporting (TVA déjà collectée)" - } - }, - { - "code": "S1", - "name": { - "en": "Services - Deposit invoice", - "fr": "Services - Facture de dépôt" - } - }, - { - "code": "S2", - "name": { - "en": "Services - Already paid invoice", - "fr": "Services - Facture déjà payée" - } - }, - { - "code": "S4", - "name": { - "en": "Services - Final invoice (after down payment)", - "fr": "Services - Facture définitive (après acompte)" - } - }, - { - "code": "S5", - "name": { - "en": "Services - Subcontractor invoice", - "fr": "Services - Facture de sous-traitance" - } - }, - { - "code": "S6", - "name": { - "en": "Services - Co-contractor invoice", - "fr": "Services - Facture de cotraitance" - } - }, - { - "code": "S7", - "name": { - "en": "Services - E-reporting (VAT already collected)", - "fr": "Services - E-reporting (TVA déjà collectée)" - } - }, - { - "code": "M1", - "name": { - "en": "Mixed - Deposit invoice", - "fr": "Mixte - Facture de dépôt" - } - }, - { - "code": "M2", - "name": { - "en": "Mixed - Already paid invoice", - "fr": "Mixte - Facture déjà payée" - } - }, - { - "code": "M4", - "name": { - "en": "Mixed - Final invoice (after down payment)", - "fr": "Mixte - Facture définitive (après acompte)" - } - } - ] - } - ], - "tags": [ - { - "schema": "bill/invoice", - "list": [ - { - "key": "final", - "name": { - "en": "Final Invoice", - "fr": "Facture définitive" - } - }, - { - "key": "b2b-int", - "name": { - "en": "International B2B", - "fr": "B2B International" - } - }, - { - "key": "archive-only", - "name": { - "en": "Archive Only", - "fr": "Archivage uniquement" - } - } - ] - } - ], - "scenarios": null, - "corrections": null -} \ No newline at end of file diff --git a/data/addons/fr-ctc-v1.json b/data/addons/fr-ctc-v1.json index 9d75251ea..ca941ec11 100644 --- a/data/addons/fr-ctc-v1.json +++ b/data/addons/fr-ctc-v1.json @@ -1,24 +1,21 @@ { "$schema": "https://gobl.org/draft-0/tax/addon-def", "key": "fr-ctc-v1", - "requires": [ - "eu-en16931-v2017" - ], "name": { - "en": "France CTC Flow 2", - "fr": "France CTC Flux 2" + "en": "France CTC", + "fr": "France CTC" }, "description": { - "en": "Support for the French CTC (Cycle de Traitement de la Commande) Flow 2 B2B\ne-invoicing requirements from the French electronic invoicing reform.\n\nThis addon provides the necessary structures and validations to ensure compliance\nwith the French CTC specifications for B2B electronic invoicing.\n\nIt requires the EN16931 addon as it extends the European standard with French-specific\nrequirements for the e-invoicing reform.\n\nNote on currency conversion (BR-FR-CO-12): When an invoice is issued in a non-EUR\ncurrency, the gobl.ubl library will automatically handle the conversion to EUR and\npresent the invoice with both the original currency and EUR equivalents for tax\namounts, ensuring compliance with French accounting requirements.", - "fr": "Support pour le CTC (Cycle de Traitement de la Commande) français Flux 2\npour les exigences de facturation électronique B2B de la réforme française.\n\nCet addon fournit les structures et validations nécessaires pour assurer la\nconformité avec les spécifications CTC françaises pour la facturation électronique B2B.\n\nIl nécessite l'addon EN16931 car il étend le standard européen avec des exigences\nspécifiques françaises pour la réforme de la facturation électronique.\n\nNote sur la conversion de devises (BR-FR-CO-12) : Lorsqu'une facture est émise dans\nune devise autre que l'EUR, la bibliothèque gobl.ubl gère automatiquement la conversion\nen EUR et présente la facture avec à la fois la devise d'origine et les équivalents en\nEUR pour les montants de TVA, garantissant la conformité avec les exigences comptables\nfrançaises." + "en": "Support for the French CTC (Continuous Transaction Control)\ne-invoicing and e-reporting reform.\n\nThe addon covers three of the flows defined by the French\nspecification:\n\n- Flow 2 (\"facturation\"): domestic B2B clearance, applied\n to invoices issued between two parties identifiable as\n French (SIREN or French VAT ID on both sides).\n- Flow 10 (\"e-reporting\"): reporting of transactions that\n fall outside Flow 2 clearance — B2C sales, cross-border\n B2B, and payment receipts subject to e-reporting.\n- Flow 6 (\"cycle de vie\"): lifecycle status messages\n (bill.Status) exchanged between registered platforms.\n\nThe invoice ruleset is dispatched at validation time based\non whether both parties resolve as French. There is no\ncaller-facing switch: identify the parties correctly and\nthe right flow runs.", + "fr": "Support pour la réforme française CTC (Contrôle Continu\ndes Transactions) de la facturation et du e-reporting.\n\nL'addon couvre trois flux du cahier des charges :\n\n- Flux 2 (« facturation ») : clearance B2B domestique,\n appliqué aux factures émises entre deux parties\n identifiables comme françaises (SIREN ou numéro de TVA\n français des deux côtés).\n- Flux 10 (« e-reporting ») : déclaration des transactions\n hors flux 2 — ventes B2C, B2B transfrontalières et\n encaissements soumis au e-reporting.\n- Flux 6 (« cycle de vie ») : statuts cycle de vie\n (bill.Status) échangés entre plateformes agréées.\n\nLe jeu de règles applicable aux factures est sélectionné\nau moment de la validation selon que les deux parties\nsont françaises ou non. Aucun commutateur explicite n'est\nexposé : il suffit d'identifier correctement les parties." }, "sources": [ { "title": { - "en": "French CTC Specifications", - "fr": "Spécifications CTC françaises" + "en": "External Specifications", + "fr": "Spécifications Externes" }, - "url": "https://www.impots.gouv.fr/e-invoicing-et-e-reporting-702-evolutions" + "url": "https://www.impots.gouv.fr/specifications-externes-b2b" } ], "extensions": [ @@ -125,36 +122,682 @@ } } ] + }, + { + "key": "fr-ctc-b2c-category", + "name": { + "en": "B2C Transaction Category", + "fr": "Catégorie de transaction B2C" + }, + "desc": { + "en": "Classifies a B2C transaction for French e-reporting to the PPF\n(G1.68). Required on Flow 10 B2C invoices.\n\n- TLB1: Goods deliveries subject to VAT.\n- TPS1: Services subject to VAT.\n- TNT1: Goods / services not subject to French VAT, including\n intra-EU distance sales per CGI articles 258 A and 259 B.\n- TMA1: Operations under the VAT-on-margin regime\n (CGI articles 266-1-e, 268, 297 A).", + "fr": "Catégorie de transaction pour le e-reporting au PPF (G1.68).\nObligatoire sur les factures B2C en Flux 10.\n\n- TLB1 : Livraisons de biens soumises à la TVA.\n- TPS1 : Prestations de services soumises à la TVA.\n- TNT1 : Livraisons et prestations non soumises à la TVA en\n France, dont les ventes à distance intracommunautaires\n (CGI art. 258 A et 259 B).\n- TMA1 : Opérations relevant du régime de TVA sur la marge\n (CGI art. 266-1-e, 268, 297 A)." + }, + "values": [ + { + "code": "TLB1", + "name": { + "en": "Goods subject to VAT", + "fr": "Livraisons de biens soumises à la TVA" + } + }, + { + "code": "TPS1", + "name": { + "en": "Services subject to VAT", + "fr": "Prestations de services soumises à la TVA" + } + }, + { + "code": "TNT1", + "name": { + "en": "Not subject to French VAT", + "fr": "Non soumis à la TVA en France" + } + }, + { + "code": "TMA1", + "name": { + "en": "VAT-on-margin regime", + "fr": "Régime de TVA sur la marge" + } + } + ] + }, + { + "key": "fr-ctc-role", + "name": { + "en": "Party Role Code", + "fr": "Code rôle partie" + }, + "desc": { + "en": "UNCL 3035 role code carried as the CDAR RoleCode (MDT-158)\nfor each populated party on a Flow 6 lifecycle message.\nLabels follow the French CTC specification, which assigns\nCDAR-specific meanings to WK (dematerialisation platform /\noperator) and DFH (Portail Public de Facturation).\nThe normalizer fills the obvious defaults (Supplier → SE,\nCustomer → BY) and leaves the rest for the caller to set\nexplicitly." + }, + "values": [ + { + "code": "BY", + "name": { + "en": "Buyer", + "fr": "Acheteur" + } + }, + { + "code": "DL", + "name": { + "en": "Factor", + "fr": "Affactureur" + } + }, + { + "code": "SE", + "name": { + "en": "Seller", + "fr": "Vendeur" + } + }, + { + "code": "AB", + "name": { + "en": "Buyer's agent", + "fr": "Agent d'acheteur" + } + }, + { + "code": "SR", + "name": { + "en": "Seller's agent", + "fr": "Agent de vendeur" + } + }, + { + "code": "WK", + "name": { + "en": "Dematerialisation platform or operator", + "fr": "Plateforme ou opérateur de dématérialisation" + } + }, + { + "code": "DFH", + "name": { + "en": "Portail Public de Facturation (PPF)", + "fr": "Portail Public de Facturation" + } + }, + { + "code": "PE", + "name": { + "en": "Payee", + "fr": "Bénéficiaire" + } + }, + { + "code": "PR", + "name": { + "en": "Payer", + "fr": "Payeur" + } + }, + { + "code": "II", + "name": { + "en": "Invoicer", + "fr": "Émetteur de la facture" + } + }, + { + "code": "IV", + "name": { + "en": "Invoicee", + "fr": "Destinataire de la facture" + } + } + ] + }, + { + "key": "fr-ctc-reason-code", + "name": { + "en": "CDAR Reason Code", + "fr": "Code motif CDAR" + }, + "desc": { + "en": "Exact CDAR ReasonCode pinned on a bill.Reason for Flow 6\nlifecycle messages. The CDAR ReasonCode dimension is 1:N\nwith bill.Reason.Key: this extension lets the caller pick\nthe precise code within a bucket. When absent, the\nconverter falls back to the default_for_key code for\nReason.Key." + }, + "values": [ + { + "code": "NON_TRANSMISE", + "name": { + "en": "unknown-receiver" + } + }, + { + "code": "JUSTIF_ABS", + "name": { + "en": "references" + } + }, + { + "code": "ROUTAGE_ERR", + "name": { + "en": "unknown-receiver" + } + }, + { + "code": "AUTRE", + "name": { + "en": "other" + } + }, + { + "code": "COORD_BANC_ERR", + "name": { + "en": "finance-terms" + } + }, + { + "code": "TX_TVA_ERR", + "name": { + "en": "legal" + } + }, + { + "code": "MONTANTTOTAL_ERR", + "name": { + "en": "prices" + } + }, + { + "code": "CALCUL_ERR", + "name": { + "en": "prices" + } + }, + { + "code": "NON_CONFORME", + "name": { + "en": "legal" + } + }, + { + "code": "DOUBLON", + "name": { + "en": "not-recognized" + } + }, + { + "code": "DEST_INC", + "name": { + "en": "unknown-receiver" + } + }, + { + "code": "DEST_ERR", + "name": { + "en": "references" + } + }, + { + "code": "TRANSAC_INC", + "name": { + "en": "not-recognized" + } + }, + { + "code": "EMMET_INC", + "name": { + "en": "not-recognized" + } + }, + { + "code": "CONTRAT_TERM", + "name": { + "en": "not-recognized" + } + }, + { + "code": "DOUBLE_FACT", + "name": { + "en": "not-recognized" + } + }, + { + "code": "CMD_ERR", + "name": { + "en": "references" + } + }, + { + "code": "ADR_ERR", + "name": { + "en": "references" + } + }, + { + "code": "SIRET_ERR", + "name": { + "en": "references" + } + }, + { + "code": "CODE_ROUTAGE_ERR", + "name": { + "en": "references" + } + }, + { + "code": "REF_CT_ABSENT", + "name": { + "en": "references" + } + }, + { + "code": "REF_ERR", + "name": { + "en": "references" + } + }, + { + "code": "PU_ERR", + "name": { + "en": "prices" + } + }, + { + "code": "REM_ERR", + "name": { + "en": "prices" + } + }, + { + "code": "QTE_ERR", + "name": { + "en": "quantity" + } + }, + { + "code": "ART_ERR", + "name": { + "en": "items" + } + }, + { + "code": "MODPAI_ERR", + "name": { + "en": "payment-terms" + } + }, + { + "code": "QUALITE_ERR", + "name": { + "en": "quality" + } + }, + { + "code": "LIVR_INCOMP", + "name": { + "en": "delivery" + } + }, + { + "code": "REJ_SEMAN", + "name": { + "en": "legal" + } + }, + { + "code": "REJ_UNI", + "name": { + "en": "not-recognized" + } + }, + { + "code": "REJ_COH", + "name": { + "en": "legal" + } + }, + { + "code": "REJ_ADR", + "name": { + "en": "references" + } + }, + { + "code": "REJ_CONT_B2G", + "name": { + "en": "legal" + } + }, + { + "code": "REJ_REF_PJ", + "name": { + "en": "references" + } + }, + { + "code": "REJ_ASS_PJ", + "name": { + "en": "references" + } + }, + { + "code": "IRR_VIDE_F", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_TYPE_F", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_SYNTAX", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_TAILLE_PJ", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_NOM_PJ", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_VID_PJ", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_EXT_DOC", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_TAILLE_F", + "name": { + "en": "legal" + } + }, + { + "code": "IRR_ANTIVIRUS", + "name": { + "en": "legal" + } + } + ] + }, + { + "key": "fr-ctc-status-code", + "name": { + "en": "CDAR Process Condition Code", + "fr": "Code condition processus CDAR" + }, + "desc": { + "en": "CDAR ProcessConditionCode (MDT-9) identifying the lifecycle\nevent reported by the Flow 6 message. The normalizer derives\nit from the (StatusLine.Key, Status.Type) pair; callers can\npre-set it to pin a specific code (e.g. when round-tripping\na parsed CDV)." + }, + "values": [ + { + "code": "200", + "name": { + "en": "Déposée" + } + }, + { + "code": "201", + "name": { + "en": "Émise par la plateforme" + } + }, + { + "code": "202", + "name": { + "en": "Reçue par PA" + } + }, + { + "code": "203", + "name": { + "en": "Mise à disposition" + } + }, + { + "code": "204", + "name": { + "en": "Prise en charge" + } + }, + { + "code": "205", + "name": { + "en": "Approuvée" + } + }, + { + "code": "206", + "name": { + "en": "Approuvée partiellement" + } + }, + { + "code": "207", + "name": { + "en": "En litige" + } + }, + { + "code": "208", + "name": { + "en": "Suspendue" + } + }, + { + "code": "209", + "name": { + "en": "Complétée" + } + }, + { + "code": "210", + "name": { + "en": "Refusée" + } + }, + { + "code": "211", + "name": { + "en": "Paiement transmis" + } + }, + { + "code": "212", + "name": { + "en": "Encaissée" + } + }, + { + "code": "213", + "name": { + "en": "Rejetée sémantique" + } + } + ] } ], - "tags": [ + "scenarios": [ { "schema": "bill/invoice", "list": [ { - "key": "final", - "name": { - "en": "Final Invoice", - "fr": "Facture définitive" + "type": [ + "standard" + ], + "ext": { + "untdid-document-type": "380" } }, { - "key": "b2b-int", - "name": { - "en": "International B2B", - "fr": "B2B International" + "type": [ + "standard" + ], + "tags": [ + "self-billed" + ], + "ext": { + "untdid-document-type": "389" } }, { - "key": "archive-only", - "name": { - "en": "Archive Only", - "fr": "Archivage uniquement" + "type": [ + "standard" + ], + "tags": [ + "factoring" + ], + "ext": { + "untdid-document-type": "393" + } + }, + { + "type": [ + "standard" + ], + "tags": [ + "self-billed", + "factoring" + ], + "ext": { + "untdid-document-type": "501" + } + }, + { + "type": [ + "standard" + ], + "tags": [ + "prepayment" + ], + "ext": { + "untdid-document-type": "386" + } + }, + { + "type": [ + "standard" + ], + "tags": [ + "self-billed", + "prepayment" + ], + "ext": { + "untdid-document-type": "500" + } + }, + { + "type": [ + "corrective" + ], + "ext": { + "untdid-document-type": "384" + } + }, + { + "type": [ + "corrective" + ], + "tags": [ + "self-billed" + ], + "ext": { + "untdid-document-type": "471" + } + }, + { + "type": [ + "corrective" + ], + "tags": [ + "factoring" + ], + "ext": { + "untdid-document-type": "472" + } + }, + { + "type": [ + "corrective" + ], + "tags": [ + "self-billed", + "factoring" + ], + "ext": { + "untdid-document-type": "473" + } + }, + { + "type": [ + "credit-note" + ], + "ext": { + "untdid-document-type": "381" + } + }, + { + "type": [ + "credit-note" + ], + "tags": [ + "self-billed" + ], + "ext": { + "untdid-document-type": "261" + } + }, + { + "type": [ + "credit-note" + ], + "tags": [ + "factoring" + ], + "ext": { + "untdid-document-type": "396" + } + }, + { + "type": [ + "credit-note" + ], + "tags": [ + "self-billed", + "factoring" + ], + "ext": { + "untdid-document-type": "502" + } + }, + { + "type": [ + "credit-note" + ], + "tags": [ + "prepayment" + ], + "ext": { + "untdid-document-type": "503" } } ] } ], - "scenarios": null, "corrections": null } \ No newline at end of file diff --git a/data/rules/fr-ctc-flow2.json b/data/rules/fr-ctc-flow2.json deleted file mode 100644 index 893d744d5..000000000 --- a/data/rules/fr-ctc-flow2.json +++ /dev/null @@ -1,584 +0,0 @@ -{ - "id": "GOBL-FR-CTC-FLOW2", - "package": "fr-ctc-flow2", - "guard": "context: addon in [fr-ctc-flow2-v1]", - "subsets": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE", - "object": "bill.Invoice", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-42", - "desc": "invoice must be in EUR or provide exchange rate for conversion", - "tests": "can convert to [EUR]" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-01", - "desc": "must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", - "tests": "valid invoice code" - } - ], - "subsets": [ - { - "field": "preceding", - "subsets": [ - { - "each": true, - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-02", - "desc": "preceding code must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", - "tests": "valid preceding code" - } - ] - } - ] - }, - { - "guard": "corrective invoice", - "subsets": [ - { - "field": "preceding", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-03", - "desc": "corrective invoices must have exactly one preceding invoice reference (BR-FR-CO-04)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-04", - "desc": "corrective invoices must have exactly one preceding invoice reference (BR-FR-CO-04)", - "tests": "length between 1 and 1" - } - ] - } - ] - }, - { - "guard": "credit note", - "subsets": [ - { - "field": "preceding", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-05", - "desc": "credit notes must have at least one preceding invoice reference (BR-FR-CO-05)", - "tests": "present" - } - ] - } - ] - }, - { - "field": "tax", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-06", - "desc": "tax is required", - "tests": "present" - } - ], - "subsets": [ - { - "field": "ext", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-07", - "desc": "UNTDID document type must be valid (BR-FR-04)", - "tests": "ext 'untdid-document-type' in [380, 389, 393, 501, 386, 500, 384, 471, 472, 473, 261, 262, 381, 396, 502, 503]" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-08", - "desc": "billing mode extension is required", - "tests": "ext require [fr-ctc-billing-mode]" - } - ] - } - ] - }, - { - "guard": "factoring mode", - "subsets": [ - { - "field": "tax", - "subsets": [ - { - "field": "ext", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-09", - "desc": "advance payment document types not allowed for factoring billing modes (BR-FR-CO-08)", - "tests": "ext 'untdid-document-type' not in [386, 500, 503]" - } - ] - } - ] - } - ] - }, - { - "field": "supplier", - "subsets": [ - { - "field": "inboxes", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-10", - "desc": "seller electronic address required for French B2B invoices (BR-FR-13)", - "tests": "present" - } - ] - }, - { - "field": "identities", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-11", - "desc": "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", - "tests": "has SIREN" - } - ] - } - ] - }, - { - "guard": "B2B non-self-billed", - "subsets": [ - { - "field": "supplier", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-12", - "desc": "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", - "tests": "has SIREN inbox" - } - ] - } - ] - }, - { - "field": "customer", - "subsets": [ - { - "field": "inboxes", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-13", - "desc": "buyer electronic address required for French B2B invoices (BR-FR-13)", - "tests": "present" - } - ] - } - ] - }, - { - "guard": "B2B transaction", - "subsets": [ - { - "field": "customer", - "subsets": [ - { - "field": "identities", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-14", - "desc": "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", - "tests": "has SIREN" - } - ] - } - ] - } - ] - }, - { - "guard": "B2B self-billed", - "subsets": [ - { - "field": "customer", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-15", - "desc": "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", - "tests": "has SIREN inbox" - } - ] - } - ] - }, - { - "field": "ordering", - "subsets": [ - { - "field": "identities", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-16", - "desc": "only one ordering identity with UNTDID reference 'AFL' is allowed (BR-FR-30)", - "tests": "no dup AFL" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-17", - "desc": "only one ordering identity with UNTDID reference 'AWW' is allowed (BR-FR-30)", - "tests": "no dup AWW" - } - ] - } - ] - }, - { - "guard": "supplier STC", - "subsets": [ - { - "field": "ordering", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-18", - "desc": "ordering with seller is required when supplier is under STC scheme (BR-FR-CO-15)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "seller", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-19", - "desc": "seller is required when supplier is under STC scheme (BR-FR-CO-15)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "tax_id", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-20", - "desc": "tax ID is required when supplier is under STC scheme (BR-FR-CO-15)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "code", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-21", - "desc": "code is required when supplier is under STC scheme (BR-FR-CO-15)", - "tests": "present" - } - ] - } - ] - } - ] - } - ] - }, - { - "field": "notes", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-22", - "desc": "for sellers with STC scheme (0231), a note with code 'TXD' and text 'MEMBRE_ASSUJETTI_UNIQUE' is required (BR-FR-CO-14)", - "tests": "has TXD note" - } - ] - } - ] - }, - { - "guard": "consolidated credit note", - "subsets": [ - { - "field": "ordering", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-23", - "desc": "ordering with contracts is required for consolidated credit notes (BR-FR-CO-03)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "contracts", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-24", - "desc": "at least one contract reference is required in ordering details for consolidated credit notes (BR-FR-CO-03)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-25", - "desc": "at least one contract reference is required in ordering details for consolidated credit notes (BR-FR-CO-03)", - "tests": "length between 1 and 0" - } - ] - } - ] - }, - { - "field": "delivery", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-26", - "desc": "delivery details are required for consolidated credit notes (BR-FR-CO-03)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "period", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-27", - "desc": "delivery period is required for consolidated credit notes (BR-FR-CO-03)", - "tests": "present" - } - ] - } - ] - } - ] - }, - { - "guard": "not advance or final", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-28", - "desc": "due dates must not be before invoice issue date (BR-FR-CO-07)", - "tests": "due dates valid" - } - ] - }, - { - "guard": "final invoice", - "subsets": [ - { - "field": "payment", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-29", - "desc": "payment details are required for final invoices (BR-FR-CO-09)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "terms", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-30", - "desc": "payment terms required for final invoices (BR-FR-CO-09)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "due_dates", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-31", - "desc": "at least one due date required for final invoices (BR-FR-CO-09)", - "tests": "present" - } - ] - } - ] - } - ] - }, - { - "field": "totals", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-33", - "desc": "advance amount must equal total with tax for final invoices (BR-FR-CO-09)", - "tests": "advances match" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-34", - "desc": "payable amount must be zero for final invoices (BR-FR-CO-09)", - "tests": "payable zero" - } - ], - "subsets": [ - { - "field": "advance", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-32", - "desc": "advance amount is required for already-paid invoices (BR-FR-CO-09)", - "tests": "present" - } - ] - } - ] - } - ] - }, - { - "field": "notes", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-35", - "desc": "notes are required for French CTC invoices (BR-FR-05)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-36", - "desc": "missing required note codes (BR-FR-05)", - "tests": "has required notes" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-37", - "desc": "duplicate note codes found (BR-FR-06/BR-FR-30)", - "tests": "no duplicate notes" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-38", - "desc": "BAR note text must be one of: B2B, B2BINT, B2C, OUTOFSCOPE, ARCHIVEONLY", - "tests": "valid BAR text" - } - ] - }, - { - "field": "attachments", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-41", - "desc": "only one attachment with description 'LISIBLE' is allowed per invoice (BR-FR-18)", - "tests": "unique LISIBLE" - } - ], - "subsets": [ - { - "each": true, - "subsets": [ - { - "field": "description", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-39", - "desc": "must be one of the allowed attachment descriptions (BR-FR-17)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-40", - "desc": "must be one of the allowed attachment descriptions (BR-FR-17)", - "tests": "one of [RIB, LISIBLE, FEUILLE_DE_STYLE, PJA, BON_LIVRAISON, BON_COMMANDE, DOCUMENT_ANNEXE, BORDEREAU_SUIVI, BORDEREAU_SUIVI_VALIDATION, ETAT_ACOMPTE, FACTURE_PAIEMENT_DIRECT, RECAPITULATIF_COTRAITANCE]" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "GOBL-FR-CTC-FLOW2-ORG-PARTY", - "object": "org.Party", - "subsets": [ - { - "field": "identities", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-ORG-PARTY-01", - "desc": "SIRET and SIREN must be coherent (BR-FR-09/10)", - "tests": "SIRET/SIREN coherent" - }, - { - "id": "GOBL-FR-CTC-FLOW2-ORG-PARTY-02", - "desc": "identity scheme format invalid (BR-FR-CO-10)", - "tests": "valid scheme format" - } - ] - }, - { - "field": "inboxes", - "subsets": [ - { - "each": true, - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-ORG-PARTY-03", - "desc": "inbox code format invalid", - "tests": "valid inbox" - } - ] - } - ] - } - ] - }, - { - "id": "GOBL-FR-CTC-FLOW2-ORG-IDENTITY", - "object": "org.Identity", - "subsets": [ - { - "guard": "scheme 0224", - "subsets": [ - { - "field": "code", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-ORG-IDENTITY-01", - "desc": "must be no more than 100 characters long", - "tests": "length between 0 and 100" - }, - { - "id": "GOBL-FR-CTC-FLOW2-ORG-IDENTITY-02", - "desc": "must be in a valid format", - "tests": "matches ^[A-Za-z0-9\\-\\+_/]+$" - } - ] - } - ] - } - ] - }, - { - "id": "GOBL-FR-CTC-FLOW2-ORG-INBOX", - "object": "org.Inbox", - "subsets": [ - { - "guard": "scheme 0225", - "subsets": [ - { - "field": "code", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-ORG-INBOX-01", - "desc": "the length must be between 0 and 125", - "tests": "length between 0 and 125" - }, - { - "id": "GOBL-FR-CTC-FLOW2-ORG-INBOX-02", - "desc": "must be in a valid format", - "tests": "matches ^[A-Za-z0-9\\-\\+_/]+$" - } - ] - } - ] - } - ] - }, - { - "id": "GOBL-FR-CTC-FLOW2-ORG-ITEM", - "object": "org.Item", - "subsets": [ - { - "field": "meta", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW2-ORG-ITEM-01", - "desc": "meta values cannot be blank (BR-FR-28)", - "tests": "no blank meta" - } - ] - } - ] - } - ] -} diff --git a/data/rules/fr-ctc.json b/data/rules/fr-ctc.json new file mode 100644 index 000000000..0c40fef35 --- /dev/null +++ b/data/rules/fr-ctc.json @@ -0,0 +1,1101 @@ +{ + "id": "GOBL-FR-CTC", + "package": "fr-ctc", + "guard": "context: addon in [fr-ctc-v1]", + "subsets": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE", + "object": "bill.Invoice", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-01", + "desc": "invoice must be in EUR or provide an exchange rate to EUR", + "tests": "can convert to [EUR]" + } + ], + "subsets": [ + { + "guard": "domestic French B2B (Flow 2 clearance)", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-02", + "desc": "domestic French B2B invoices must also declare the eu-en16931-v2017 addon", + "tests": "has addon eu-en16931-v2017" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-03", + "desc": "must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", + "tests": "valid invoice code" + } + ], + "subsets": [ + { + "field": "preceding", + "subsets": [ + { + "each": true, + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-04", + "desc": "preceding code must be 1-35 characters, alphanumeric plus -+_/ (BR-FR-01/02), including the series", + "tests": "valid preceding code" + } + ] + } + ] + }, + { + "guard": "corrective invoice", + "subsets": [ + { + "field": "preceding", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-05", + "desc": "corrective invoices must reference the original invoice in preceding (BR-FR-CO-04)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-06", + "desc": "corrective invoices must reference exactly one preceding invoice — multiple references are not allowed (BR-FR-CO-04)", + "tests": "length between 1 and 1" + } + ] + } + ] + }, + { + "guard": "credit note", + "subsets": [ + { + "field": "preceding", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-07", + "desc": "credit notes must have at least one preceding invoice reference (BR-FR-CO-05)", + "tests": "present" + } + ] + } + ] + }, + { + "field": "tax", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-08", + "desc": "tax is required", + "tests": "present" + } + ], + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-09", + "desc": "UNTDID document type must be valid (BR-FR-04)", + "tests": "ext 'untdid-document-type' in [380, 389, 393, 501, 386, 500, 384, 471, 472, 473, 381, 261, 396, 502, 503, 262]" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-10", + "desc": "billing mode extension is required", + "tests": "ext require [fr-ctc-billing-mode]" + } + ] + } + ] + }, + { + "guard": "factoring mode", + "subsets": [ + { + "field": "tax", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-11", + "desc": "advance payment document types (386, 500, 503) are not allowed for factoring billing modes (B4, S4, M4) (BR-FR-CO-08)", + "tests": "ext 'untdid-document-type' not in [386, 500, 503]" + } + ] + } + ] + } + ] + }, + { + "field": "supplier", + "subsets": [ + { + "field": "inboxes", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-12", + "desc": "seller electronic address required for French B2B invoices (BR-FR-13)", + "tests": "present" + } + ] + }, + { + "field": "identities", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-13", + "desc": "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", + "tests": "has SIREN (legal scope)" + } + ] + } + ] + }, + { + "guard": "not self-billed", + "subsets": [ + { + "field": "supplier", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-14", + "desc": "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", + "tests": "has SIREN inbox" + } + ] + } + ] + }, + { + "field": "customer", + "subsets": [ + { + "field": "inboxes", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-15", + "desc": "buyer electronic address required for French B2B invoices (BR-FR-13)", + "tests": "present" + } + ] + }, + { + "field": "identities", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-16", + "desc": "SIREN identity required for French parties with scheme 0002 and scope legal (BR-FR-10/11)", + "tests": "has SIREN (legal scope)" + } + ] + } + ] + }, + { + "guard": "self-billed", + "subsets": [ + { + "field": "customer", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-17", + "desc": "party must have endpoint ID with scheme 0225 (SIREN) (BR-FR-21/22)", + "tests": "has SIREN inbox" + } + ] + } + ] + }, + { + "field": "ordering", + "subsets": [ + { + "field": "identities", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-18", + "desc": "only one ordering identity with UNTDID reference 'AFL' is allowed (BR-FR-30)", + "tests": "no dup AFL" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-19", + "desc": "only one ordering identity with UNTDID reference 'AWW' is allowed (BR-FR-30)", + "tests": "no dup AWW" + } + ] + } + ] + }, + { + "guard": "supplier STC", + "subsets": [ + { + "field": "ordering", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-20", + "desc": "ordering with seller is required when supplier is under STC scheme (BR-FR-CO-15)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "seller", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-21", + "desc": "seller is required when supplier is under STC scheme (BR-FR-CO-15)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "tax_id", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-22", + "desc": "tax ID is required when supplier is under STC scheme (BR-FR-CO-15)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-23", + "desc": "code is required when supplier is under STC scheme (BR-FR-CO-15)", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + }, + { + "field": "notes", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-24", + "desc": "for sellers with STC scheme (0231), a note with code 'TXD' and text 'MEMBRE_ASSUJETTI_UNIQUE' is required (BR-FR-CO-14)", + "tests": "has TXD note" + } + ] + } + ] + }, + { + "guard": "consolidated credit note", + "subsets": [ + { + "field": "ordering", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-25", + "desc": "ordering with contracts is required for consolidated credit notes (BR-FR-CO-03)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "contracts", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-26", + "desc": "ordering.contracts is required for consolidated credit notes (BR-FR-CO-03)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-27", + "desc": "ordering.contracts must contain at least one entry for consolidated credit notes (BR-FR-CO-03)", + "tests": "length between 1 and 0" + } + ] + } + ] + }, + { + "field": "delivery", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-28", + "desc": "delivery details are required for consolidated credit notes (BR-FR-CO-03)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "period", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-29", + "desc": "delivery period is required for consolidated credit notes (BR-FR-CO-03)", + "tests": "present" + } + ] + } + ] + } + ] + }, + { + "guard": "not advance or final", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-30", + "desc": "due dates must not be before invoice issue date (BR-FR-CO-07)", + "tests": "due dates valid" + } + ] + }, + { + "guard": "final invoice", + "subsets": [ + { + "field": "payment", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-31", + "desc": "payment details are required for final invoices (BR-FR-CO-09)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "terms", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-32", + "desc": "payment terms required for final invoices (BR-FR-CO-09)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "due_dates", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-33", + "desc": "at least one due date required for final invoices (BR-FR-CO-09)", + "tests": "present" + } + ] + } + ] + } + ] + }, + { + "field": "totals", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-35", + "desc": "advance amount must equal total with tax for final invoices (BR-FR-CO-09)", + "tests": "advances match" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-36", + "desc": "payable amount must be zero for final invoices (BR-FR-CO-09)", + "tests": "payable zero" + } + ], + "subsets": [ + { + "field": "advance", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-34", + "desc": "advance amount is required for already-paid invoices (BR-FR-CO-09)", + "tests": "present" + } + ] + } + ] + } + ] + }, + { + "field": "notes", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-37", + "desc": "notes are required for French CTC invoices (BR-FR-05)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-38", + "desc": "missing required note codes (BR-FR-05)", + "tests": "has required notes" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-39", + "desc": "duplicate note codes found (BR-FR-06/BR-FR-30)", + "tests": "no duplicate notes" + } + ] + }, + { + "field": "attachments", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-42", + "desc": "only one attachment with description 'LISIBLE' is allowed per invoice (BR-FR-18)", + "tests": "unique LISIBLE" + } + ], + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "description", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-40", + "desc": "must be one of the allowed attachment descriptions (BR-FR-17)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-41", + "desc": "must be one of the allowed attachment descriptions (BR-FR-17)", + "tests": "one of [RIB, LISIBLE, FEUILLE_DE_STYLE, PJA, BON_LIVRAISON, BON_COMMANDE, DOCUMENT_ANNEXE, BORDEREAU_SUIVI, BORDEREAU_SUIVI_VALIDATION, ETAT_ACOMPTE, FACTURE_PAIEMENT_DIRECT, RECAPITULATIF_COTRAITANCE]" + } + ] + } + ] + } + ] + } + ] + }, + { + "guard": "Flow 10 reporting (cross-border or B2C)", + "subsets": [ + { + "guard": "B2C invoice", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-46", + "desc": "every VAT line percent must be one of the Flow 10 permitted values (G1.24): 0, 0.9, 1.05, 1.75, 2.1, 5.5, 7, 8.5, 9.2, 9.6, 10, 13, 19.6, 20, 20.6", + "tests": "allowed Flow 10 VAT percents" + } + ], + "subsets": [ + { + "field": "tax", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-43", + "desc": "B2C transaction category extension (fr-ctc-b2c-category) is required on B2C invoices (G1.68)", + "tests": "has B2C category" + } + ] + } + ] + }, + { + "field": "supplier", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-44", + "desc": "supplier is required on B2C invoice", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-45", + "desc": "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002) on a B2C invoice", + "tests": "party has SIREN" + } + ] + } + ] + }, + { + "field": "supplier", + "subsets": [ + { + "field": "addresses", + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "country", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-47", + "desc": "supplier address must include country", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + }, + { + "field": "customer", + "subsets": [ + { + "field": "addresses", + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "country", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-48", + "desc": "customer address must include country", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + }, + { + "guard": "cross-border B2B invoice", + "subsets": [ + { + "field": "tax", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-49", + "desc": "invoice document type must be one of the Flow 10 permitted UNTDID 1001 codes", + "tests": "allowed Flow 10 document type" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-50", + "desc": "billing mode extension (fr-ctc-billing-mode) is required (G1.02)", + "tests": "has billing mode" + } + ] + } + ] + }, + { + "guard": "billing mode is final-after-advance (B4/S4/M4)", + "subsets": [ + { + "field": "tax", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-51", + "desc": "final-after-advance billing mode (B4/S4/M4) cannot be combined with an advance-payment document type (386/500/503) (G1.60)", + "tests": "not advance-payment doc type" + } + ] + } + ] + } + ] + }, + { + "field": "supplier", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-52", + "desc": "supplier is required for Flow 10 B2B invoice (G2.19)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-53", + "desc": "supplier must declare a legal identity with an allowed ICD 6523 scheme (G2.19): 0002, 0223, 0227, 0228 or 0229", + "tests": "party has allowed legal scheme" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-54", + "desc": "supplier TaxID is required when legal identity scheme is SIREN (0002) or EU VAT (0223) (G2.33)", + "tests": "party has TaxID when required" + } + ] + }, + { + "guard": "invoice has exempt (E) VAT category", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-55", + "desc": "supplier VAT ID or ordering.seller (tax representative) VAT ID is required when the invoice VAT breakdown contains an exempt (E) category", + "tests": "supplier or tax rep has VAT ID" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-56", + "desc": "invoice with an exempt (E) VAT category must include an exemption reason in tax.notes (key=exempt, non-empty text)", + "tests": "has exempt tax note" + } + ] + }, + { + "field": "customer", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-INVOICE-57", + "desc": "customer is required for Flow 10 B2B invoice (G2.19)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-58", + "desc": "customer must declare a legal identity with an allowed ICD 6523 scheme (G2.19): 0002, 0223, 0227, 0228 or 0229", + "tests": "party has allowed legal scheme" + }, + { + "id": "GOBL-FR-CTC-BILL-INVOICE-59", + "desc": "customer TaxID is required when legal identity scheme is SIREN (0002) or EU VAT (0223) (G2.33)", + "tests": "party has TaxID when required" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-BILL-PAYMENT", + "object": "bill.Payment", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-03", + "desc": "every VAT line percent must be one of the Flow 10 permitted values (G1.24): 0, 0.9, 1.05, 1.75, 2.1, 5.5, 7, 8.5, 9.2, 9.6, 10, 13, 19.6, 20, 20.6", + "tests": "allowed Flow 10 VAT percents" + } + ], + "subsets": [ + { + "field": "type", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-01", + "desc": "payment type must be 'receipt' for Flow 10 reporting", + "tests": "one of [receipt]" + } + ] + }, + { + "field": "value_date", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-02", + "desc": "payment value_date (settlement date) is required", + "tests": "present" + } + ] + }, + { + "field": "supplier", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-04", + "desc": "supplier is required", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-05", + "desc": "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002)", + "tests": "party has SIREN" + } + ] + }, + { + "guard": "payment has customer", + "subsets": [ + { + "field": "lines", + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "document", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-06", + "desc": "each payment line must reference a document (invoice) when a customer is present", + "tests": "present" + } + ], + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-07", + "desc": "payment line document code (invoice ID) is required when a customer is present", + "tests": "present" + } + ] + }, + { + "field": "issue_date", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-PAYMENT-08", + "desc": "payment line document issue_date (invoice issue date) is required when a customer is present", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS", + "object": "bill.Status", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-08", + "desc": "Status.Type must be a valid pair with each StatusLine.Key in the Flow 6 process table", + "tests": "status type consistent with line keys" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-19", + "desc": "each Reason's fr-ctc-reason-code must be allowed for the line's CDAR ProcessConditionCode (BR-FR-CDV-CL-09)", + "tests": "reason codes allowed for status" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-07", + "desc": "status line with key 'paid' on a response status (CDAR 212) must carry a Characteristic complement with Amount (value + currency) set — this is the MEN (BR-FR-CDV-14)", + "tests": "amount received set when paid response" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-21", + "desc": "ext.fr-ctc-status-code must match the CDAR ProcessConditionCode implied by (line.Key, Status.Type)", + "tests": "status code matches key/type" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-22", + "desc": "every CDV party identity scheme must be in the Flow 6 allow-list (STC 0231 is rejected — it is a Flow 2 invoice concept)", + "tests": "CDV identity schemes allowed" + } + ], + "subsets": [ + { + "field": "type", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-01", + "desc": "status type must be one of: response, update", + "tests": "one of [response, update]" + } + ] + }, + { + "field": "supplier", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-02", + "desc": "supplier is required (BR-FR-CDV-13)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-03", + "desc": "supplier must have an identity with ISO/IEC 6523 scheme 0002 (SIREN)", + "tests": "supplier has SIREN" + } + ] + }, + { + "field": "issuer", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-14", + "desc": "issuer is required (BR-FR-CDV-CL-03)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-15", + "desc": "issuer.ext.fr-ctc-role must be set. Allowed values are BY/DL/SE/AB/SR/PE/PR/II/IV/WK/DFH (BR-FR-CDV-CL-03)", + "tests": "issuer has fr-ctc-role" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-20", + "desc": "issuer must have an electronic address (inbox) when its role is not WK or DFH (BR-FR-CDV-08)", + "tests": "issuer has inbox unless WK/DFH" + } + ] + }, + { + "field": "recipient", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-16", + "desc": "recipient is required (BR-FR-CDV-CL-04)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-17", + "desc": "recipient.ext.fr-ctc-role must be set. Allowed values are BY/DL/SE/AB/SR/PE/PR/II/IV/WK/DFH (BR-FR-CDV-CL-04)", + "tests": "recipient has fr-ctc-role" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-18", + "desc": "recipient must have an electronic address (inbox) when its role is not WK or DFH (BR-FR-CDV-08)", + "tests": "recipient has inbox unless WK/DFH" + } + ] + }, + { + "field": "lines", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-04", + "desc": "exactly one status line is required", + "tests": "exactly one line" + } + ], + "subsets": [ + { + "each": true, + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-06", + "desc": "status line key must be a recognised Flow 6 event", + "tests": "known Flow 6 status event" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-13", + "desc": "status lines with key rejected / error / disputed / partially-accepted / suspended require at least one reason (BR-FR-CDV-15)", + "tests": "reason required for rejection-like statuses" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-09", + "desc": "Characteristic.ReasonCode must match the fr-ctc-reason-code of some sibling Reason on the same status line", + "tests": "characteristic reason link resolves" + }, + { + "id": "GOBL-FR-CTC-BILL-STATUS-10", + "desc": "Characteristic.TypeCode must be one of the MDT-207 values", + "tests": "characteristic type code known" + } + ], + "subsets": [ + { + "field": "doc", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-05", + "desc": "status line must reference a document (BR-FR-CDV-10)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-11", + "desc": "referenced invoice code is required (BR-FR-CDV-10)", + "tests": "present" + } + ] + }, + { + "field": "issue_date", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-STATUS-12", + "desc": "referenced invoice issue date is required (BR-FR-CDV-11)", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-BILL-REASON", + "object": "bill.Reason", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-REASON-02", + "desc": "fr-ctc-reason-code must be a known CDAR code and its bucket must match reason.key", + "tests": "reason ext code consistent with key" + } + ], + "subsets": [ + { + "field": "key", + "subsets": [ + { + "guard": "present", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-REASON-01", + "desc": "reason key is not a recognised bill.ReasonKeys value", + "tests": "one of [none, references, legal, unknown-receiver, quality, delivery, prices, quantity, items, payment-terms, not-recognized, finance-terms, partial, other]" + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-BILL-ACTION", + "object": "bill.Action", + "subsets": [ + { + "field": "key", + "subsets": [ + { + "guard": "present", + "assert": [ + { + "id": "GOBL-FR-CTC-BILL-ACTION-01", + "desc": "action key is not a recognised bill.ActionKeys value", + "tests": "one of [none, provide, reissue, credit-full, credit-partial, credit-amount, other]" + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-ORG-PARTY", + "object": "org.Party", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-ORG-PARTY-01", + "desc": "fr-ctc-role must be one of the UNCL 3035 subset: SE, BY, WK, DFH, AB, SR, DL, PE, PR, II, IV", + "tests": "known fr-ctc-role" + } + ] + }, + { + "field": "identities", + "assert": [ + { + "id": "GOBL-FR-CTC-ORG-PARTY-02", + "desc": "SIRET and SIREN must be coherent (BR-FR-09/10)", + "tests": "SIRET/SIREN coherent" + }, + { + "id": "GOBL-FR-CTC-ORG-PARTY-03", + "desc": "identity scheme format invalid (BR-FR-CO-10)", + "tests": "valid scheme format" + } + ] + }, + { + "field": "inboxes", + "subsets": [ + { + "each": true, + "assert": [ + { + "id": "GOBL-FR-CTC-ORG-PARTY-05", + "desc": "inbox code format invalid", + "tests": "valid inbox" + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-ORG-IDENTITY", + "object": "org.Identity", + "subsets": [ + { + "guard": "scheme 0224", + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-FR-CTC-ORG-IDENTITY-01", + "desc": "must be no more than 100 characters long", + "tests": "length between 0 and 100" + }, + { + "id": "GOBL-FR-CTC-ORG-IDENTITY-02", + "desc": "must be in a valid format", + "tests": "matches ^[A-Za-z0-9\\-\\+_/]+$" + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-ORG-INBOX", + "object": "org.Inbox", + "subsets": [ + { + "guard": "scheme 0225", + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-FR-CTC-ORG-INBOX-01", + "desc": "the length must be between 0 and 125", + "tests": "length between 0 and 125" + }, + { + "id": "GOBL-FR-CTC-ORG-INBOX-02", + "desc": "must be in a valid format", + "tests": "matches ^[A-Za-z0-9\\-\\+_/]+$" + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-ORG-ITEM", + "object": "org.Item", + "subsets": [ + { + "field": "meta", + "assert": [ + { + "id": "GOBL-FR-CTC-ORG-ITEM-01", + "desc": "meta values cannot be blank (BR-FR-28)", + "tests": "no blank meta" + } + ] + } + ] + } + ] +} diff --git a/data/schemas/addons/fr/ctc/characteristic.json b/data/schemas/addons/fr/ctc/characteristic.json new file mode 100644 index 000000000..be3089e4f --- /dev/null +++ b/data/schemas/addons/fr/ctc/characteristic.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gobl.org/draft-0/addons/fr/ctc/characteristic", + "$ref": "#/$defs/ctc.Characteristic", + "$defs": { + "ctc.Characteristic": { + "properties": { + "id": { + "type": "string", + "title": "ID", + "description": "ID optionally identifies the characteristic." + }, + "type_code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Type Code", + "description": "TypeCode is the CDAR CharacteristicTypeCode." + }, + "reason_code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Reason Code", + "description": "ReasonCode links this characteristic to a sibling bill.Reason\nvia its fr-ctc-reason-code extension value." + }, + "description": { + "type": "string", + "title": "Description", + "description": "Description is a free-form human-readable explanation." + }, + "changed": { + "type": "boolean", + "title": "Changed", + "description": "Changed signals whether the reported value represents a\ncorrection (true) or is being reported unchanged (false)." + }, + "direction": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Direction", + "description": "Direction carries the CDAR AdjustmentDirectionCode." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name is the semantic label of the field the characteristic\nrefers to." + }, + "location": { + "type": "string", + "title": "Location", + "description": "Location is a locator (XPath, JSON pointer, etc.) into the\nreferenced invoice identifying the specific field." + }, + "value": { + "type": "string", + "title": "Value", + "description": "Value carries a free-form string value when the field is textual." + }, + "code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Code", + "description": "Code carries a coded value when the field is itself a code." + }, + "percent": { + "$ref": "https://gobl.org/draft-0/num/percentage", + "title": "Percent", + "description": "Percent holds a percentage value (e.g. a VAT rate correction)." + }, + "amount": { + "$ref": "https://gobl.org/draft-0/currency/amount", + "title": "Amount", + "description": "Amount holds a monetary value paired with its currency." + }, + "numeric": { + "$ref": "https://gobl.org/draft-0/num/amount", + "title": "Numeric", + "description": "Numeric holds a plain numeric value without currency." + }, + "quantity": { + "$ref": "https://gobl.org/draft-0/num/amount", + "title": "Quantity", + "description": "Quantity holds a quantity value, optionally qualified by Measure." + }, + "measure": { + "type": "string", + "title": "Measure", + "description": "Measure optionally describes the unit of Quantity or Numeric." + }, + "date_time": { + "$ref": "https://gobl.org/draft-0/cal/date-time", + "title": "Date Time", + "description": "DateTime holds a date-time value." + } + }, + "type": "object", + "description": "Characteristic mirrors the CDAR SpecifiedDocumentCharacteristic element (MDT-207 and friends) used on Flow 6 lifecycle messages." + } + } +} \ No newline at end of file diff --git a/data/schemas/tax/addon-list.json b/data/schemas/tax/addon-list.json index 04f25514b..bb3b414f4 100644 --- a/data/schemas/tax/addon-list.json +++ b/data/schemas/tax/addon-list.json @@ -56,8 +56,8 @@ "title": "Chorus Pro" }, { - "const": "fr-ctc-flow2-v1", - "title": "France CTC Flow 2" + "const": "fr-ctc-v1", + "title": "France CTC" }, { "const": "fr-facturx-v1", diff --git a/examples/fr/invoice-fr-de-ctc-b2bint.yaml b/examples/fr/invoice-fr-de-ctc-b2bint.yaml index bb5a36314..f516db7d8 100644 --- a/examples/fr/invoice-fr-de-ctc-b2bint.yaml +++ b/examples/fr/invoice-fr-de-ctc-b2bint.yaml @@ -1,6 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/invoice" $addons: - - "fr-ctc-flow2-v1" + - "fr-ctc-v1" uuid: "d4e5f6a7-8901-23de-f012-456789012abc" currency: "EUR" issue_date: "2024-06-15" @@ -11,6 +11,9 @@ tax: ext: fr-ctc-billing-mode: "B7" untdid-document-type: "380" + notes: + - key: exempt + text: "Reverse charge: intra-community supply of services (CGI art. 283-2; VATEX-EU-IC)." supplier: tax_id: diff --git a/examples/fr/invoice-fr-fr-ctc-advance.yaml b/examples/fr/invoice-fr-fr-ctc-advance.yaml index 33ec5357b..9bc955815 100644 --- a/examples/fr/invoice-fr-fr-ctc-advance.yaml +++ b/examples/fr/invoice-fr-fr-ctc-advance.yaml @@ -1,6 +1,7 @@ $schema: "https://gobl.org/draft-0/bill/invoice" $addons: - - "fr-ctc-flow2-v1" + - "eu-en16931-v2017" + - "fr-ctc-v1" uuid: "c3d4e5f6-7890-12cd-ef01-345678901abc" currency: "EUR" issue_date: "2024-06-01" diff --git a/examples/fr/invoice-fr-fr-ctc-b2b.yaml b/examples/fr/invoice-fr-fr-ctc-b2b.yaml index faa8928d4..946f70651 100644 --- a/examples/fr/invoice-fr-fr-ctc-b2b.yaml +++ b/examples/fr/invoice-fr-fr-ctc-b2b.yaml @@ -1,6 +1,7 @@ $schema: "https://gobl.org/draft-0/bill/invoice" $addons: - - "fr-ctc-flow2-v1" + - "eu-en16931-v2017" + - "fr-ctc-v1" uuid: "3aea7b56-59d8-4beb-90bd-f8f280d852a0" currency: "EUR" issue_date: "2024-06-13" diff --git a/examples/fr/invoice-fr-fr-ctc-credit-note.yaml b/examples/fr/invoice-fr-fr-ctc-credit-note.yaml index 6f3aefd3a..3d0bcba9b 100644 --- a/examples/fr/invoice-fr-fr-ctc-credit-note.yaml +++ b/examples/fr/invoice-fr-fr-ctc-credit-note.yaml @@ -1,6 +1,7 @@ $schema: "https://gobl.org/draft-0/bill/invoice" $addons: - - "fr-ctc-flow2-v1" + - "eu-en16931-v2017" + - "fr-ctc-v1" uuid: "b2c3d4e5-6789-01bc-def0-234567890abc" type: "credit-note" currency: "EUR" diff --git a/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml b/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml new file mode 100644 index 000000000..6e796ed9d --- /dev/null +++ b/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml @@ -0,0 +1,45 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +$addons: + - "fr-ctc-v1" +uuid: "8c2b4a1d-d7e2-4d6e-9c1f-3a8b9d4f2e10" +currency: "EUR" +issue_date: "2026-04-15" +series: "B2C" +code: "2026-00018" + +tax: + ext: + fr-ctc-b2c-category: "TLB1" + +supplier: + tax_id: + country: "FR" + code: "732829320" + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + name: "Boutique Reporting SARL" + addresses: + - street: "20 Rue du Commerce" + locality: "Paris" + code: "75015" + country: "FR" + +lines: + - quantity: 1 + item: + name: "Article de boutique" + price: "59.00" + taxes: + - cat: VAT + rate: standard + +payment: + terms: + detail: "Paiement comptant" + instructions: + key: "credit-transfer" + ext: + untdid-payment-means: "30" diff --git a/examples/fr/out/invoice-fr-de-ctc-b2bint.json b/examples/fr/out/invoice-fr-de-ctc-b2bint.json index 4a8223902..18ecf51bc 100644 --- a/examples/fr/out/invoice-fr-de-ctc-b2bint.json +++ b/examples/fr/out/invoice-fr-de-ctc-b2bint.json @@ -4,15 +4,14 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "a92840e74805e6d90ff322344e421e9e0edabc54c306b54ab8d82b6d18496f3b" + "val": "2bbd7ae324f8609660857268fee6b1e87682daeb9c96fc1dc25fafc9b09ae9d9" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "FR", "$addons": [ - "eu-en16931-v2017", - "fr-ctc-flow2-v1" + "fr-ctc-v1" ], "uuid": "d4e5f6a7-8901-23de-f012-456789012abc", "type": "standard", @@ -21,11 +20,16 @@ "issue_date": "2024-06-15", "currency": "EUR", "tax": { - "rounding": "currency", "ext": { "fr-ctc-billing-mode": "B7", "untdid-document-type": "380" - } + }, + "notes": [ + { + "key": "exempt", + "text": "Reverse charge: intra-community supply of services (CGI art. 283-2; VATEX-EU-IC)." + } + ] }, "supplier": { "name": "Fournisseur France SARL", @@ -71,6 +75,15 @@ "country": "DE", "code": "111111125" }, + "identities": [ + { + "scope": "legal", + "code": "DE111111125", + "ext": { + "iso-scheme-id": "0223" + } + } + ], "inboxes": [ { "email": "buchhaltung@kunde.de" @@ -107,7 +120,7 @@ "key": "exempt", "ext": { "cef-vatex": "VATEX-EU-IC", - "untdid-tax-category": "E" + "untdid-tax-category": "K" } } ], @@ -125,10 +138,7 @@ "iban": "FR7630006000011234567890189", "name": "Fournisseur France SARL" } - ], - "ext": { - "untdid-payment-means": "30" - } + ] } }, "totals": { @@ -143,7 +153,7 @@ "key": "exempt", "ext": { "cef-vatex": "VATEX-EU-IC", - "untdid-tax-category": "E" + "untdid-tax-category": "K" }, "base": "1000.00", "amount": "0.00" diff --git a/examples/fr/out/invoice-fr-fr-ctc-advance.json b/examples/fr/out/invoice-fr-fr-ctc-advance.json index 69135a722..eaa9dbf89 100644 --- a/examples/fr/out/invoice-fr-fr-ctc-advance.json +++ b/examples/fr/out/invoice-fr-fr-ctc-advance.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "b8f6004e6c357ba8dcd041c6876030a85b4d123b50901bc2fd5615d05ddfb3ac" + "val": "88e6adbbfa299c63e5a74927d3b87d55d640404ad7072245b4203e334bf69581" } }, "doc": { @@ -12,7 +12,7 @@ "$regime": "FR", "$addons": [ "eu-en16931-v2017", - "fr-ctc-flow2-v1" + "fr-ctc-v1" ], "uuid": "c3d4e5f6-7890-12cd-ef01-345678901abc", "type": "standard", diff --git a/examples/fr/out/invoice-fr-fr-ctc-b2b.json b/examples/fr/out/invoice-fr-fr-ctc-b2b.json index ee581eed7..801e36d26 100644 --- a/examples/fr/out/invoice-fr-fr-ctc-b2b.json +++ b/examples/fr/out/invoice-fr-fr-ctc-b2b.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "414ec7ddd1f304e0c796d1f93be0c330e4a0502ab8cd88235a92a7c07d93b61c" + "val": "06a94209d23cad09d46cbe518bd47eeb62ebd673947c3ea0b9503f180effc264" } }, "doc": { @@ -12,7 +12,7 @@ "$regime": "FR", "$addons": [ "eu-en16931-v2017", - "fr-ctc-flow2-v1" + "fr-ctc-v1" ], "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", "type": "standard", diff --git a/examples/fr/out/invoice-fr-fr-ctc-credit-note.json b/examples/fr/out/invoice-fr-fr-ctc-credit-note.json index 987cdeb1f..4d2ea5496 100644 --- a/examples/fr/out/invoice-fr-fr-ctc-credit-note.json +++ b/examples/fr/out/invoice-fr-fr-ctc-credit-note.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "fd2e3f198fb3c9cedf7af56d63e24f27f1071fa83af3eeef19fe35f62164db1b" + "val": "1da32a0c88fa97f85c8221a86d57a3ce9ff704835c27140a29257ca3fb2db2ea" } }, "doc": { @@ -12,7 +12,7 @@ "$regime": "FR", "$addons": [ "eu-en16931-v2017", - "fr-ctc-flow2-v1" + "fr-ctc-v1" ], "uuid": "b2c3d4e5-6789-01bc-def0-234567890abc", "type": "credit-note", diff --git a/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json new file mode 100644 index 000000000..052fbda10 --- /dev/null +++ b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "f6d97cf5dff94cd50a9d59808e4634a2e8450e5e4d2ab434a3f751d93b34f66e" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "FR", + "$addons": [ + "fr-ctc-v1" + ], + "uuid": "8c2b4a1d-d7e2-4d6e-9c1f-3a8b9d4f2e10", + "type": "standard", + "series": "B2C", + "code": "2026-00018", + "issue_date": "2026-04-15", + "currency": "EUR", + "tax": { + "ext": { + "fr-ctc-b2c-category": "TLB1", + "untdid-document-type": "380" + } + }, + "supplier": { + "name": "Boutique Reporting SARL", + "tax_id": { + "country": "FR", + "code": "44732829320" + }, + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "addresses": [ + { + "street": "20 Rue du Commerce", + "locality": "Paris", + "code": "75015", + "country": "FR" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Article de boutique", + "price": "59.00" + }, + "sum": "59.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "20%" + } + ], + "total": "59.00" + } + ], + "payment": { + "terms": { + "notes": "Paiement comptant" + }, + "instructions": { + "key": "credit-transfer", + "ext": { + "untdid-payment-means": "30" + } + } + }, + "totals": { + "sum": "59.00", + "total": "59.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "59.00", + "percent": "20%", + "amount": "11.80" + } + ], + "amount": "11.80" + } + ], + "sum": "11.80" + }, + "tax": "11.80", + "total_with_tax": "70.80", + "payable": "70.80" + } + } +} \ No newline at end of file diff --git a/examples/fr/out/payment-fr-de-ctc-b2bint.json b/examples/fr/out/payment-fr-de-ctc-b2bint.json new file mode 100644 index 000000000..a605f07f0 --- /dev/null +++ b/examples/fr/out/payment-fr-de-ctc-b2bint.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "4cabf1cc377c67f69ddfd206b0029a896cb22604600d8b41eb9787e828c812a7" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/payment", + "$regime": "FR", + "$addons": [ + "fr-ctc-v1" + ], + "uuid": "1f4d3e8a-9b6c-4d2e-8e7f-7a3c4d5e6f01", + "type": "receipt", + "series": "PAY", + "code": "2026-00042", + "issue_date": "2026-05-02", + "value_date": "2026-05-02", + "currency": "EUR", + "supplier": { + "name": "Fournisseur Reporting SARL", + "tax_id": { + "country": "FR", + "code": "44732829320" + }, + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ] + }, + "customer": { + "name": "Kunde Deutschland GmbH", + "tax_id": { + "country": "DE", + "code": "111111125" + }, + "identities": [ + { + "scope": "legal", + "code": "DE111111125", + "ext": { + "iso-scheme-id": "0223" + } + } + ] + }, + "lines": [ + { + "i": 1, + "document": { + "issue_date": "2026-04-15", + "code": "2026-INT-001" + }, + "amount": "1200.00", + "tax": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "base": "1000.00", + "percent": "20%", + "amount": "200.00" + } + ], + "amount": "200.00" + } + ], + "sum": "200.00" + } + } + ], + "methods": [ + { + "key": "credit-transfer", + "amount": "1200.00", + "credit_transfer": { + "iban": "FR7630006000011234567890189", + "name": "Fournisseur Reporting SARL" + } + } + ], + "total": "1200.00" + } +} \ No newline at end of file diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json new file mode 100644 index 000000000..1b334ed81 --- /dev/null +++ b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "50202b9d3b460812e4e10a4eaa3d502d931f8887de1108f8e44db0630f9c097c" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/status", + "$addons": [ + "fr-ctc-v1" + ], + "uuid": "2a5b6c7d-8e9f-4a1b-2c3d-4e5f6a7b8c01", + "type": "response", + "issue_date": "2026-04-16", + "code": "STA-2026-0001", + "ext": { + "fr-ctc-status-code": "205" + }, + "supplier": { + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ] + }, + "issuer": { + "name": "ACHETEUR SARL", + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "200000008", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "inboxes": [ + { + "key": "peppol", + "scheme": "0225", + "code": "200000008_PEP" + } + ], + "ext": { + "fr-ctc-role": "BY" + } + }, + "recipient": { + "name": "VENDEUR SARL", + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "inboxes": [ + { + "key": "peppol", + "scheme": "0225", + "code": "732829320_PEP" + } + ], + "ext": { + "fr-ctc-role": "SE" + } + }, + "lines": [ + { + "index": 1, + "key": "accepted", + "date": "2026-04-15", + "doc": { + "issue_date": "2026-04-15", + "code": "2026-00042" + } + } + ] + } +} \ No newline at end of file diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json new file mode 100644 index 000000000..d6cdbda7b --- /dev/null +++ b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "926e915228135293354bb22ccb3fdf00fdd7483158275a7952c9c727253b0325" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/status", + "$addons": [ + "fr-ctc-v1" + ], + "uuid": "3b6c7d8e-9f0a-4b2c-3d4e-5f6a7b8c9d01", + "type": "response", + "issue_date": "2026-05-02", + "code": "STA-2026-0002", + "ext": { + "fr-ctc-status-code": "212" + }, + "supplier": { + "name": "VENDEUR SARL", + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ] + }, + "issuer": { + "name": "VENDEUR SARL", + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "inboxes": [ + { + "key": "peppol", + "scheme": "0225", + "code": "732829320_PEP" + } + ], + "ext": { + "fr-ctc-role": "SE" + } + }, + "recipient": { + "name": "ACHETEUR SARL", + "identities": [ + { + "scope": "legal", + "type": "SIREN", + "code": "200000008", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "inboxes": [ + { + "key": "peppol", + "scheme": "0225", + "code": "200000008_PEP" + } + ], + "ext": { + "fr-ctc-role": "BY" + } + }, + "lines": [ + { + "index": 1, + "key": "paid", + "date": "2026-05-02", + "doc": { + "issue_date": "2026-04-15", + "code": "2026-00042" + }, + "complements": [ + { + "$schema": "https://gobl.org/draft-0/addons/fr/ctc/characteristic", + "type_code": "MEN", + "amount": { + "currency": "EUR", + "value": "1200.00" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/examples/fr/payment-fr-de-ctc-b2bint.yaml b/examples/fr/payment-fr-de-ctc-b2bint.yaml new file mode 100644 index 000000000..9d6937311 --- /dev/null +++ b/examples/fr/payment-fr-de-ctc-b2bint.yaml @@ -0,0 +1,46 @@ +$schema: "https://gobl.org/draft-0/bill/payment" +$addons: + - "fr-ctc-v1" +uuid: "1f4d3e8a-9b6c-4d2e-8e7f-7a3c4d5e6f01" +type: "receipt" +currency: "EUR" +issue_date: "2026-05-02" +value_date: "2026-05-02" +series: "PAY" +code: "2026-00042" + +methods: + - key: "credit-transfer" + credit_transfer: + iban: "FR7630006000011234567890189" + name: "Fournisseur Reporting SARL" + +supplier: + tax_id: + country: "FR" + code: "732829320" + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + name: "Fournisseur Reporting SARL" + +customer: + tax_id: + country: "DE" + code: "111111125" + name: "Kunde Deutschland GmbH" + +lines: + - document: + code: "2026-INT-001" + issue_date: "2026-04-15" + amount: "1200.00" + tax: + categories: + - code: "VAT" + rates: + - percent: "20%" + base: "1000.00" + amount: "200.00" diff --git a/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml new file mode 100644 index 000000000..f541c1ff7 --- /dev/null +++ b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml @@ -0,0 +1,44 @@ +$schema: "https://gobl.org/draft-0/bill/status" +$addons: + - "fr-ctc-v1" +uuid: "2a5b6c7d-8e9f-4a1b-2c3d-4e5f6a7b8c01" +issue_date: "2026-04-16" +code: "STA-2026-0001" + +# Buyer-issued ack 23 (CDV-205, "approved"). The buyer's platform reports +# acceptance to the seller. + +# `accepted` is buyer-issued (CDV-205) per Annexe A "Acteurs CDV", so +# the flow6 normaliser: +# - fills issuer.fr-ctc-role=BY and recipient.fr-ctc-role=SE +# - propagates the SE-roled recipient's SIREN onto Supplier so it +# populates ref.IssuerTradeParty (MDT-129) — that's why this +# example doesn't need to spell out a `supplier:` block. +issuer: + name: "ACHETEUR SARL" + identities: + - type: "SIREN" + code: "200000008" + ext: + iso-scheme-id: "0002" + inboxes: + - scheme: "0225" + code: "200000008_PEP" + +recipient: + name: "VENDEUR SARL" + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + inboxes: + - scheme: "0225" + code: "732829320_PEP" + +lines: + - key: "accepted" + date: "2026-04-15" + doc: + code: "2026-00042" + issue_date: "2026-04-15" diff --git a/examples/fr/status-fr-fr-ctc-flow6-paid.yaml b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml new file mode 100644 index 000000000..d4793c70e --- /dev/null +++ b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml @@ -0,0 +1,56 @@ +$schema: "https://gobl.org/draft-0/bill/status" +$addons: + - "fr-ctc-v1" +uuid: "3b6c7d8e-9f0a-4b2c-3d4e-5f6a7b8c9d01" +issue_date: "2026-05-02" +code: "STA-2026-0002" +# `paid` is shared across update (CDV-211 Paiement transmis) and +# response (CDV-212 Encaissée) — pin the Type explicitly. +type: response + +# supplier carries the seller of the referenced invoice; its SIREN +# populates ref.IssuerTradeParty (MDT-129). +supplier: + name: "VENDEUR SARL" + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + +# `paid + response` is seller-issued (CDV-212): normaliser fills +# issuer.fr-ctc-role=SE and recipient.fr-ctc-role=BY automatically. +issuer: + name: "VENDEUR SARL" + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + inboxes: + - scheme: "0225" + code: "732829320_PEP" + +recipient: + name: "ACHETEUR SARL" + identities: + - type: "SIREN" + code: "200000008" + ext: + iso-scheme-id: "0002" + inboxes: + - scheme: "0225" + code: "200000008_PEP" + +lines: + - key: "paid" + date: "2026-05-02" + doc: + code: "2026-00042" + issue_date: "2026-04-15" + complements: + - $schema: "https://gobl.org/draft-0/addons/fr/ctc/characteristic" + type_code: "MEN" + amount: + currency: "EUR" + value: "1200.00" diff --git a/examples_test.go b/examples_test.go index 0e152cad8..863512b1c 100644 --- a/examples_test.go +++ b/examples_test.go @@ -27,6 +27,7 @@ var skipExamplePaths = []string{ ".golangci.yaml", "wasm/", ".claude/", + "cdar_mapping.yaml", ".git/", "internal/", "pkg/", diff --git a/internal/ops/bulk_test.go b/internal/ops/bulk_test.go index 01c83d0a0..e8b143725 100644 --- a/internal/ops/bulk_test.go +++ b/internal/ops/bulk_test.go @@ -615,7 +615,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo // Following raw message is copied and pasted! (sorry!) Payload: json.RawMessage(`{ "list": [ - "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" + "https://gobl.org/draft-0/addons/fr/ctc/characteristic", "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" ] }`), IsFinal: false,