From 1603e6ea32b54ec31e693e4e43ea9be8ba883e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Fri, 24 Apr 2026 18:43:18 +0000 Subject: [PATCH 01/26] Add FR CTC Flow 10 and Flow 6 addons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce two new French CTC addons — reporting (Flow 10, on bill.Invoice and bill.Payment) and lifecycle status (Flow 6, on bill.Status) — and reorganise Flow 2 under addons/fr/ctc/flow2/ so the three variants sit side by side. Flow 10 (e-reporting) covers B2B and B2C invoices plus B2B and B2C payments, gated by a b2c tag. Validates billing mode (G1.02), allowed UNTDID document types, final-after-advance invariant (G1.60), currency convertibility to EUR, address country, supplier/customer legal scheme (G2.19), VAT ID when scheme is SIREN/EU-VAT (G2.33), exempt categories, G1.24 rate whitelist on invoices and payments, B2C transaction category (G1.68), and payment receipt/lines/document refs. Normalizes SIREN and EU VAT identities from TaxID, defaults billing mode (M1/M2) and the B2C category (TNT1), and maps rate keys to UNTDID 5305 categories. Flow 6 (CDV lifecycle) is standalone — does not require Flow 2. Carries the authoritative tables gobl.cii reads for the CDAR round-trip: ProcessConditionCode 200–213 (extends bill.StatusEvents with 8 France-specific keys), 45 ReasonCodes with bucket + default-for-key flag, 7 RequestedActionCodes. Three extensions (fr-ctc-role, fr-ctc-reason-code) plus a Characteristic complement covering MDT-207 and the MEN for paid lines. Validations for the rules surfaced in BR-FR-CDV (supplier SIREN, doc code/issue-date, reason required on rejection-like statuses, TypeCode whitelist, characteristic ReasonCode link to sibling Reason); the Type-dependent role and recipient routing rules are intentionally left to PPF-side business logic. The Flow 2 move (addons/fr/ctc/ → addons/fr/ctc/flow2/) renames the package to flow2 and shortens Flow2Key/Flow2V1 to Key/V1. addons.go blank-imports all three flows. Tests are one _test.go per source file in each package (internal package so unexported helpers are reachable), with individual named tests wired to tiny per-axis helpers instead of table-driven loops. Coverage: flow2 93.0%, flow10 90.1%, flow6 96.4%. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/addons.go | 4 +- addons/fr/ctc/flow10/bill.go | 193 +++++++++ addons/fr/ctc/flow10/bill_invoice.go | 359 +++++++++++++++++ addons/fr/ctc/flow10/bill_invoice_test.go | 369 ++++++++++++++++++ addons/fr/ctc/flow10/bill_test.go | 179 +++++++++ addons/fr/ctc/flow10/extensions.go | 182 +++++++++ addons/fr/ctc/flow10/flow10.go | 91 +++++ addons/fr/ctc/flow10/party.go | 144 +++++++ addons/fr/ctc/flow10/party_test.go | 121 ++++++ addons/fr/ctc/flow10/scenarios.go | 154 ++++++++ addons/fr/ctc/flow10/tags.go | 34 ++ addons/fr/ctc/{ => flow2}/bill.go | 2 +- addons/fr/ctc/{ => flow2}/bill_invoices.go | 2 +- addons/fr/ctc/{ => flow2}/bill_test.go | 10 +- addons/fr/ctc/{ => flow2}/extensions.go | 2 +- addons/fr/ctc/{ctc.go => flow2/flow2.go} | 18 +- addons/fr/ctc/{ => flow2}/org.go | 2 +- addons/fr/ctc/{ => flow2}/org_party.go | 2 +- addons/fr/ctc/{ => flow2}/org_test.go | 101 ++++- addons/fr/ctc/{ => flow2}/tags.go | 2 +- addons/fr/ctc/flow6/bill_status.go | 363 +++++++++++++++++ addons/fr/ctc/flow6/bill_status_test.go | 430 +++++++++++++++++++++ addons/fr/ctc/flow6/codes.go | 207 ++++++++++ addons/fr/ctc/flow6/codes_test.go | 361 +++++++++++++++++ addons/fr/ctc/flow6/complements.go | 116 ++++++ addons/fr/ctc/flow6/extensions.go | 114 ++++++ addons/fr/ctc/flow6/extensions_test.go | 26 ++ addons/fr/ctc/flow6/flow6.go | 99 +++++ addons/fr/ctc/flow6/org_party.go | 74 ++++ addons/fr/ctc/flow6/org_party_test.go | 56 +++ addons/fr/ctc/item_test.go | 85 ---- examples_test.go | 1 + internal/ops/bulk_test.go | 2 +- 33 files changed, 3785 insertions(+), 120 deletions(-) create mode 100644 addons/fr/ctc/flow10/bill.go create mode 100644 addons/fr/ctc/flow10/bill_invoice.go create mode 100644 addons/fr/ctc/flow10/bill_invoice_test.go create mode 100644 addons/fr/ctc/flow10/bill_test.go create mode 100644 addons/fr/ctc/flow10/extensions.go create mode 100644 addons/fr/ctc/flow10/flow10.go create mode 100644 addons/fr/ctc/flow10/party.go create mode 100644 addons/fr/ctc/flow10/party_test.go create mode 100644 addons/fr/ctc/flow10/scenarios.go create mode 100644 addons/fr/ctc/flow10/tags.go rename addons/fr/ctc/{ => flow2}/bill.go (99%) rename addons/fr/ctc/{ => flow2}/bill_invoices.go (99%) rename addons/fr/ctc/{ => flow2}/bill_test.go (99%) rename addons/fr/ctc/{ => flow2}/extensions.go (99%) rename addons/fr/ctc/{ctc.go => flow2/flow2.go} (91%) rename addons/fr/ctc/{ => flow2}/org.go (99%) rename addons/fr/ctc/{ => flow2}/org_party.go (99%) rename addons/fr/ctc/{ => flow2}/org_test.go (91%) rename addons/fr/ctc/{ => flow2}/tags.go (98%) create mode 100644 addons/fr/ctc/flow6/bill_status.go create mode 100644 addons/fr/ctc/flow6/bill_status_test.go create mode 100644 addons/fr/ctc/flow6/codes.go create mode 100644 addons/fr/ctc/flow6/codes_test.go create mode 100644 addons/fr/ctc/flow6/complements.go create mode 100644 addons/fr/ctc/flow6/extensions.go create mode 100644 addons/fr/ctc/flow6/extensions_test.go create mode 100644 addons/fr/ctc/flow6/flow6.go create mode 100644 addons/fr/ctc/flow6/org_party.go create mode 100644 addons/fr/ctc/flow6/org_party_test.go delete mode 100644 addons/fr/ctc/item_test.go diff --git a/addons/addons.go b/addons/addons.go index e11c87802..31f878f55 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -21,7 +21,9 @@ import ( _ "github.com/invopop/gobl/addons/es/verifactu" _ "github.com/invopop/gobl/addons/eu/en16931" _ "github.com/invopop/gobl/addons/fr/choruspro" - _ "github.com/invopop/gobl/addons/fr/ctc" + _ "github.com/invopop/gobl/addons/fr/ctc/flow10" + _ "github.com/invopop/gobl/addons/fr/ctc/flow2" + _ "github.com/invopop/gobl/addons/fr/ctc/flow6" _ "github.com/invopop/gobl/addons/fr/facturx" _ "github.com/invopop/gobl/addons/gr/mydata" _ "github.com/invopop/gobl/addons/it/sdi" diff --git a/addons/fr/ctc/flow10/bill.go b/addons/fr/ctc/flow10/bill.go new file mode 100644 index 000000000..56ba0ef6c --- /dev/null +++ b/addons/fr/ctc/flow10/bill.go @@ -0,0 +1,193 @@ +package flow10 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// vatKeyToUNTDIDCategory maps each supported GOBL VAT rate key to its +// UNTDID 5305 category code. The Canary Islands (IGIC / "L") and +// Ceuta/Melilla (IPSI / "M") categories are intentionally absent since +// they are not applicable to Flow 10. +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", +} + +func invoiceIsB2C(inv *bill.Invoice) bool { + return inv != nil && inv.Tags.HasTags(TagB2C) +} + +func paymentIsB2C(pmt *bill.Payment) bool { + return pmt != nil && pmt.Tags.HasTags(TagB2C) +} + +func normalizeInvoice(inv *bill.Invoice) { + if inv == nil { + return + } + normalizeInvoiceTaxCategories(inv) + if invoiceIsB2C(inv) { + normalizeB2CCategoryOnInvoice(inv) + return + } + normalizeParty(inv.Supplier) + normalizeParty(inv.Customer) + normalizeInvoiceBillingMode(inv) +} + +// normalizeB2CCategoryOnInvoice defaults the B2C transaction category to +// TNT1 (not subject to French VAT) when the caller has not supplied one. +// TNT1 is the safest default: it covers B2C sales that would otherwise +// require explicit per-case classification (intra-EU distance sales, +// out-of-scope, etc.), and a user wanting a narrower code must set it +// explicitly. +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. Combos whose key we do not +// map (IGIC / IPSI, or unknown) are left untouched. +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 code, ok := vatKeyToUNTDIDCategory[combo.Key]; ok { + combo.Ext = combo.Ext.Set(untdid.ExtKeyTaxCategory, code) + } + } + } +} + +// normalizeInvoiceBillingMode picks a sensible default for the Flow 10 +// billing-mode extension when the user has not supplied one. We default to +// the Mixed (M) prefix since it is the safest without line-level analysis: +// - M2 when the invoice is already paid in full +// - M1 otherwise +// +// The user can override by setting the extension explicitly. +func normalizeInvoiceBillingMode(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) +} + +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), + ), + ), + // Payment date and at least one line (needed to report the amount + // per rate) apply to both B2B and B2C payments. + rules.Field("value_date", + rules.Assert("02", "payment value_date (settlement date) is required", + is.Present, + ), + ), + // VAT rates reported on payment lines are constrained to the same + // G1.24 whitelist as invoices, applied to both B2B and B2C. + rules.Assert("07", "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates", paymentVATRatesAllowed), + ), + // Supplier SIREN identifies the French reporting party on the + // payment. Required for both B2B and B2C. + rules.Field("supplier", + rules.Assert("08", "supplier is required", + is.Present, + ), + rules.Assert("09", "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002)", + is.Func("party has SIREN", partyHasSIREN), + ), + ), + // Only B2B payments must carry an invoice reference per line + // (invoice ID and issue date) so they can be reconciled against + // the settled invoice. + rules.When( + is.Func("B2B payment", paymentIsB2BAny), + rules.Field("lines", + rules.Each( + rules.Field("document", + rules.Assert("04", "each payment line must reference a document (invoice) on B2B payments", + is.Present, + ), + rules.Field("code", + rules.Assert("05", "payment line document code (invoice ID) is required on B2B payments", + is.Present, + ), + ), + rules.Field("issue_date", + rules.Assert("06", "payment line document issue_date (invoice issue date) is required on B2B payments", + is.Present, + ), + ), + ), + ), + ), + ), + ) +} + +func paymentIsB2BAny(v any) bool { + pmt, ok := v.(*bill.Payment) + return ok && !paymentIsB2C(pmt) +} + +// paymentVATRatesAllowed reports whether every VAT rate total on the +// payment's lines matches one of the G1.24 whitelist percentages. +func paymentVATRatesAllowed(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, allowedVATRates) { + return false + } + } + } + } + return true +} diff --git a/addons/fr/ctc/flow10/bill_invoice.go b/addons/fr/ctc/flow10/bill_invoice.go new file mode 100644 index 000000000..1d7101070 --- /dev/null +++ b/addons/fr/ctc/flow10/bill_invoice.go @@ -0,0 +1,359 @@ +package flow10 + +import ( + "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/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" +) + +// A VAT rate key other than "standard" or "zero" is treated as an +// exemption for Flow 10 purposes: it must be paired with a matching +// exemption reason (tax.Note with the same Key and non-empty Text). +// Translating the key to the final UNTDID category code is the +// converter's job, not this addon's. + +// finalAfterAdvanceBillingModes are the billing-mode codes that mark an +// invoice as a "final invoice after down payment" (G1.60): B4, S4, M4. +// Under these modes the invoice may not be an advance-payment document +// type (386/500/503). +var finalAfterAdvanceBillingModes = []cbc.Code{ + BillingModeB4, BillingModeS4, BillingModeM4, +} + +// advancePaymentDocumentTypes are the UNTDID 1001 codes representing +// advance-payment invoices and their credit memo (G1.60 forbids them +// combined with B4/S4/M4 billing modes). +var advancePaymentDocumentTypes = []cbc.Code{ + "386", // Advance payment invoice + "500", // Self-billed advance payment + "503", // Down-payment credit memo +} + +func billInvoiceRules() *rules.Set { + return rules.For(new(bill.Invoice), + // B2C rules: category, supplier SIREN, VAT rate whitelist. + rules.When( + is.Func("B2C invoice", invoiceIsB2CAny), + rules.Field("tax", + rules.Field("ext", + rules.Assert("16", "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("17", "supplier is required on B2C invoice", + is.Present, + ), + rules.Assert("18", "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002) on a B2C invoice", + is.Func("party has SIREN", partyHasSIREN), + ), + ), + rules.Assert("19", "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates", invoiceVATRatesAllowed), + ), + ), + // Flow 10 reports to the French authority in EUR: if the invoice is + // issued in a different currency, an exchange rate must be provided. + rules.Assert("10", "invoice must be in EUR or provide an exchange rate to EUR", + currency.CanConvertTo(currency.EUR), + ), + // When a party carries any postal address, the country on that + // address must be populated. The address itself remains optional. + rules.Field("supplier", + rules.Field("addresses", + rules.Each( + rules.Field("country", + rules.Assert("13", "supplier address must include country", + is.Present, + ), + ), + ), + ), + ), + rules.Field("customer", + rules.Field("addresses", + rules.Each( + rules.Field("country", + rules.Assert("14", "customer address must include country", + is.Present, + ), + ), + ), + ), + ), + // B2B: both supplier and customer must be present, each with a legal + // identity declaring an allowed ICD 6523 scheme (G2.19). If that scheme + // is SIREN (0002) or EU VAT (0223), a matching TaxID must also be set + // (G2.33). + rules.When( + is.Func("B2B invoice", invoiceIsB2BAny), + rules.Field("tax", + rules.Field("ext", + rules.Assert("09", "invoice document type must be one of the Flow 10 permitted UNTDID 1001 codes (380, 389, 393, 501, 386, 500, 384, 471, 472, 473, 381, 261, 396, 502, 503)", + is.Func("allowed Flow 10 document type", invoiceDocumentTypeAllowed), + ), + rules.Assert("11", "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("12", "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("01", "supplier is required for Flow 10 B2B invoice (G2.19)", + is.Present, + ), + rules.Assert("02", "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("03", "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("07", "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("15", "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("04", "customer is required for Flow 10 B2B invoice (G2.19)", + is.Present, + ), + rules.Assert("05", "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("06", "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), + ), + ), + ), + ) +} + +func invoiceIsB2BAny(v any) bool { + inv, ok := v.(*bill.Invoice) + return ok && !invoiceIsB2C(inv) +} + +func invoiceIsB2CAny(v any) bool { + inv, ok := v.(*bill.Invoice) + return ok && invoiceIsB2C(inv) +} + +// allowedVATRates is the whitelist of VAT percentages authorised on a +// Flow 10 invoice (G1.24). Comparison is numeric — "20", "20.0" and +// "20.00" are all equivalent, handled by num.Percentage.Compare. +var allowedVATRates = 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 partyHasSIREN(v any) bool { + party, ok := v.(*org.Party) + if !ok || party == nil { + return false + } + for _, id := range party.Identities { + if id == nil || id.Ext.IsZero() { + continue + } + if id.Ext.Get(iso.ExtKeySchemeID).String() == schemeIDSIREN { + return true + } + } + return false +} + +func invoiceVATRatesAllowed(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, allowedVATRates) { + return false + } + } + } + return true +} + +func percentageInList(p num.Percentage, list []num.Percentage) bool { + for _, a := range list { + if p.Compare(a) == 0 { + return true + } + } + return false +} + +func extensionsHaveB2CCategory(v any) bool { + return extensionsValue(v).Get(ExtKeyB2CCategory) != "" +} + +// extensionsValue extracts a tax.Extensions from either a value- or +// pointer-typed argument. The rules engine currently passes fields of +// struct type by pointer, so both forms must be handled. +func extensionsValue(v any) tax.Extensions { + switch ext := v.(type) { + case tax.Extensions: + return ext + case *tax.Extensions: + if ext == nil { + return tax.Extensions{} + } + return *ext + default: + return tax.Extensions{} + } +} + +func partyHasAllowedLegalScheme(v any) bool { + party, ok := v.(*org.Party) + if !ok || party == nil { + return false + } + return slices.Contains(allowedPartySchemeIDs, partyLegalSchemeID(party)) +} + +func extensionsHaveBillingMode(v any) bool { + return extensionsValue(v).Get(ExtKeyBillingMode) != "" +} + +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, extensionsValue(v).Get(untdid.ExtKeyDocumentType)) +} + +// invoiceDocumentTypeAllowed reads the untdid-document-type extension set +// by the Flow 10 scenarios and confirms it is one of the permitted codes. +func invoiceDocumentTypeAllowed(v any) bool { + return slices.Contains(allowedDocumentTypes, extensionsValue(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. Per the Flow 10 spec, invoices with an +// exempt VAT breakdown must carry at least one of these two VAT IDs. +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 != "" +} + +// invoiceHasExemptVATCategory reports whether any line on the invoice +// carries a VAT combo tagged with UNTDID 5305 category code "E" (exempt). +// We inspect line-level combos rather than the aggregated totals because +// the untdid-tax-category extension is carried on the combo itself, and +// the totals breakdown is only populated after Calculate has run. +// invoiceHasExemptCombo reports whether the invoice has any VAT combo +// whose UNTDID 5305 tax-category extension is "E" (exempt). Reading the +// extension rather than the combo Key lets upstream converters or +// manual entries declare exemption directly via the UNTDID code. +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/flow10/bill_invoice_test.go b/addons/fr/ctc/flow10/bill_invoice_test.go new file mode 100644 index 000000000..562ee2157 --- /dev/null +++ b/addons/fr/ctc/flow10/bill_invoice_test.go @@ -0,0 +1,369 @@ +package flow10 + +import ( + "testing" + + "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/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/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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(tax.ExtMap{ + iso.ExtKeySchemeID: "0002", + }), + }, + }, + Addresses: []*org.Address{{Country: "FR"}}, + } +} + +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(tax.ExtMap{ + iso.ExtKeySchemeID: "0002", + }), + }, + }, + Addresses: []*org.Address{{Country: "FR"}}, + } +} + +func testInvoiceB2B(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: frCustomerWithSIREN(), + 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 testInvoiceB2C(t *testing.T) *bill.Invoice { + t.Helper() + inv := &bill.Invoice{ + Regime: tax.WithRegime("FR"), + Addons: tax.WithAddons(V1), + Tags: tax.WithTags(TagB2C), + Code: "INV-2026-B2C-001", + Currency: "EUR", + IssueDate: cal.MakeDate(2026, 1, 15), + Type: bill.InvoiceTypeStandard, + Tax: &bill.Tax{ + Ext: tax.ExtensionsOf(tax.ExtMap{ + 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)}, + }, + }, + }, + } + return inv +} + +func TestInvoiceB2BHappyPath(t *testing.T) { + inv := testInvoiceB2B(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 TestInvoiceCurrencyRequiresEURConversion(t *testing.T) { + inv := testInvoiceB2B(t) + inv.Currency = "USD" + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "EUR") +} + +func TestInvoiceCurrencyUSDWithExchangeRate(t *testing.T) { + inv := testInvoiceB2B(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 TestInvoiceB2BDocTypeNotAllowed(t *testing.T) { + inv := testInvoiceB2B(t) + require.NoError(t, inv.Calculate()) + // Force a document type that is not in the Flow 10 whitelist. + 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 TestInvoiceB2BMissingBillingMode(t *testing.T) { + inv := testInvoiceB2B(t) + require.NoError(t, inv.Calculate()) + // Normalization defaults the billing mode; clear it to simulate a + // downstream consumer that strips the extension. + inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "billing mode") +} + +func TestInvoiceB2BFinalAfterAdvanceRejectsDepositDocType(t *testing.T) { + inv := testInvoiceB2B(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 TestInvoiceB2BSupplierRequiresAllowedScheme(t *testing.T) { + inv := testInvoiceB2B(t) + require.NoError(t, inv.Calculate()) + // Strip the supplier's identities so no allowed scheme remains. + inv.Supplier.Identities = nil + err := rules.Validate(inv) + assert.ErrorContains(t, err, "supplier must declare a legal identity") +} + +func TestInvoiceB2BAddressRequiresCountry(t *testing.T) { + inv := testInvoiceB2B(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 TestInvoiceB2BExemptRequiresSellerVATID(t *testing.T) { + inv := testInvoiceB2B(t) + inv.Lines[0].Taxes = tax.Set{ + {Category: tax.CategoryVAT, Key: tax.KeyExempt}, + } + require.NoError(t, inv.Calculate()) + // Drop both potential VAT IDs; no ordering.seller either. + inv.Supplier.TaxID = nil + inv.Ordering = nil + err := rules.Validate(inv) + assert.ErrorContains(t, err, "supplier VAT ID or ordering.seller") +} + +func TestInvoiceB2BExemptRequiresExemptTaxNote(t *testing.T) { + inv := testInvoiceB2B(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 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) + // Clear both TaxID and Identities — party normalization would + // otherwise regenerate a SIREN from the French TaxID. + 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)}, // 17%, not allowed + } + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "G1.24") +} + +func TestNormalizeDefaultBillingModeM1(t *testing.T) { + inv := testInvoiceB2B(t) + require.NoError(t, inv.Calculate()) + assert.Equal(t, BillingModeM1, inv.Tax.Ext.Get(ExtKeyBillingMode)) +} + +func TestNormalizeTaxCategorySetFromKey(t *testing.T) { + inv := testInvoiceB2B(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 TestNormalizeGeneratesSIRENFromFrenchTaxID(t *testing.T) { + inv := testInvoiceB2B(t) + inv.Supplier.Identities = nil + require.NoError(t, inv.Calculate()) + // normalizeParty should have injected a SIREN-scheme identity. + 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") +} + +// --- Internal helper coverage (bill_invoice.go) ------------------------- + +func TestExtensionsValueNilPointer(t *testing.T) { + assert.True(t, extensionsValue((*tax.Extensions)(nil)).IsZero()) +} + +func TestExtensionsValueUnknownType(t *testing.T) { + assert.True(t, extensionsValue(42).IsZero()) +} + +func TestExtensionsValueValue(t *testing.T) { + e := tax.ExtensionsOf(tax.ExtMap{"k": "v"}) + assert.False(t, extensionsValue(e).IsZero()) +} + +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(tax.ExtMap{iso.ExtKeySchemeID: "0227"}), + }}} + assert.True(t, partyHasTaxIDWhenRequired(p)) +} + +func TestInvoiceIsB2BWrongType(t *testing.T) { + assert.False(t, invoiceIsB2BAny("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, invoiceVATRatesAllowed("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)) +} diff --git a/addons/fr/ctc/flow10/bill_test.go b/addons/fr/ctc/flow10/bill_test.go new file mode 100644 index 000000000..8d08fdd87 --- /dev/null +++ b/addons/fr/ctc/flow10/bill_test.go @@ -0,0 +1,179 @@ +package flow10 + +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, + Method: &pay.Instructions{Key: pay.MeansKeyCreditTransfer}, + 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), + Tags: tax.WithTags(TagB2C), + Type: bill.PaymentTypeReceipt, + Code: "PAY-2026-B2C-001", + Currency: "EUR", + IssueDate: cal.MakeDate(2026, 2, 1), + ValueDate: &paid, + Method: &pay.Instructions{Key: pay.MeansKeyCreditTransfer}, + 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) + // A B2C payment line has no Document at all — should still pass. + 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 (bill.go) --------------------------------- + +func TestPaymentIsB2BWrongType(t *testing.T) { + assert.False(t, paymentIsB2BAny("x")) +} + +func TestPaymentVATRatesAllowedWrongType(t *testing.T) { + assert.True(t, paymentVATRatesAllowed("x")) +} + +func TestPaymentVATRatesAllowedNilLine(t *testing.T) { + p := &bill.Payment{Lines: []*bill.PaymentLine{nil}} + assert.True(t, paymentVATRatesAllowed(p)) +} + +func TestNormalizeInvoiceNilSafe(t *testing.T) { + assert.NotPanics(t, func() { normalizeInvoice(nil) }) +} + +func TestNormalizeInvoiceBillingModeDefaultsM2WhenPaid(t *testing.T) { + due := num.MakeAmount(0, 2) + inv := &bill.Invoice{ + Totals: &bill.Totals{Due: &due}, + Tax: &bill.Tax{}, + } + normalizeInvoiceBillingMode(inv) + assert.Equal(t, BillingModeM2, inv.Tax.Ext.Get(ExtKeyBillingMode)) +} diff --git a/addons/fr/ctc/flow10/extensions.go b/addons/fr/ctc/flow10/extensions.go new file mode 100644 index 000000000..a0bc308ec --- /dev/null +++ b/addons/fr/ctc/flow10/extensions.go @@ -0,0 +1,182 @@ +package flow10 + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +// French CTC extension keys for e-reporting +const ( + // ExtKeyBillingMode defines the billing framework mode (B1-B8, S1-S8, M1-M8). + // Shared conceptually with Flow 2: both flows use the same underlying key + // "fr-ctc-billing-mode" and identical value set; each addon declares the + // definition independently so consumers can opt into either flow in + // isolation. + ExtKeyBillingMode cbc.Key = "fr-ctc-billing-mode" + + // ExtKeyB2CCategory classifies the B2C transaction for PPF reporting + // (G1.68). Required on B2C invoices and B2C payments. + ExtKeyB2CCategory cbc.Key = "fr-ctc-b2c-category" +) + +// 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) +// 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) +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" +) + +var extensions = []*cbc.Definition{ + { + 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 Flow 10 reporting to the PPF + (G1.68). Required on B2C invoices and B2C payments. + + - 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 reporting Flux 10 au PPF (G1.68). + Obligatoire sur les factures et paiements B2C. + + - 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: ExtKeyBillingMode, + Name: i18n.String{ + i18n.EN: "Billing Mode", + i18n.FR: "Cadre de Facturation", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Code used to describe the billing framework of the invoice. The billing mode + indicates the nature of goods/services and the payment context. + + Code prefixes indicate the invoice nature: + - "B": Goods invoice (Biens) + - "S": Services invoice + - "M": Mixed/dual invoice (goods and services that are not accessory to each other) + + The numeric suffix indicates the payment type (1=deposit, 2=already paid, + 4=final after down payment, 5=subcontractor, 6=co-contractor, 7=e-reporting). + `), + i18n.FR: here.Doc(` + Code utilisé pour décrire le cadre de facturation de la facture. Le mode de + facturation indique la nature des biens/services et le contexte de paiement. + + Les préfixes de code indiquent la nature de la facture : + - "B" : Facture de biens + - "S" : Facture de services + - "M" : Facture mixte (biens et services qui ne sont pas accessoires l'un de l'autre) + + Le suffixe numérique indique le type de paiement (1=dépôt, 2=déjà payée, + 4=définitive après acompte, 5=sous-traitant, 6=cotraitant, 7=e-reporting). + `), + }, + 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)"}}, + }, + }, +} diff --git a/addons/fr/ctc/flow10/flow10.go b/addons/fr/ctc/flow10/flow10.go new file mode 100644 index 000000000..e9ffb2a0c --- /dev/null +++ b/addons/fr/ctc/flow10/flow10.go @@ -0,0 +1,91 @@ +// Package flow10 handles the extensions and validation rules for the French +// CTC (Cycle de Traitement de la Commande) Flow 10 e-reporting requirements +// for transactions not subject to Flow 2 domestic B2B clearance. +package flow10 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +const ( + // Key identifies the French CTC Flow 10 addon family. + Key cbc.Key = "fr-ctc-flow10" + + // V1 is the key for the French CTC Flow 10 addon + V1 cbc.Key = Key + "-v1" +) + +func init() { + tax.RegisterAddonDef(newAddon()) + rules.RegisterWithGuard( + Key.String(), + rules.GOBL.Add("FR-CTC-FLOW10"), + is.InContext(tax.AddonIn(V1)), + billInvoiceRules(), + billPaymentRules(), + ) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V1, + Name: i18n.String{ + i18n.EN: "France CTC Flow 10", + i18n.FR: "France CTC Flux 10", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Support for the French CTC (Continuous Transaction Control) Flow 10 + e-reporting requirements from the French electronic invoicing reform. + + Flow 10 covers transactions that must be reported to the tax authority + but are not subject to the domestic B2B clearance flow (Flow 2). This + includes B2C, cross-border, and out-of-scope transactions where VAT + data and payment data must still be transmitted to the PPF. + `), + i18n.FR: here.Doc(` + Support pour le CTC (Contrôle Continu des Transactions) français Flux 10 + pour les exigences de e-reporting de la réforme française de la + facturation électronique. + + Le Flux 10 couvre les transactions qui doivent être déclarées à + l'administration fiscale mais qui ne sont pas soumises au flux B2B + domestique (Flux 2). Cela inclut les transactions B2C, transfrontalières + et hors champ pour lesquelles les données de TVA et de paiement doivent + tout de même être transmises au PPF. + `), + }, + Sources: []*cbc.Source{ + { + Title: i18n.String{ + i18n.EN: "External Specifications", + i18n.FR: "Spécifications Externes", + }, + URL: "https://www.impots.gouv.fr/specifications-externes-b2b", + }, + }, + Extensions: extensions, + Tags: []*tax.TagSet{ + invoiceTags, + paymentTags, + }, + Scenarios: scenarios, + Normalizer: normalize, + } +} + +func normalize(doc any) { + switch obj := doc.(type) { + case *bill.Invoice: + normalizeInvoice(obj) + case *org.Party: + normalizeParty(obj) + } +} diff --git a/addons/fr/ctc/flow10/party.go b/addons/fr/ctc/flow10/party.go new file mode 100644 index 000000000..1e059d903 --- /dev/null +++ b/addons/fr/ctc/flow10/party.go @@ -0,0 +1,144 @@ +package flow10 + +import ( + "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" +) + +// ICD 6523 scheme IDs accepted for Flow 10 party identification (G2.19). +const ( + schemeIDSIREN = "0002" // French SIREN (9 digits) + schemeIDEUVAT = "0223" // EU (non-French) intra-community VAT ID + schemeIDNonEU = "0227" // Outside EU: country code + first 16 chars of name + schemeIDRIDET = "0228" // New Caledonia RIDET + schemeIDTAHITI = "0229" // French Polynesia TAHITI +) + +// 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{ + schemeIDSIREN, + schemeIDEUVAT, + schemeIDNonEU, + schemeIDRIDET, + schemeIDTAHITI, +} + +// schemeIDsRequiringVAT are the scheme IDs for which party.TaxID must also +// be present (G2.33): SIREN (French) and EU non-French VAT identifiers. +var schemeIDsRequiringVAT = []string{ + schemeIDSIREN, + schemeIDEUVAT, +} + +// normalizeParty attempts to derive the Flow 10 party identity from +// information already present on the party (TaxID, SIRET) so that the +// downstream rules can succeed without the caller having to hand-craft +// the ICD 6523 identity entry. +func normalizeParty(party *org.Party) { + if party == nil || 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)), schemeIDSIREN) + case isEUNonFrance(country): + ensureIdentity(party, "", cbc.Code(country.String()+code), schemeIDEUVAT) + } +} + +// sirenFromFrenchTaxID extracts the 9-digit SIREN from a French TaxID. The +// French VAT format is FR + 2 check digits + 9 digit SIREN; if the TaxID +// has already been stripped to just the 9 digits we return it as-is. If a +// SIRET identity is already present we prefer its first 9 digits. +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; identities that already declare the scheme (via +// iso.ExtKeySchemeID) are left untouched so user-supplied data wins. +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(tax.ExtMap{ + iso.ExtKeySchemeID: cbc.Code(schemeID), + }), + Scope: org.IdentityScopeLegal, + }) +} + +// partyLegalSchemeID returns the ICD 6523 scheme ID of the identity the +// party presents as its legal identifier for Flow 10. It prefers an +// identity scoped as "legal"; failing that, the first identity that +// declares a known Flow 10 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) +} diff --git a/addons/fr/ctc/flow10/party_test.go b/addons/fr/ctc/flow10/party_test.go new file mode 100644 index 000000000..1522b279a --- /dev/null +++ b/addons/fr/ctc/flow10/party_test.go @@ -0,0 +1,121 @@ +package flow10 + +import ( + "testing" + + "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/tax" + "github.com/stretchr/testify/assert" +) + +// --- isEUNonFrance ------------------------------------------------------- + +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)) +} + +// --- normalizeParty ------------------------------------------------------ + +func TestNormalizePartyNilSafe(t *testing.T) { + assert.NotPanics(t, func() { normalizeParty(nil) }) +} + +func TestNormalizePartyNoTaxID(t *testing.T) { + p := &org.Party{Name: "Solo"} + normalizeParty(p) + assert.Empty(t, p.Identities) +} + +func TestNormalizePartyEmptyTaxIDCode(t *testing.T) { + p := &org.Party{TaxID: &tax.Identity{Country: "FR"}} + normalizeParty(p) + assert.Empty(t, p.Identities) +} + +func TestNormalizePartyNonEUNonFR(t *testing.T) { + // Non-EU / non-FR countries are left alone. + p := &org.Party{TaxID: &tax.Identity{Country: "US", Code: "12-3456789"}} + normalizeParty(p) + assert.Empty(t, p.Identities) +} + +// --- sirenFromFrenchTaxID ------------------------------------------------ + +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) +} + +// --- ensureIdentity ------------------------------------------------------ + +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(tax.ExtMap{iso.ExtKeySchemeID: "0002"}), + }, + }} + ensureIdentity(p, "", "new", "0002") + assert.Len(t, p.Identities, 1) + assert.Equal(t, cbc.Code("existing"), p.Identities[0].Code) +} + +// --- partyLegalSchemeID -------------------------------------------------- + +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(tax.ExtMap{iso.ExtKeySchemeID: "0227"})}, + {Code: "B", Scope: org.IdentityScopeLegal, Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{iso.ExtKeySchemeID: "9999"})}, + {Code: "B", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"})}, + }} + assert.Equal(t, "0002", partyLegalSchemeID(p)) +} diff --git a/addons/fr/ctc/flow10/scenarios.go b/addons/fr/ctc/flow10/scenarios.go new file mode 100644 index 000000000..a71d8c163 --- /dev/null +++ b/addons/fr/ctc/flow10/scenarios.go @@ -0,0 +1,154 @@ +package flow10 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +// Flow 10 accepts a curated subset of UNTDID 1001 document type codes. +// The scenarios below map GOBL invoice types (+ tag combinations) to the +// corresponding UNTDID code via untdid.ExtKeyDocumentType. The list is +// intentionally self-contained so Flow 10 can operate without requiring +// the en16931 addon. +var scenarios = []*tax.ScenarioSet{ + { + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // Simple invoices --------------------------------------------------- + { + // 380 — Sales invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "380", + }), + }, + { + // 389 — Self-billed invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagSelfBilled}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "389", + }), + }, + { + // 393 — Factored invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagFactoring}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "393", + }), + }, + { + // 501 — Self-invoiced factored invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagSelfBilled, tax.TagFactoring}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "501", + }), + }, + + // Deposit invoices -------------------------------------------------- + { + // 386 — Deposit invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagPrepayment}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "386", + }), + }, + { + // 500 — Self-invoiced deposit invoice + Types: []cbc.Key{bill.InvoiceTypeStandard}, + Tags: []cbc.Key{tax.TagSelfBilled, tax.TagPrepayment}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "500", + }), + }, + + // Corrective invoices ----------------------------------------------- + { + // 384 — Corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "384", + }), + }, + { + // 471 — Self-billed corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Tags: []cbc.Key{tax.TagSelfBilled}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "471", + }), + }, + { + // 472 — Factored corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Tags: []cbc.Key{tax.TagFactoring}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "472", + }), + }, + { + // 473 — Self-billed factored corrective invoice + Types: []cbc.Key{bill.InvoiceTypeCorrective}, + Tags: []cbc.Key{tax.TagSelfBilled, tax.TagFactoring}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "473", + }), + }, + + // Credit memos ------------------------------------------------------ + { + // 381 — Credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "381", + }), + }, + { + // 261 — Self-billed credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Tags: []cbc.Key{tax.TagSelfBilled}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "261", + }), + }, + { + // 396 — Factored credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Tags: []cbc.Key{tax.TagFactoring}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + 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(tax.ExtMap{ + untdid.ExtKeyDocumentType: "502", + }), + }, + { + // 503 — Down-payment invoice credit memo + Types: []cbc.Key{bill.InvoiceTypeCreditNote}, + Tags: []cbc.Key{tax.TagPrepayment}, + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyDocumentType: "503", + }), + }, + }, + }, +} + +// allowedDocumentTypes is the whitelist of UNTDID 1001 codes permitted on a +// Flow 10 invoice (B2B scope). Kept in sync with the scenarios above. +var allowedDocumentTypes = []cbc.Code{ + "380", "389", "393", "501", + "386", "500", + "384", "471", "472", "473", + "381", "261", "396", "502", "503", +} diff --git a/addons/fr/ctc/flow10/tags.go b/addons/fr/ctc/flow10/tags.go new file mode 100644 index 000000000..b2aabb9da --- /dev/null +++ b/addons/fr/ctc/flow10/tags.go @@ -0,0 +1,34 @@ +package flow10 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/tax" +) + +// French CTC Flow 10 tag keys. Absence of a tag means the document is B2B. +const ( + // TagB2C marks a document as reporting a business-to-consumer transaction. + // Applied to both bill.Invoice and bill.Payment, it switches Flow 10 + // validation into the B2C rule set (no customer SIREN required, etc.). + TagB2C cbc.Key = "b2c" +) + +var b2cTagDef = &cbc.Definition{ + Key: TagB2C, + Name: i18n.String{ + i18n.EN: "B2C", + i18n.FR: "B2C", + }, +} + +var invoiceTags = &tax.TagSet{ + Schema: bill.ShortSchemaInvoice, + List: []*cbc.Definition{b2cTagDef}, +} + +var paymentTags = &tax.TagSet{ + Schema: bill.ShortSchemaPayment, + List: []*cbc.Definition{b2cTagDef}, +} diff --git a/addons/fr/ctc/bill.go b/addons/fr/ctc/flow2/bill.go similarity index 99% rename from addons/fr/ctc/bill.go rename to addons/fr/ctc/flow2/bill.go index eeaf38039..2c5f5c7db 100644 --- a/addons/fr/ctc/bill.go +++ b/addons/fr/ctc/flow2/bill.go @@ -1,4 +1,4 @@ -package ctc +package flow2 import ( "regexp" diff --git a/addons/fr/ctc/bill_invoices.go b/addons/fr/ctc/flow2/bill_invoices.go similarity index 99% rename from addons/fr/ctc/bill_invoices.go rename to addons/fr/ctc/flow2/bill_invoices.go index 11eeb7907..7d9a33620 100644 --- a/addons/fr/ctc/bill_invoices.go +++ b/addons/fr/ctc/flow2/bill_invoices.go @@ -1,4 +1,4 @@ -package ctc +package flow2 import ( "slices" diff --git a/addons/fr/ctc/bill_test.go b/addons/fr/ctc/flow2/bill_test.go similarity index 99% rename from addons/fr/ctc/bill_test.go rename to addons/fr/ctc/flow2/bill_test.go index 2556bb2cb..1cea3069f 100644 --- a/addons/fr/ctc/bill_test.go +++ b/addons/fr/ctc/flow2/bill_test.go @@ -1,9 +1,9 @@ -package ctc_test +package flow2_test import ( "testing" - "github.com/invopop/gobl/addons/fr/ctc" + ctc "github.com/invopop/gobl/addons/fr/ctc/flow2" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/catalogues/iso" @@ -24,7 +24,7 @@ func testInvoiceB2BStandard(t *testing.T) *bill.Invoice { t.Helper() i := &bill.Invoice{ Regime: tax.WithRegime("FR"), - Addons: tax.WithAddons(ctc.Flow2V1), + Addons: tax.WithAddons(ctc.V1), Code: "FAC-2024-001", Currency: "EUR", Type: bill.InvoiceTypeStandard, @@ -2016,7 +2016,7 @@ func TestFinalInvoiceTypes(t *testing.T) { } func TestInvoiceNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) t.Run("normalizes invoice with existing tax", func(t *testing.T) { inv := testInvoiceB2BStandard(t) @@ -2294,7 +2294,7 @@ func TestDeliveryAndTotalsValidation(t *testing.T) { func withAddonContext() rules.WithContext { return func(rc *rules.Context) { - rc.Set(rules.ContextKey(ctc.Flow2V1), tax.AddonForKey(ctc.Flow2V1)) + rc.Set(rules.ContextKey(ctc.V1), tax.AddonForKey(ctc.V1)) } } diff --git a/addons/fr/ctc/extensions.go b/addons/fr/ctc/flow2/extensions.go similarity index 99% rename from addons/fr/ctc/extensions.go rename to addons/fr/ctc/flow2/extensions.go index 6c7bc5bb1..2d046b16b 100644 --- a/addons/fr/ctc/extensions.go +++ b/addons/fr/ctc/flow2/extensions.go @@ -1,4 +1,4 @@ -package ctc +package flow2 import ( "github.com/invopop/gobl/cbc" diff --git a/addons/fr/ctc/ctc.go b/addons/fr/ctc/flow2/flow2.go similarity index 91% rename from addons/fr/ctc/ctc.go rename to addons/fr/ctc/flow2/flow2.go index 18f67c882..c8cd727ea 100644 --- a/addons/fr/ctc/ctc.go +++ b/addons/fr/ctc/flow2/flow2.go @@ -1,6 +1,6 @@ -// Package ctc handles the extensions and validation rules for the French +// Package flow2 handles the extensions and validation rules for the French // CTC (Cycle de Traitement de la Commande) Flow 2 B2B e-invoicing requirements. -package ctc +package flow2 import ( "github.com/invopop/gobl/addons/eu/en16931" @@ -15,22 +15,22 @@ import ( ) const ( - // Flow2Key identifies the French CTC Flow 2 addon family. Individual + // Key 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 cbc.Key = "fr-ctc-flow2" - // Flow2V1 is the key for the French CTC addon - Flow2V1 cbc.Key = Flow2Key + "-v1" + // V1 is the key for the French CTC Flow 2 addon + V1 cbc.Key = Key + "-v1" ) func init() { tax.RegisterAddonDef(newAddon()) rules.RegisterWithGuard( - Flow2Key.String(), + Key.String(), rules.GOBL.Add("FR-CTC-FLOW2"), - is.InContext(tax.AddonIn(Flow2V1)), + is.InContext(tax.AddonIn(V1)), billInvoiceRules(), orgPartyRules(), orgIdentityRules(), @@ -41,7 +41,7 @@ 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", diff --git a/addons/fr/ctc/org.go b/addons/fr/ctc/flow2/org.go similarity index 99% rename from addons/fr/ctc/org.go rename to addons/fr/ctc/flow2/org.go index d701dd491..672f36a1c 100644 --- a/addons/fr/ctc/org.go +++ b/addons/fr/ctc/flow2/org.go @@ -1,4 +1,4 @@ -package ctc +package flow2 import ( "regexp" diff --git a/addons/fr/ctc/org_party.go b/addons/fr/ctc/flow2/org_party.go similarity index 99% rename from addons/fr/ctc/org_party.go rename to addons/fr/ctc/flow2/org_party.go index ff0eb0e12..92b319357 100644 --- a/addons/fr/ctc/org_party.go +++ b/addons/fr/ctc/flow2/org_party.go @@ -1,4 +1,4 @@ -package ctc +package flow2 import ( "errors" diff --git a/addons/fr/ctc/org_test.go b/addons/fr/ctc/flow2/org_test.go similarity index 91% rename from addons/fr/ctc/org_test.go rename to addons/fr/ctc/flow2/org_test.go index fb7b02f09..4896808d0 100644 --- a/addons/fr/ctc/org_test.go +++ b/addons/fr/ctc/flow2/org_test.go @@ -1,10 +1,10 @@ -package ctc_test +package flow2_test import ( "strings" "testing" - "github.com/invopop/gobl/addons/fr/ctc" + ctc "github.com/invopop/gobl/addons/fr/ctc/flow2" "github.com/invopop/gobl/catalogues/iso" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" @@ -166,7 +166,7 @@ func TestElectronicAddressValidation(t *testing.T) { } func TestPeppolKeyNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) t.Run("peppol key set on SIREN inbox when none exist", func(t *testing.T) { party := &org.Party{ @@ -382,7 +382,7 @@ func TestIdentitySchemeFormatValidation(t *testing.T) { } func TestPrivateIDNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) t.Run("private-id key sets ISO scheme ID 0224", func(t *testing.T) { party := &org.Party{ @@ -449,7 +449,7 @@ func TestPrivateIDNormalization(t *testing.T) { } func TestSIRENGenerationFromSIRET(t *testing.T) { - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) t.Run("generated SIREN from SIRET", func(t *testing.T) { party := &org.Party{ @@ -598,7 +598,7 @@ 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(ctc.V1) ad.Normalizer((*org.Party)(nil)) // Should not crash }) @@ -607,7 +607,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { party := &org.Party{ Name: "Test Party", } - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) ad.Normalizer(party) assert.Len(t, party.Identities, 0) }) @@ -624,7 +624,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) ad.Normalizer(party) // Should have generated SIREN from SIRET, plus the original SIRET, plus 1 nil @@ -664,7 +664,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) ad.Normalizer(party) // Should have generated SIREN @@ -703,7 +703,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) ad.Normalizer(party) // Should not generate duplicate SIREN @@ -726,7 +726,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) ad.Normalizer(party) assert.Equal(t, cbc.Key("peppol"), party.Inboxes[0].Key) }) @@ -746,7 +746,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) 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) @@ -766,7 +766,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { nilInbox, // Another nil for good measure }, } - ad := tax.AddonForKey(ctc.Flow2V1) + ad := tax.AddonForKey(ctc.V1) ad.Normalizer(party) // Should still have 3 elements (2 nils + 1 valid inbox) @@ -902,3 +902,78 @@ func TestValidateInboxEdgeCases(t *testing.T) { assert.NoError(t, err) }) } + +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/tags.go b/addons/fr/ctc/flow2/tags.go similarity index 98% rename from addons/fr/ctc/tags.go rename to addons/fr/ctc/flow2/tags.go index 4ca3760e5..5253595ec 100644 --- a/addons/fr/ctc/tags.go +++ b/addons/fr/ctc/flow2/tags.go @@ -1,4 +1,4 @@ -package ctc +package flow2 import ( "github.com/invopop/gobl/bill" diff --git a/addons/fr/ctc/flow6/bill_status.go b/addons/fr/ctc/flow6/bill_status.go new file mode 100644 index 000000000..61c3a4440 --- /dev/null +++ b/addons/fr/ctc/flow6/bill_status.go @@ -0,0 +1,363 @@ +package flow6 + +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" +) + +// schemeIDSIREN is the ISO/IEC 6523 scheme for SIREN identities. +const schemeIDSIREN = "0002" + +func normalizeStatus(st *bill.Status) { + if st == nil { + return + } + // Default Type from the first line's Key — each Flow 6 line key has + // exactly one associated Status.Type in the process table. + if st.Type == "" { + for _, line := range st.Lines { + if line == nil { + continue + } + if typ, ok := statusTypeForKey(line.Key); ok { + st.Type = typ + break + } + } + } + // Default party role for the two structural slots. Issuer and + // Recipient are left untouched: their role is context-dependent. + setPartyRoleDefault(st.Supplier, RoleSE) + setPartyRoleDefault(st.Customer, RoleBY) +} + +func setPartyRoleDefault(p *org.Party, role cbc.Code) { + if p == nil || p.Ext.Get(ExtKeyRole) != "" { + return + } + p.Ext = p.Ext.Set(ExtKeyRole, role) +} + +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 on Flow 6 status messages", + is.Present, + ), + rules.Assert("03", "supplier must have an identity with ISO/IEC 6523 scheme 0002 (SIREN)", + is.Func("supplier has SIREN", partyHasSIRENIdentity), + ), + ), + rules.Field("lines", + rules.Assert("04", "at least one status line is required", + is.Present, + ), + 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("07", "status line with key 'paid' (CDAR 212) must carry a Characteristic complement with Amount (value + currency) set — this is the MEN", + is.Func("amount received set when paid", statusLinePaidHasAmount), + ), + 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: MEN, MPA, RAP, ESC, RAB, REM, MAP, MAPTTC, MNA, MNATTC, CBB, DIV, DVA, MAJ", + is.Func("characteristic type code known", statusLineTypeCodesKnown), + ), + ), + ), + rules.Assert("08", "Status.Type must match the Type implied by each StatusLine.Key", + is.Func("status type consistent with line keys", statusTypeMatchesLines), + ), + ) +} + +func partyHasSIRENIdentity(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() == schemeIDSIREN { + return true + } + } + return false +} + +func statusLineKeyKnown(v any) bool { + line, ok := v.(*bill.StatusLine) + if !ok || line == nil { + return false + } + _, ok = statusTypeForKey(line.Key) + return ok +} + +// statusLinePaidHasAmount checks that a paid StatusLine carries a +// Characteristic complement with TypeCode=MEN and Amount populated +// (both value and currency). Other payment-related TypeCodes (MPA, +// RAP, etc.) may coexist on the same line but do not substitute for +// the mandatory MEN. +func statusLinePaidHasAmount(v any) bool { + line, ok := v.(*bill.StatusLine) + if !ok || line == nil { + return true + } + if line.Key != bill.StatusEventPaid { + return true + } + 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. An unset ReasonCode is allowed. +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. The 501 "IRRECEVABLE" status +// from the CSV is not in our process table (it's PPF-ingress-only) and +// is deliberately omitted — if we ever model it, add it here. +var reasonRequiredStatusKeys = []cbc.Key{ + bill.StatusEventRejected, + bill.StatusEventError, + StatusEventDisputed, + StatusEventPartiallyAccepted, + StatusEventSuspended, +} + +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. The extension carries the +// exact CDAR ReasonCode; the Key is the bucket. +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 + } + // Key may be empty when normalization has not yet run; the + // normalizer fills it from the ext. + 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/flow6/bill_status_test.go b/addons/fr/ctc/flow6/bill_status_test.go new file mode 100644 index 000000000..260e27116 --- /dev/null +++ b/addons/fr/ctc/flow6/bill_status_test.go @@ -0,0 +1,430 @@ +package flow6 + +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 -------------------------------------------------------------- + +// addonContext activates the Flow 6 rule guard so the addon's validators +// fire even for standalone objects (bill.Reason / org.Party) that do not +// carry an addon themselves. +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) +} + +func frPartyWithSIREN() *org.Party { + return &org.Party{ + Name: "Test Platform SARL", + Identities: []*org.Identity{ + { + Code: "356000000", + Ext: tax.ExtensionsOf(tax.ExtMap{ + iso.ExtKeySchemeID: "0002", + }), + }, + }, + } +} + +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: frPartyWithSIREN(), + 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) + assert.Equal(t, RoleSE, st.Supplier.Ext.Get(ExtKeyRole)) +} + +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 + runNormalize(t, st) + err := rules.Validate(st) + assert.ErrorContains(t, err, "SIREN") +} + +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 match") +} + +// --- 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 = StatusEventSuspended + 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 + 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 + 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 + 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 + 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(tax.ExtMap{ExtKeyReasonCode: "ART_ERR"}), + }} + obj, err := schema.NewObject(&Characteristic{ + ReasonCode: "QTE_ERR", // not matching any sibling reason + 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.ReasonKeyItems, + Ext: tax.ExtensionsOf(tax.ExtMap{ExtKeyReasonCode: "ART_ERR"}), + }} + obj, err := schema.NewObject(&Characteristic{ + ReasonCode: "ART_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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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 TestPartyHasSIRENIdentityWrongType(t *testing.T) { + assert.False(t, partyHasSIRENIdentity("not a party")) +} + +func TestPartyHasSIRENIdentityNilParty(t *testing.T) { + assert.False(t, partyHasSIRENIdentity((*org.Party)(nil))) +} + +func TestPartyHasSIRENIdentityWithoutExt(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{{Code: "X"}}} + assert.False(t, partyHasSIRENIdentity(p)) +} + +func TestStatusLineKeyKnownWrongType(t *testing.T) { + assert.False(t, statusLineKeyKnown("x")) +} + +func TestStatusLinePaidHasAmountWrongType(t *testing.T) { + assert.True(t, statusLinePaidHasAmount(42)) +} + +func TestStatusLinePaidHasAmountNonPaidLine(t *testing.T) { + assert.True(t, statusLinePaidHasAmount(&bill.StatusLine{Key: bill.StatusEventAccepted})) +} + +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")) +} diff --git a/addons/fr/ctc/flow6/codes.go b/addons/fr/ctc/flow6/codes.go new file mode 100644 index 000000000..fc523cbc0 --- /dev/null +++ b/addons/fr/ctc/flow6/codes.go @@ -0,0 +1,207 @@ +package flow6 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" +) + +// Extended bill.StatusLine.Key values added by Flow 6 so every CDAR +// ProcessConditionCode maps 1:1 to a (key, type) pair. GOBL ships the +// "plain" keys (issued, processing, accepted, rejected, paid, error); +// the ones marked here are France-specific additions needed for CDAR. +const ( + StatusEventIssuedByPlatform cbc.Key = "issued-by-platform" + StatusEventReceivedByPlatform cbc.Key = "received-by-platform" + StatusEventMadeAvailable cbc.Key = "made-available" + StatusEventPartiallyAccepted cbc.Key = "partially-accepted" + StatusEventDisputed cbc.Key = "disputed" + StatusEventSuspended cbc.Key = "suspended" + StatusEventCompleted cbc.Key = "completed" + StatusEventPaymentForwarded cbc.Key = "payment-forwarded" +) + +// processEntry pairs a bill.StatusLine.Key with the bill.Status.Type it +// implies. For Flow 6 the pair is always fixed per key: the Type is a +// property of the CDAR code, not a disambiguator. +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"}, + {StatusEventIssuedByPlatform, bill.StatusTypeUpdate, "201"}, + {StatusEventReceivedByPlatform, 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"}, + {StatusEventSuspended, bill.StatusTypeResponse, "208"}, + {StatusEventCompleted, bill.StatusTypeResponse, "209"}, + {bill.StatusEventRejected, bill.StatusTypeResponse, "210"}, + {StatusEventPaymentForwarded, 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. Returns ("", false) if the pair is +// unknown or the Type does not match the fixed Type for the key. +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. Returns ("", "", false) if the code is unknown. +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 fixed Status.Type associated with a +// StatusLine.Key for Flow 6. The second return is false if the key has +// no CDAR entry. +func statusTypeForKey(key cbc.Key) (cbc.Key, bool) { + for _, e := range processTable { + if e.Key == key { + return e.Type, 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 (see CDARReasonCodeFor). +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. Used on generate when the caller did not pin an +// exact code via Reason.Ext["fr-ctc-reason-code"]. +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. Used on parse and by the normalizer to fill Reason.Key +// from the extension. +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}, +} + +// 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/flow6/codes_test.go b/addons/fr/ctc/flow6/codes_test.go new file mode 100644 index 000000000..82529106b --- /dev/null +++ b/addons/fr/ctc/flow6/codes_test.go @@ -0,0 +1,361 @@ +package flow6 + +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", StatusEventIssuedByPlatform, bill.StatusTypeUpdate) +} + +func TestProcessCode202ReceivedByPlatform(t *testing.T) { + assertProcessRoundTrip(t, "202", StatusEventReceivedByPlatform, 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 TestProcessCode208Suspended(t *testing.T) { + assertProcessRoundTrip(t, "208", StatusEventSuspended, 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 TestProcessCode211PaymentForwarded(t *testing.T) { + assertProcessRoundTrip(t, "211", StatusEventPaymentForwarded, bill.StatusTypeUpdate) +} + +func TestProcessCode212Paid(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) { + // paid is a response code; querying with Type=update must miss. + _, ok := CDARProcessCodeFor(bill.StatusEventPaid, bill.StatusTypeUpdate) + assert.False(t, ok) +} + +// 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) +} + +// Business-rejection reasons --------------------------------------------- + +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) +} + +// Technical / platform rejection reasons (code 213) --------------------- + +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) +} + +// Default-for-key: one per bucket with codes. + +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) +} + +// --- Internal helper coverage ------------------------------------------- + +func TestStatusTypeForKeyUnknown(t *testing.T) { + _, ok := statusTypeForKey("unknown") + assert.False(t, ok) +} diff --git a/addons/fr/ctc/flow6/complements.go b/addons/fr/ctc/flow6/complements.go new file mode 100644 index 000000000..85b7b26af --- /dev/null +++ b/addons/fr/ctc/flow6/complements.go @@ -0,0 +1,116 @@ +package flow6 + +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. Used by CDAR to + // correlate a correction with a previously reported field. + ID string `json:"id,omitempty" jsonschema:"title=ID"` + + // TypeCode is the CDAR CharacteristicTypeCode. See the TypeCode* + // constants for reserved values Flow 6 interprets directly. + 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. Only meaningful on + // rejection / dispute / partial-acceptance lines. + 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 — typically + // "+" or "-" when Changed is true. + 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. Used + // for the MEN on paid lines and for any price/total correction. + 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). The list comes from the +// French CTC Flow 6 specification; additional codes may be added as +// the spec evolves. +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/flow6/extensions.go b/addons/fr/ctc/flow6/extensions.go new file mode 100644 index 000000000..f5fa706ef --- /dev/null +++ b/addons/fr/ctc/flow6/extensions.go @@ -0,0 +1,114 @@ +package flow6 + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/tax" +) + +// Flow 6 extension keys. +const ( + // ExtKeyRole carries the CDAR RoleCode for a party (UNCL 3035 subset). + // Applied per populated party (Supplier / Customer / Issuer / Recipient) + // on a bill.Status message. + ExtKeyRole cbc.Key = "fr-ctc-role" + + // ExtKeyReasonCode pins the exact CDAR ReasonCode for a bill.Reason. + // When set, takes precedence over the default_for_key lookup that the + // converter would otherwise perform from Reason.Key. + ExtKeyReasonCode cbc.Key = "fr-ctc-reason-code" +) + +// Flow 6 party role codes (UNCL 3035 subset accepted by CDAR). +const ( + RoleSE cbc.Code = "SE" // Seller + RoleBY cbc.Code = "BY" // Buyer + RoleWK cbc.Code = "WK" // Work/Service receiver + RoleDFH cbc.Code = "DFH" // Delivery from + RoleAB cbc.Code = "AB" // Bank + RoleSR cbc.Code = "SR" // Sender / issuer on behalf of + RoleDL cbc.Code = "DL" // Dealer / intermediary + RolePE cbc.Code = "PE" // Payee + RolePR cbc.Code = "PR" // Payer + RoleII cbc.Code = "II" // Issuer of invoice + RoleIV cbc.Code = "IV" // Invoicee +) + +var extensions = []*cbc.Definition{ + { + 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 for each + populated party on a Flow 6 lifecycle message. The normalizer + fills the obvious defaults (Supplier → SE, Customer → BY) + and leaves the rest for the caller to set explicitly. + `), + }, + Values: []*cbc.Definition{ + {Code: RoleSE, Name: i18n.String{i18n.EN: "Seller"}}, + {Code: RoleBY, Name: i18n.String{i18n.EN: "Buyer"}}, + {Code: RoleWK, Name: i18n.String{i18n.EN: "Work / Service Receiver"}}, + {Code: RoleDFH, Name: i18n.String{i18n.EN: "Delivery From"}}, + {Code: RoleAB, Name: i18n.String{i18n.EN: "Bank"}}, + {Code: RoleSR, Name: i18n.String{i18n.EN: "Sender"}}, + {Code: RoleDL, Name: i18n.String{i18n.EN: "Dealer"}}, + {Code: RolePE, Name: i18n.String{i18n.EN: "Payee"}}, + {Code: RolePR, Name: i18n.String{i18n.EN: "Payer"}}, + {Code: RoleII, Name: i18n.String{i18n.EN: "Issuer of Invoice"}}, + {Code: RoleIV, Name: i18n.String{i18n.EN: "Invoicee"}}, + }, + }, + { + 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(), + }, +} + +// 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 +} diff --git a/addons/fr/ctc/flow6/extensions_test.go b/addons/fr/ctc/flow6/extensions_test.go new file mode 100644 index 000000000..b55ac3753 --- /dev/null +++ b/addons/fr/ctc/flow6/extensions_test.go @@ -0,0 +1,26 @@ +package flow6 + +import ( + "testing" + + "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(tax.ExtMap{"k": "v"}) + assert.False(t, extValue(e).IsZero()) +} + +func TestExtValueFromPointer(t *testing.T) { + e := tax.ExtensionsOf(tax.ExtMap{"k": "v"}) + assert.False(t, extValue(&e).IsZero()) +} diff --git a/addons/fr/ctc/flow6/flow6.go b/addons/fr/ctc/flow6/flow6.go new file mode 100644 index 000000000..89c7ae319 --- /dev/null +++ b/addons/fr/ctc/flow6/flow6.go @@ -0,0 +1,99 @@ +// Package flow6 handles the extensions, validations and normalization +// for the French CTC Flow 6 — CDV (cycle de vie) lifecycle statuses +// exchanged between PAs (plateformes agréées) for B2B invoices. +// +// The addon is standalone: it does not require fr-ctc-flow2-v1. It +// operates on bill.Status documents, carries the codebooks needed for +// the gobl.cii CDAR round-trip, and validates the subset of (key, type) +// / reason / action / role combinations that Flow 6 accepts. +package flow6 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "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 ( + // Key identifies the French CTC Flow 6 addon family. + Key cbc.Key = "fr-ctc-flow6" + + // V1 is the key for the French CTC Flow 6 addon. + V1 cbc.Key = Key + "-v1" +) + +func init() { + tax.RegisterAddonDef(newAddon()) + schema.Register(schema.GOBL.Add("addons/fr/ctc/flow6"), + Characteristic{}, + ) + rules.RegisterWithGuard( + Key.String(), + rules.GOBL.Add("FR-CTC-FLOW6"), + is.InContext(tax.AddonIn(V1)), + billStatusRules(), + billReasonRules(), + billActionRules(), + orgPartyRules(), + ) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V1, + Name: i18n.String{ + i18n.EN: "France CTC Flow 6", + i18n.FR: "France CTC Flux 6", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Support for the French CTC (Continuous Transaction Control) + Flow 6 lifecycle messages (Cycle de Vie) exchanged between + registered platforms (plateformes agréées) for B2B invoices. + + This addon operates on bill.Status documents. It carries the + code tables (ProcessConditionCode, ReasonCode, RequestedAction, + RoleCode) that the gobl.cii CDAR converter reads to round-trip + to and from the French PPF XML, and validates the subset of + (key, type) / reason / action / role combinations that Flow 6 + accepts. + + It does not depend on Flow 2: a platform may report lifecycle + events for any compliant invoice, whether or not the invoice + itself went through the Flow 2 B2B clearance path. + `), + }, + Sources: []*cbc.Source{ + { + Title: i18n.String{ + i18n.EN: "External Specifications", + i18n.FR: "Spécifications Externes", + }, + URL: "https://www.impots.gouv.fr/specifications-externes-b2b", + }, + }, + Extensions: extensions, + Normalizer: normalize, + } +} + +func normalize(doc any) { + switch obj := doc.(type) { + case *bill.Status: + normalizeStatus(obj) + case *bill.Reason: + normalizeReason(obj) + case *org.Party: + // Party-level normalization handles the case where a party is + // processed in isolation (e.g. through a direct tax.Normalize call); + // the status-level normalizer applies the contextual role defaults + // because those depend on which slot the party occupies. + _ = obj + } +} diff --git a/addons/fr/ctc/flow6/org_party.go b/addons/fr/ctc/flow6/org_party.go new file mode 100644 index 000000000..5bbd14403 --- /dev/null +++ b/addons/fr/ctc/flow6/org_party.go @@ -0,0 +1,74 @@ +package flow6 + +import ( + "slices" + + "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" +) + +// allowedRoleCodes is the UNCL 3035 subset that the fr-ctc-role +// extension accepts, kept in sync with the extension definition. +var allowedRoleCodes = []cbc.Code{ + RoleSE, RoleBY, RoleWK, RoleDFH, RoleAB, RoleSR, + RoleDL, RolePE, RolePR, RoleII, RoleIV, +} + +// allowedIdentitySchemes is the ICD 6523 subset CDAR accepts on the +// Flow 6 party identities — SIREN plus the commonly used foreign +// identifier schemes. Parties with identities outside this set should +// not be reported in a Flow 6 CDV. +var allowedIdentitySchemes = []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 +} + +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.Each( + rules.Field("ext", + rules.Assert("02", "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", + is.Func("scheme in allowed set", partyIdentitySchemeAllowed), + ), + ), + ), + ), + ) +} + +func partyRoleKnown(v any) bool { + ext := extValue(v) + role := ext.Get(ExtKeyRole) + if role == "" { + return true + } + return slices.Contains(allowedRoleCodes, role) +} + +func partyIdentitySchemeAllowed(v any) bool { + ext := extValue(v) + if ext.IsZero() { + return true + } + scheme := ext.Get(iso.ExtKeySchemeID).String() + if scheme == "" { + return true + } + return slices.Contains(allowedIdentitySchemes, scheme) +} diff --git a/addons/fr/ctc/flow6/org_party_test.go b/addons/fr/ctc/flow6/org_party_test.go new file mode 100644 index 000000000..6dff9473c --- /dev/null +++ b/addons/fr/ctc/flow6/org_party_test.go @@ -0,0 +1,56 @@ +package flow6 + +import ( + "testing" + + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestPartyUnknownRoleRejected(t *testing.T) { + p := &org.Party{ + Name: "Agent", + Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{ExtKeyRole: RoleWK}), + } + assert.NoError(t, rules.Validate(p, addonContext())) +} + +func TestPartyUnknownIdentitySchemeRejected(t *testing.T) { + p := &org.Party{ + Name: "Agent", + Identities: []*org.Identity{{ + Code: "X", + Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "9999"}), + }}, + } + err := rules.Validate(p, addonContext()) + assert.ErrorContains(t, err, "ICD 6523") +} + +func TestPartyIdentityWithoutSchemeAccepted(t *testing.T) { + p := &org.Party{ + Name: "Agent", + Identities: []*org.Identity{{Code: "X"}}, + } + assert.NoError(t, rules.Validate(p, addonContext())) +} + +// --- Internal helpers --------------------------------------------------- + +func TestPartyIdentitySchemeAllowedEmptyScheme(t *testing.T) { + // An Ext without the scheme ID key falls through the scheme check. + e := tax.ExtensionsOf(tax.ExtMap{"some-other": "x"}) + assert.True(t, partyIdentitySchemeAllowed(e)) +} 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/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 793ff3e70..bce05e4d7 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/advance", "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/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/flow6/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/advance", "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/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, From f5609397e61478f07ad0982966e6aba763f7841c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 08:14:32 +0000 Subject: [PATCH 02/26] Add flow10 / flow6 examples and exact-one-line status rule - Five GOBL example documents covering the new addons: - Flow 10 B2B invoice, B2C invoice, B2B payment - Flow 6 accepted status, paid status with MEN complement - Flow 6 now enforces exactly one StatusLine per bill.Status (CDAR carries a single status per CDV message) - Tighten Flow 2 description copy Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow2/flow2.go | 11 -- addons/fr/ctc/flow6/bill_status.go | 16 ++- addons/fr/ctc/flow6/bill_status_test.go | 27 ++++ examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml | 52 +++++++ examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml | 47 +++++++ .../fr/out/invoice-fr-fr-ctc-flow10-b2b.json | 127 ++++++++++++++++++ .../fr/out/invoice-fr-fr-ctc-flow10-b2c.json | 111 +++++++++++++++ .../fr/out/payment-fr-fr-ctc-flow10-b2b.json | 92 +++++++++++++ .../out/status-fr-fr-ctc-flow6-accepted.json | 46 +++++++ .../fr/out/status-fr-fr-ctc-flow6-paid.json | 56 ++++++++ examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml | 51 +++++++ .../fr/status-fr-fr-ctc-flow6-accepted.yaml | 21 +++ examples/fr/status-fr-fr-ctc-flow6-paid.yaml | 27 ++++ 13 files changed, 671 insertions(+), 13 deletions(-) create mode 100644 examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml create mode 100644 examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml create mode 100644 examples/fr/out/invoice-fr-fr-ctc-flow10-b2b.json create mode 100644 examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json create mode 100644 examples/fr/out/payment-fr-fr-ctc-flow10-b2b.json create mode 100644 examples/fr/out/status-fr-fr-ctc-flow6-accepted.json create mode 100644 examples/fr/out/status-fr-fr-ctc-flow6-paid.json create mode 100644 examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml create mode 100644 examples/fr/status-fr-fr-ctc-flow6-accepted.yaml create mode 100644 examples/fr/status-fr-fr-ctc-flow6-paid.yaml diff --git a/addons/fr/ctc/flow2/flow2.go b/addons/fr/ctc/flow2/flow2.go index c8cd727ea..b81cc64db 100644 --- a/addons/fr/ctc/flow2/flow2.go +++ b/addons/fr/ctc/flow2/flow2.go @@ -62,11 +62,6 @@ func newAddon() *tax.AddonDef { 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. - - 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. `), i18n.FR: here.Doc(` Support pour le CTC (Contrôle Continu des Transactions) français Flux 2 @@ -81,12 +76,6 @@ func newAddon() *tax.AddonDef { 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. `), }, Sources: []*cbc.Source{ diff --git a/addons/fr/ctc/flow6/bill_status.go b/addons/fr/ctc/flow6/bill_status.go index 61c3a4440..922bd6854 100644 --- a/addons/fr/ctc/flow6/bill_status.go +++ b/addons/fr/ctc/flow6/bill_status.go @@ -60,8 +60,8 @@ func billStatusRules() *rules.Set { ), ), rules.Field("lines", - rules.Assert("04", "at least one status line is required", - is.Present, + rules.Assert("04", "exactly one status line is required (CDAR carries a single status per CDV message)", + is.Func("exactly one line", statusHasExactlyOneLine), ), rules.Each( rules.Field("doc", @@ -102,6 +102,18 @@ func billStatusRules() *rules.Set { ) } +// statusHasExactlyOneLine enforces the CDAR invariant that a CDV +// message carries one and only one status — a single line on the +// bill.Status. Multiple lines would map to multiple CDARs and must be +// split into separate documents. +func statusHasExactlyOneLine(v any) bool { + lines, ok := v.([]*bill.StatusLine) + if !ok { + return false + } + return len(lines) == 1 +} + func partyHasSIRENIdentity(v any) bool { p, ok := v.(*org.Party) if !ok || p == nil { diff --git a/addons/fr/ctc/flow6/bill_status_test.go b/addons/fr/ctc/flow6/bill_status_test.go index 260e27116..d0ed4b804 100644 --- a/addons/fr/ctc/flow6/bill_status_test.go +++ b/addons/fr/ctc/flow6/bill_status_test.go @@ -105,6 +105,33 @@ func TestStatusTypeMismatchRejected(t *testing.T) { assert.ErrorContains(t, err, "Status.Type must match") } +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) { diff --git a/examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml b/examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml new file mode 100644 index 000000000..4d6f7497f --- /dev/null +++ b/examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml @@ -0,0 +1,52 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +$addons: + - "fr-ctc-flow10-v1" +uuid: "5d3aebcf-8b7e-4f5a-9b7e-0d4c83f9e1a1" +currency: "EUR" +issue_date: "2026-04-15" +series: "FAC" +code: "2026-00042" + +supplier: + tax_id: + country: "FR" + code: "732829320" + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + name: "Fournisseur Reporting SARL" + emails: + - addr: "facturation@fournisseur.fr" + addresses: + - street: "12 Rue de la Reforme" + locality: "Paris" + code: "75001" + country: "FR" + +customer: + tax_id: + country: "FR" + code: "356000000" + identities: + - type: "SIREN" + code: "356000000" + ext: + iso-scheme-id: "0002" + name: "Client Reporting SAS" + addresses: + - street: "8 Avenue de l'Opera" + locality: "Paris" + code: "75001" + country: "FR" + +lines: + - quantity: 5 + item: + name: "Prestation de conseil" + price: "200.00" + unit: "h" + taxes: + - cat: VAT + rate: standard 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..c73e973ef --- /dev/null +++ b/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml @@ -0,0 +1,47 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +$addons: + - "fr-ctc-flow10-v1" +$tags: + - "b2c" +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" + +customer: + name: "Client Particulier" + addresses: + - street: "4 Rue Lafayette" + locality: "Lyon" + code: "69001" + country: "FR" + +lines: + - quantity: 1 + item: + name: "Article de boutique" + price: "59.00" + taxes: + - cat: VAT + rate: standard diff --git a/examples/fr/out/invoice-fr-fr-ctc-flow10-b2b.json b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2b.json new file mode 100644 index 000000000..91b57a2d4 --- /dev/null +++ b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2b.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "fbe1f4fb4308c1b02d5f559246131f0a9658a7cd96584c45d26f5299fe28c5ba" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "FR", + "$addons": [ + "fr-ctc-flow10-v1" + ], + "uuid": "5d3aebcf-8b7e-4f5a-9b7e-0d4c83f9e1a1", + "type": "standard", + "series": "FAC", + "code": "2026-00042", + "issue_date": "2026-04-15", + "currency": "EUR", + "tax": { + "ext": { + "fr-ctc-billing-mode": "M1", + "untdid-document-type": "380" + } + }, + "supplier": { + "name": "Fournisseur Reporting SARL", + "tax_id": { + "country": "FR", + "code": "44732829320" + }, + "identities": [ + { + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "addresses": [ + { + "street": "12 Rue de la Reforme", + "locality": "Paris", + "code": "75001", + "country": "FR" + } + ], + "emails": [ + { + "addr": "facturation@fournisseur.fr" + } + ] + }, + "customer": { + "name": "Client Reporting SAS", + "tax_id": { + "country": "FR", + "code": "39356000000" + }, + "identities": [ + { + "type": "SIREN", + "code": "356000000", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "addresses": [ + { + "street": "8 Avenue de l'Opera", + "locality": "Paris", + "code": "75001", + "country": "FR" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "5", + "item": { + "name": "Prestation de conseil", + "price": "200.00", + "unit": "h" + }, + "sum": "1000.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "20%" + } + ], + "total": "1000.00" + } + ], + "totals": { + "sum": "1000.00", + "total": "1000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1000.00", + "percent": "20%", + "amount": "200.00" + } + ], + "amount": "200.00" + } + ], + "sum": "200.00" + }, + "tax": "200.00", + "total_with_tax": "1200.00", + "payable": "1200.00" + } + } +} \ No newline at end of file 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..187b79916 --- /dev/null +++ b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "fc1e96ec7f527ed114e719694a9abcdce3c19457ee7ef950b3c294d61ad68901" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "FR", + "$addons": [ + "fr-ctc-flow10-v1" + ], + "$tags": [ + "b2c" + ], + "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": [ + { + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "addresses": [ + { + "street": "20 Rue du Commerce", + "locality": "Paris", + "code": "75015", + "country": "FR" + } + ] + }, + "customer": { + "name": "Client Particulier", + "addresses": [ + { + "street": "4 Rue Lafayette", + "locality": "Lyon", + "code": "69001", + "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" + } + ], + "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-fr-ctc-flow10-b2b.json b/examples/fr/out/payment-fr-fr-ctc-flow10-b2b.json new file mode 100644 index 000000000..09bae145e --- /dev/null +++ b/examples/fr/out/payment-fr-fr-ctc-flow10-b2b.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "eab4d3ed16ca4ea595ca43f716eec2d7fff6e72cf7c3b675c1bb3ecd2791b251" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/payment", + "$regime": "FR", + "$addons": [ + "fr-ctc-flow10-v1" + ], + "uuid": "1f4d3e8a-9b6c-4d2e-8e7f-7a3c4d5e6f01", + "type": "receipt", + "method": { + "key": "credit-transfer", + "credit_transfer": [ + { + "iban": "FR7630006000011234567890189", + "name": "Fournisseur Reporting SARL" + } + ] + }, + "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": [ + { + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ] + }, + "customer": { + "name": "Client Reporting SAS", + "tax_id": { + "country": "FR", + "code": "39356000000" + }, + "identities": [ + { + "type": "SIREN", + "code": "356000000", + "ext": { + "iso-scheme-id": "0002" + } + } + ] + }, + "lines": [ + { + "i": 1, + "document": { + "issue_date": "2026-04-15", + "code": "2026-00042" + }, + "amount": "1200.00", + "tax": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "base": "1000.00", + "percent": "20%", + "amount": "200.00" + } + ], + "amount": "200.00" + } + ], + "sum": "200.00" + } + } + ], + "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..3dd6aed5a --- /dev/null +++ b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "e231e613bbb31ad364cd5b9815c69193b739eb5977cbbf533d0e985ebe2b3cfc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/status", + "$addons": [ + "fr-ctc-flow6-v1" + ], + "uuid": "2a5b6c7d-8e9f-4a1b-2c3d-4e5f6a7b8c01", + "type": "response", + "issue_date": "2026-04-16", + "code": "STA-2026-0001", + "supplier": { + "name": "Plateforme Agreee SARL", + "identities": [ + { + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "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..3a8ae04fd --- /dev/null +++ b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "ed8422596919262af603b76fb2038d9d7f60d4440af7c8abfa6793bd07b3aee2" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/status", + "$addons": [ + "fr-ctc-flow6-v1" + ], + "uuid": "3b6c7d8e-9f0a-4b2c-3d4e-5f6a7b8c9d01", + "type": "response", + "issue_date": "2026-05-02", + "code": "STA-2026-0002", + "supplier": { + "name": "Plateforme Agreee SARL", + "identities": [ + { + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "ext": { + "fr-ctc-role": "SE" + } + }, + "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/flow6/characteristic", + "type_code": "MEN", + "amount": { + "currency": "EUR", + "value": "1200.00" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml b/examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml new file mode 100644 index 000000000..9b9f33385 --- /dev/null +++ b/examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml @@ -0,0 +1,51 @@ +$schema: "https://gobl.org/draft-0/bill/payment" +$addons: + - "fr-ctc-flow10-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" + +method: + 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: "FR" + code: "356000000" + identities: + - type: "SIREN" + code: "356000000" + ext: + iso-scheme-id: "0002" + name: "Client Reporting SAS" + +lines: + - document: + code: "2026-00042" + 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..1d6c294f5 --- /dev/null +++ b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml @@ -0,0 +1,21 @@ +$schema: "https://gobl.org/draft-0/bill/status" +$addons: + - "fr-ctc-flow6-v1" +uuid: "2a5b6c7d-8e9f-4a1b-2c3d-4e5f6a7b8c01" +issue_date: "2026-04-16" +code: "STA-2026-0001" + +supplier: + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + name: "Plateforme Agreee SARL" + +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..086f0ab63 --- /dev/null +++ b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml @@ -0,0 +1,27 @@ +$schema: "https://gobl.org/draft-0/bill/status" +$addons: + - "fr-ctc-flow6-v1" +uuid: "3b6c7d8e-9f0a-4b2c-3d4e-5f6a7b8c9d01" +issue_date: "2026-05-02" +code: "STA-2026-0002" + +supplier: + identities: + - type: "SIREN" + code: "732829320" + ext: + iso-scheme-id: "0002" + name: "Plateforme Agreee SARL" + +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/flow6/characteristic" + type_code: "MEN" + amount: + currency: "EUR" + value: "1200.00" From d775b7cb3efa858bb83709a745b1e7ff2770979f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 08:18:23 +0000 Subject: [PATCH 03/26] Split flow10 bill.go into bill_invoice.go and bill_payment.go Each source file now matches a single domain: bill_invoice.go owns the invoice rules, normalizers, and shared VAT-key map; bill_payment.go owns the payment rules and helpers. The catch-all bill.go file is removed; tests follow the same split. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow10/bill_invoice.go | 86 +++++++++++++++++ addons/fr/ctc/flow10/bill_invoice_test.go | 14 +++ .../ctc/flow10/{bill.go => bill_payment.go} | 96 +------------------ .../{bill_test.go => bill_payment_test.go} | 14 --- 4 files changed, 103 insertions(+), 107 deletions(-) rename addons/fr/ctc/flow10/{bill.go => bill_payment.go} (51%) rename addons/fr/ctc/flow10/{bill_test.go => bill_payment_test.go} (91%) diff --git a/addons/fr/ctc/flow10/bill_invoice.go b/addons/fr/ctc/flow10/bill_invoice.go index 1d7101070..ee0ea831e 100644 --- a/addons/fr/ctc/flow10/bill_invoice.go +++ b/addons/fr/ctc/flow10/bill_invoice.go @@ -357,3 +357,89 @@ func partyHasTaxIDWhenRequired(v any) bool { } return party.TaxID != nil && party.TaxID.Code != "" } + +// vatKeyToUNTDIDCategory maps each supported GOBL VAT rate key to its +// UNTDID 5305 category code. The Canary Islands (IGIC / "L") and +// Ceuta/Melilla (IPSI / "M") categories are intentionally absent since +// they are not applicable to Flow 10. +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", +} + +func invoiceIsB2C(inv *bill.Invoice) bool { + return inv != nil && inv.Tags.HasTags(TagB2C) +} + +func normalizeInvoice(inv *bill.Invoice) { + if inv == nil { + return + } + normalizeInvoiceTaxCategories(inv) + if invoiceIsB2C(inv) { + normalizeB2CCategoryOnInvoice(inv) + return + } + normalizeParty(inv.Supplier) + normalizeParty(inv.Customer) + normalizeInvoiceBillingMode(inv) +} + +// normalizeB2CCategoryOnInvoice defaults the B2C transaction category to +// TNT1 (not subject to French VAT) when the caller has not supplied one. +// TNT1 is the safest default: it covers B2C sales that would otherwise +// require explicit per-case classification (intra-EU distance sales, +// out-of-scope, etc.), and a user wanting a narrower code must set it +// explicitly. +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. Combos whose key we do not +// map (IGIC / IPSI, or unknown) are left untouched. +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 code, ok := vatKeyToUNTDIDCategory[combo.Key]; ok { + combo.Ext = combo.Ext.Set(untdid.ExtKeyTaxCategory, code) + } + } + } +} + +// normalizeInvoiceBillingMode picks a sensible default for the Flow 10 +// billing-mode extension when the user has not supplied one. We default +// to the Mixed (M) prefix since it is the safest without line-level +// analysis: M2 when the invoice is already paid in full, M1 otherwise. +// The user can override by setting the extension explicitly. +func normalizeInvoiceBillingMode(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) +} diff --git a/addons/fr/ctc/flow10/bill_invoice_test.go b/addons/fr/ctc/flow10/bill_invoice_test.go index 562ee2157..48465f11f 100644 --- a/addons/fr/ctc/flow10/bill_invoice_test.go +++ b/addons/fr/ctc/flow10/bill_invoice_test.go @@ -367,3 +367,17 @@ 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 TestNormalizeInvoiceBillingModeDefaultsM2WhenPaid(t *testing.T) { + due := num.MakeAmount(0, 2) + inv := &bill.Invoice{ + Totals: &bill.Totals{Due: &due}, + Tax: &bill.Tax{}, + } + normalizeInvoiceBillingMode(inv) + assert.Equal(t, BillingModeM2, inv.Tax.Ext.Get(ExtKeyBillingMode)) +} diff --git a/addons/fr/ctc/flow10/bill.go b/addons/fr/ctc/flow10/bill_payment.go similarity index 51% rename from addons/fr/ctc/flow10/bill.go rename to addons/fr/ctc/flow10/bill_payment.go index 56ba0ef6c..d4680bfa3 100644 --- a/addons/fr/ctc/flow10/bill.go +++ b/addons/fr/ctc/flow10/bill_payment.go @@ -2,103 +2,18 @@ package flow10 import ( "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/catalogues/untdid" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/rules" "github.com/invopop/gobl/rules/is" "github.com/invopop/gobl/tax" ) -// vatKeyToUNTDIDCategory maps each supported GOBL VAT rate key to its -// UNTDID 5305 category code. The Canary Islands (IGIC / "L") and -// Ceuta/Melilla (IPSI / "M") categories are intentionally absent since -// they are not applicable to Flow 10. -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", -} - -func invoiceIsB2C(inv *bill.Invoice) bool { - return inv != nil && inv.Tags.HasTags(TagB2C) -} - func paymentIsB2C(pmt *bill.Payment) bool { return pmt != nil && pmt.Tags.HasTags(TagB2C) } -func normalizeInvoice(inv *bill.Invoice) { - if inv == nil { - return - } - normalizeInvoiceTaxCategories(inv) - if invoiceIsB2C(inv) { - normalizeB2CCategoryOnInvoice(inv) - return - } - normalizeParty(inv.Supplier) - normalizeParty(inv.Customer) - normalizeInvoiceBillingMode(inv) -} - -// normalizeB2CCategoryOnInvoice defaults the B2C transaction category to -// TNT1 (not subject to French VAT) when the caller has not supplied one. -// TNT1 is the safest default: it covers B2C sales that would otherwise -// require explicit per-case classification (intra-EU distance sales, -// out-of-scope, etc.), and a user wanting a narrower code must set it -// explicitly. -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. Combos whose key we do not -// map (IGIC / IPSI, or unknown) are left untouched. -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 code, ok := vatKeyToUNTDIDCategory[combo.Key]; ok { - combo.Ext = combo.Ext.Set(untdid.ExtKeyTaxCategory, code) - } - } - } -} - -// normalizeInvoiceBillingMode picks a sensible default for the Flow 10 -// billing-mode extension when the user has not supplied one. We default to -// the Mixed (M) prefix since it is the safest without line-level analysis: -// - M2 when the invoice is already paid in full -// - M1 otherwise -// -// The user can override by setting the extension explicitly. -func normalizeInvoiceBillingMode(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) +func paymentIsB2BAny(v any) bool { + pmt, ok := v.(*bill.Payment) + return ok && !paymentIsB2C(pmt) } func billPaymentRules() *rules.Set { @@ -159,11 +74,6 @@ func billPaymentRules() *rules.Set { ) } -func paymentIsB2BAny(v any) bool { - pmt, ok := v.(*bill.Payment) - return ok && !paymentIsB2C(pmt) -} - // paymentVATRatesAllowed reports whether every VAT rate total on the // payment's lines matches one of the G1.24 whitelist percentages. func paymentVATRatesAllowed(v any) bool { diff --git a/addons/fr/ctc/flow10/bill_test.go b/addons/fr/ctc/flow10/bill_payment_test.go similarity index 91% rename from addons/fr/ctc/flow10/bill_test.go rename to addons/fr/ctc/flow10/bill_payment_test.go index 8d08fdd87..6c8cef6c8 100644 --- a/addons/fr/ctc/flow10/bill_test.go +++ b/addons/fr/ctc/flow10/bill_payment_test.go @@ -163,17 +163,3 @@ func TestPaymentVATRatesAllowedNilLine(t *testing.T) { p := &bill.Payment{Lines: []*bill.PaymentLine{nil}} assert.True(t, paymentVATRatesAllowed(p)) } - -func TestNormalizeInvoiceNilSafe(t *testing.T) { - assert.NotPanics(t, func() { normalizeInvoice(nil) }) -} - -func TestNormalizeInvoiceBillingModeDefaultsM2WhenPaid(t *testing.T) { - due := num.MakeAmount(0, 2) - inv := &bill.Invoice{ - Totals: &bill.Totals{Due: &due}, - Tax: &bill.Tax{}, - } - normalizeInvoiceBillingMode(inv) - assert.Equal(t, BillingModeM2, inv.Tax.Ext.Get(ExtKeyBillingMode)) -} From 31c32d73e7b50b1ac59e6c49216a12a6bf15b2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 08:23:56 +0000 Subject: [PATCH 04/26] Consolidate flow2 invoice files Merge bill.go (invoice helpers) and bill_invoices.go (invoice rules) into a single bill_invoice.go matching the flow10 layout. Tests follow the rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow2/bill.go | 234 ------------------ .../{bill_invoices.go => bill_invoice.go} | 222 +++++++++++++++++ .../{bill_test.go => bill_invoice_test.go} | 0 3 files changed, 222 insertions(+), 234 deletions(-) delete mode 100644 addons/fr/ctc/flow2/bill.go rename addons/fr/ctc/flow2/{bill_invoices.go => bill_invoice.go} (71%) rename addons/fr/ctc/flow2/{bill_test.go => bill_invoice_test.go} (100%) diff --git a/addons/fr/ctc/flow2/bill.go b/addons/fr/ctc/flow2/bill.go deleted file mode 100644 index 2c5f5c7db..000000000 --- a/addons/fr/ctc/flow2/bill.go +++ /dev/null @@ -1,234 +0,0 @@ -package flow2 - -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/flow2/bill_invoices.go b/addons/fr/ctc/flow2/bill_invoice.go similarity index 71% rename from addons/fr/ctc/flow2/bill_invoices.go rename to addons/fr/ctc/flow2/bill_invoice.go index 7d9a33620..6e1e77e10 100644 --- a/addons/fr/ctc/flow2/bill_invoices.go +++ b/addons/fr/ctc/flow2/bill_invoice.go @@ -1,6 +1,7 @@ package flow2 import ( + "regexp" "slices" "strings" @@ -11,11 +12,232 @@ import ( "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" ) +// 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 "" +} + 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)), diff --git a/addons/fr/ctc/flow2/bill_test.go b/addons/fr/ctc/flow2/bill_invoice_test.go similarity index 100% rename from addons/fr/ctc/flow2/bill_test.go rename to addons/fr/ctc/flow2/bill_invoice_test.go From f7790f5a0a19c8548a3fbb7b9fa6d0362ed86067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 09:03:16 +0000 Subject: [PATCH 05/26] Polish flow2 rules and add default note normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tighten descriptions on rules 03/04 (corrective preceding), 09 (factoring vs advance-payment doc type), 24/25 (consolidated credit note contracts) so each message says what it actually checks. - Default the flow2 billing mode the same way flow10 does — M2 if Totals.Paid(), M1 otherwise; user-supplied values are preserved. - Auto-fill the three BR-FR-05 regulatory mentions (PMT / PMD / AAB) with minimal, business-neutral text when missing. - Auto-fill the BR-FR-CO-14 TXD / MEMBRE_ASSUJETTI_UNIQUE note when the supplier carries an STC-scheme (0231) identity. - Regenerate data/ artefacts via go generate. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow2/bill_invoice.go | 97 ++++- addons/fr/ctc/flow2/bill_invoice_test.go | 128 ++++-- data/addons/fr-ctc-flow10-v1.json | 362 +++++++++++++++++ data/addons/fr-ctc-flow2-v1.json | 4 +- data/addons/fr-ctc-flow6-v1.json | 384 ++++++++++++++++++ data/rules/fr-ctc-flow10.json | 307 ++++++++++++++ data/rules/fr-ctc-flow2.json | 10 +- data/rules/fr-ctc-flow6.json | 206 ++++++++++ .../addons/fr/ctc/flow6/characteristic.json | 93 +++++ data/schemas/tax/addon-list.json | 8 + 10 files changed, 1559 insertions(+), 40 deletions(-) create mode 100644 data/addons/fr-ctc-flow10-v1.json create mode 100644 data/addons/fr-ctc-flow6-v1.json create mode 100644 data/rules/fr-ctc-flow10.json create mode 100644 data/rules/fr-ctc-flow6.json create mode 100644 data/schemas/addons/fr/ctc/flow6/characteristic.json diff --git a/addons/fr/ctc/flow2/bill_invoice.go b/addons/fr/ctc/flow2/bill_invoice.go index 6e1e77e10..595536156 100644 --- a/addons/fr/ctc/flow2/bill_invoice.go +++ b/addons/fr/ctc/flow2/bill_invoice.go @@ -119,6 +119,93 @@ func normalizeInvoice(inv *bill.Invoice) { // Always set rounding to currency for French CTC inv.Tax.Rounding = tax.RoundingRuleCurrency + + normalizeBillingMode(inv) + normalizeRequiredNotes(inv) + normalizeSTCNote(inv) +} + +// 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) == "TXD" && n.Text == "MEMBRE_ASSUJETTI_UNIQUE" { + return + } + } + inv.Notes = append(inv.Notes, &org.Note{ + Key: org.NoteKeyLegal, + Text: "MEMBRE_ASSUJETTI_UNIQUE", + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "TXD"}), + }) +} + +// defaultRequiredNotes lists the three UNTDID 4451 mentions French CTC +// requires on every B2B invoice (BR-FR-05). The defaults are minimal +// regulatory placeholders — they intentionally avoid committing to +// specific payment terms (penalty amounts, interest rates, etc.), +// which are business-specific. Callers should override with their own +// terms when required; supplying any note with the matching +// untdid-text-subject suppresses the default. +var defaultRequiredNotes = []*org.Note{ + { + Key: org.NoteKeyPayment, + Text: "Conditions de paiement selon les conditions générales de vente.", + Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{untdid.ExtKeyTextSubject: "PMD"}), + }, + { + Key: org.NoteKeyPaymentTerm, + Text: "Aucun escompte n'est accordé pour paiement anticipé.", + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "AAB"}), + }, +} + +// normalizeRequiredNotes appends any of the three regulatory PMT / PMD +// / AAB notes that are missing from the invoice. A user-supplied note +// carrying the same untdid-text-subject is left untouched. +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 +} + +// normalizeBillingMode picks a sensible default for the Flow 2 +// billing-mode extension when the caller hasn't supplied one. We +// default to the Mixed (M) prefix since it is the safest without +// line-level analysis: M2 when the invoice is fully paid, M1 otherwise. +// The user can override by setting the extension explicitly. +func normalizeBillingMode(inv *bill.Invoice) { + if inv.Tax.Ext.Get(ExtKeyBillingMode) != "" { + return + } + mode := BillingModeM1 + if inv.Totals != nil && inv.Totals.Paid() { + mode = BillingModeM2 + } + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, mode) } // isB2BTransaction determines if the transaction is B2B (business to business) @@ -257,10 +344,10 @@ func billInvoiceRules() *rules.Set { 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)", + rules.Assert("03", "corrective invoices must reference the original invoice in preceding (BR-FR-CO-04)", is.Present, ), - rules.Assert("04", "corrective invoices must have exactly one preceding invoice reference (BR-FR-CO-04)", + rules.Assert("04", "corrective invoices must reference exactly one preceding invoice — multiple references are not allowed (BR-FR-CO-04)", is.Length(1, 1), ), ), @@ -291,7 +378,7 @@ func billInvoiceRules() *rules.Set { 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)", + rules.Assert("09", "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...), ), ), @@ -396,10 +483,10 @@ func billInvoiceRules() *rules.Set { 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)", + rules.Assert("24", "ordering.contracts is required 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)", + rules.Assert("25", "ordering.contracts must contain at least one entry for consolidated credit notes (BR-FR-CO-03)", is.Length(1, 0), ), ), diff --git a/addons/fr/ctc/flow2/bill_invoice_test.go b/addons/fr/ctc/flow2/bill_invoice_test.go index 1cea3069f..9970779c0 100644 --- a/addons/fr/ctc/flow2/bill_invoice_test.go +++ b/addons/fr/ctc/flow2/bill_invoice_test.go @@ -1122,7 +1122,7 @@ func TestConsolidatedCreditNoteValidation(t *testing.T) { 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, "ordering.contracts") assert.ErrorContains(t, err, "BR-FR-CO-03") }) @@ -1141,7 +1141,7 @@ func TestConsolidatedCreditNoteValidation(t *testing.T) { 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, "ordering.contracts") assert.ErrorContains(t, err, "BR-FR-CO-03") }) @@ -1314,13 +1314,48 @@ func TestSTCSupplierValidation(t *testing.T) { TaxID: inv.Supplier.TaxID, // Reuse supplier's valid tax ID }, } - // TXD note missing require.NoError(t, inv.Calculate()) + // Strip the TXD note that the normalizer auto-added to simulate a + // downstream consumer that drops it before validation. + kept := inv.Notes[:0] + for _, n := range inv.Notes { + if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == "TXD" { + continue + } + kept = append(kept, n) + } + inv.Notes = kept err := rules.Validate(inv) assert.Error(t, err) assert.ErrorContains(t, err, "TXD") assert.ErrorContains(t, err, "MEMBRE_ASSUJETTI_UNIQUE") }) + + t.Run("STC supplier auto-fills TXD note via normalizer", func(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ + Code: "12345678", + Ext: tax.ExtensionsOf(tax.ExtMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Ordering = &bill.Ordering{ + Seller: &org.Party{ + Name: "Assujetti Unique", + TaxID: inv.Supplier.TaxID, + }, + } + require.NoError(t, inv.Calculate()) + require.NoError(t, rules.Validate(inv)) + var found bool + for _, n := range inv.Notes { + if n.Ext.Get(untdid.ExtKeyTextSubject) == "TXD" && n.Text == "MEMBRE_ASSUJETTI_UNIQUE" { + found = true + break + } + } + assert.True(t, found, "expected normalizer to add TXD note") + }) } func TestFinalInvoicePaymentValidation(t *testing.T) { @@ -1384,7 +1419,7 @@ func TestPrecedingReferencesValidation(t *testing.T) { setDocumentType(inv, "384") err := rules.Validate(inv) assert.Error(t, err) - assert.ErrorContains(t, err, "exactly one preceding invoice reference") + assert.ErrorContains(t, err, "must reference the original invoice in preceding") assert.ErrorContains(t, err, "BR-FR-CO-04") }) @@ -1405,7 +1440,7 @@ func TestPrecedingReferencesValidation(t *testing.T) { setDocumentType(inv, "384") err := rules.Validate(inv) assert.Error(t, err) - assert.ErrorContains(t, err, "exactly one preceding invoice reference") + assert.ErrorContains(t, err, "must reference exactly one preceding invoice") assert.ErrorContains(t, err, "BR-FR-CO-04") }) @@ -1618,7 +1653,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { setDocumentType(inv, "386") err := rules.Validate(inv) assert.Error(t, err) - assert.ErrorContains(t, err, "advance payment document types not allowed") + assert.ErrorContains(t, err, "advance payment document types (386, 500, 503) are not allowed") }) t.Run("factoring billing mode S4 with advance payment type 500 is invalid (BR-FR-CO-08)", func(t *testing.T) { @@ -1633,7 +1668,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { setDocumentType(inv, "500") err := rules.Validate(inv) assert.Error(t, err) - assert.ErrorContains(t, err, "advance payment document types not allowed") + assert.ErrorContains(t, err, "advance payment document types (386, 500, 503) are not allowed") }) t.Run("factoring billing mode M4 with advance payment type 503 is invalid (BR-FR-CO-08)", func(t *testing.T) { @@ -1648,7 +1683,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { setDocumentType(inv, "503") err := rules.Validate(inv) assert.Error(t, err) - assert.ErrorContains(t, err, "advance payment document types not allowed") + assert.ErrorContains(t, err, "advance payment document types (386, 500, 503) are not allowed") }) t.Run("factoring billing mode B4 with standard invoice type 380 is valid (BR-FR-CO-08)", func(t *testing.T) { @@ -2484,14 +2519,26 @@ func TestAdditionalBillingModes(t *testing.T) { assert.NoError(t, err) }) } -func TestMissingRequiredNoteCodes(t *testing.T) { +// TestRequiredNoteCodesValidation exercises the BR-FR-05 rule by stripping +// notes AFTER Calculate so the addon's default-note normalization does +// not refill them. This simulates a downstream consumer that drops the +// regulatory mentions before validating. +func TestRequiredNoteCodesValidation(t *testing.T) { + stripSubject := func(inv *bill.Invoice, subject cbc.Code) { + kept := inv.Notes[:0] + for _, n := range inv.Notes { + if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == subject { + continue + } + kept = append(kept, n) + } + inv.Notes = kept + } + 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(tax.ExtMap{untdid.ExtKeyTextSubject: "PMD"})}, - {Key: org.NoteKeyPaymentTerm, Text: "AAB text", Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "AAB"})}, - } require.NoError(t, inv.Calculate()) + stripSubject(inv, "PMT") err := rules.Validate(inv) assert.ErrorContains(t, err, "missing required note codes") assert.ErrorContains(t, err, "BR-FR-05") @@ -2499,11 +2546,8 @@ func TestMissingRequiredNoteCodes(t *testing.T) { 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(tax.ExtMap{untdid.ExtKeyTextSubject: "PMT"})}, - {Key: org.NoteKeyPaymentTerm, Text: "AAB text", Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "AAB"})}, - } require.NoError(t, inv.Calculate()) + stripSubject(inv, "PMD") err := rules.Validate(inv) assert.ErrorContains(t, err, "missing required note codes") assert.ErrorContains(t, err, "BR-FR-05") @@ -2511,11 +2555,8 @@ func TestMissingRequiredNoteCodes(t *testing.T) { 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(tax.ExtMap{untdid.ExtKeyTextSubject: "PMT"})}, - {Key: org.NoteKeyPaymentMethod, Text: "PMD text", Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "PMD"})}, - } require.NoError(t, inv.Calculate()) + stripSubject(inv, "AAB") err := rules.Validate(inv) assert.ErrorContains(t, err, "missing required note codes") assert.ErrorContains(t, err, "BR-FR-05") @@ -2523,10 +2564,9 @@ func TestMissingRequiredNoteCodes(t *testing.T) { 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(tax.ExtMap{untdid.ExtKeyTextSubject: "PMT"})}, - } require.NoError(t, inv.Calculate()) + stripSubject(inv, "PMD") + stripSubject(inv, "AAB") err := rules.Validate(inv) assert.ErrorContains(t, err, "missing required note codes") assert.ErrorContains(t, err, "BR-FR-05") @@ -2608,12 +2648,44 @@ func TestValidationNilChecks(t *testing.T) { _ = err }) - t.Run("self-billed invoice helper with nil invoice", func(t *testing.T) { + t.Run("nil Tax is rebuilt by the normalizer", 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) + // Normalizer recreates Tax and fills the mandatory extensions. + require.NotNil(t, inv.Tax) + assert.NotEmpty(t, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) + assert.NoError(t, rules.Validate(inv)) }) } + +func TestNormalizeBillingModeDefaultsM1(t *testing.T) { + inv := testInvoiceB2BStandard(t) + // Strip any billing mode the fixture provided so we exercise the + // default path. + inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) + require.NoError(t, inv.Calculate()) + assert.Equal(t, ctc.BillingModeM1, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) +} + +func TestNormalizeBillingModeDefaultsM2WhenPaid(t *testing.T) { + inv := testInvoiceB2BStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) + require.NoError(t, inv.Calculate()) + // Re-apply M-prefix default by simulating a paid invoice via Totals.Due. + due := num.MakeAmount(0, 2) + inv.Totals.Due = &due + // Re-run normalize: clear the billing mode and call Calculate again so + // the addon normalizer picks up the now-paid totals. + inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) + require.NoError(t, inv.Calculate()) + assert.Equal(t, ctc.BillingModeM2, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) +} + +func TestNormalizeBillingModePreservesUserValue(t *testing.T) { + inv := testInvoiceB2BStandard(t) + // Fixture sets S1 — re-set explicitly to make the assertion clear. + inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB7) + require.NoError(t, inv.Calculate()) + assert.Equal(t, ctc.BillingModeB7, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) +} diff --git a/data/addons/fr-ctc-flow10-v1.json b/data/addons/fr-ctc-flow10-v1.json new file mode 100644 index 000000000..f069730ae --- /dev/null +++ b/data/addons/fr-ctc-flow10-v1.json @@ -0,0 +1,362 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/addon-def", + "key": "fr-ctc-flow10-v1", + "name": { + "en": "France CTC Flow 10", + "fr": "France CTC Flux 10" + }, + "description": { + "en": "Support for the French CTC (Continuous Transaction Control) Flow 10\ne-reporting requirements from the French electronic invoicing reform.\n\nFlow 10 covers transactions that must be reported to the tax authority\nbut are not subject to the domestic B2B clearance flow (Flow 2). This\nincludes B2C, cross-border, and out-of-scope transactions where VAT\ndata and payment data must still be transmitted to the PPF.", + "fr": "Support pour le CTC (Contrôle Continu des Transactions) français Flux 10\npour les exigences de e-reporting de la réforme française de la\nfacturation électronique.\n\nLe Flux 10 couvre les transactions qui doivent être déclarées à\nl'administration fiscale mais qui ne sont pas soumises au flux B2B\ndomestique (Flux 2). Cela inclut les transactions B2C, transfrontalières\net hors champ pour lesquelles les données de TVA et de paiement doivent\ntout de même être transmises au PPF." + }, + "sources": [ + { + "title": { + "en": "External Specifications", + "fr": "Spécifications Externes" + }, + "url": "https://www.impots.gouv.fr/specifications-externes-b2b" + } + ], + "extensions": [ + { + "key": "fr-ctc-b2c-category", + "name": { + "en": "B2C Transaction Category", + "fr": "Catégorie de transaction B2C" + }, + "desc": { + "en": "Classifies a B2C transaction for French Flow 10 reporting to the PPF\n(G1.68). Required on B2C invoices and B2C payments.\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 reporting Flux 10 au PPF (G1.68).\nObligatoire sur les factures et paiements B2C.\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-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": "b2c", + "name": { + "en": "B2C", + "fr": "B2C" + } + } + ] + }, + { + "schema": "bill/payment", + "list": [ + { + "key": "b2c", + "name": { + "en": "B2C", + "fr": "B2C" + } + } + ] + } + ], + "scenarios": [ + { + "schema": "bill/invoice", + "list": [ + { + "type": [ + "standard" + ], + "ext": { + "untdid-document-type": "380" + } + }, + { + "type": [ + "standard" + ], + "tags": [ + "self-billed" + ], + "ext": { + "untdid-document-type": "389" + } + }, + { + "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" + } + } + ] + } + ], + "corrections": null +} \ No newline at end of file diff --git a/data/addons/fr-ctc-flow2-v1.json b/data/addons/fr-ctc-flow2-v1.json index 2c88c4ba8..71d016205 100644 --- a/data/addons/fr-ctc-flow2-v1.json +++ b/data/addons/fr-ctc-flow2-v1.json @@ -9,8 +9,8 @@ "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." + "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.", + "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." }, "sources": [ { diff --git a/data/addons/fr-ctc-flow6-v1.json b/data/addons/fr-ctc-flow6-v1.json new file mode 100644 index 000000000..d686c3b54 --- /dev/null +++ b/data/addons/fr-ctc-flow6-v1.json @@ -0,0 +1,384 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/addon-def", + "key": "fr-ctc-flow6-v1", + "name": { + "en": "France CTC Flow 6", + "fr": "France CTC Flux 6" + }, + "description": { + "en": "Support for the French CTC (Continuous Transaction Control)\nFlow 6 lifecycle messages (Cycle de Vie) exchanged between\nregistered platforms (plateformes agréées) for B2B invoices.\n\nThis addon operates on bill.Status documents. It carries the\ncode tables (ProcessConditionCode, ReasonCode, RequestedAction,\nRoleCode) that the gobl.cii CDAR converter reads to round-trip\nto and from the French PPF XML, and validates the subset of\n(key, type) / reason / action / role combinations that Flow 6\naccepts.\n\nIt does not depend on Flow 2: a platform may report lifecycle\nevents for any compliant invoice, whether or not the invoice\nitself went through the Flow 2 B2B clearance path." + }, + "sources": [ + { + "title": { + "en": "External Specifications", + "fr": "Spécifications Externes" + }, + "url": "https://www.impots.gouv.fr/specifications-externes-b2b" + } + ], + "extensions": [ + { + "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 for each\npopulated party on a Flow 6 lifecycle message. The normalizer\nfills the obvious defaults (Supplier → SE, Customer → BY)\nand leaves the rest for the caller to set explicitly." + }, + "values": [ + { + "code": "SE", + "name": { + "en": "Seller" + } + }, + { + "code": "BY", + "name": { + "en": "Buyer" + } + }, + { + "code": "WK", + "name": { + "en": "Work / Service Receiver" + } + }, + { + "code": "DFH", + "name": { + "en": "Delivery From" + } + }, + { + "code": "AB", + "name": { + "en": "Bank" + } + }, + { + "code": "SR", + "name": { + "en": "Sender" + } + }, + { + "code": "DL", + "name": { + "en": "Dealer" + } + }, + { + "code": "PE", + "name": { + "en": "Payee" + } + }, + { + "code": "PR", + "name": { + "en": "Payer" + } + }, + { + "code": "II", + "name": { + "en": "Issuer of Invoice" + } + }, + { + "code": "IV", + "name": { + "en": "Invoicee" + } + } + ] + }, + { + "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" + } + } + ] + } + ], + "scenarios": null, + "corrections": null +} \ No newline at end of file diff --git a/data/rules/fr-ctc-flow10.json b/data/rules/fr-ctc-flow10.json new file mode 100644 index 000000000..db0e867ff --- /dev/null +++ b/data/rules/fr-ctc-flow10.json @@ -0,0 +1,307 @@ +{ + "id": "GOBL-FR-CTC-FLOW10", + "package": "fr-ctc-flow10", + "guard": "context: addon in [fr-ctc-flow10-v1]", + "subsets": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE", + "object": "bill.Invoice", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-10", + "desc": "invoice must be in EUR or provide an exchange rate to EUR", + "tests": "can convert to [EUR]" + } + ], + "subsets": [ + { + "guard": "B2C invoice", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-19", + "desc": "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates" + } + ], + "subsets": [ + { + "field": "tax", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-16", + "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-FLOW10-BILL-INVOICE-17", + "desc": "supplier is required on B2C invoice", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-18", + "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-FLOW10-BILL-INVOICE-13", + "desc": "supplier address must include country", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + }, + { + "field": "customer", + "subsets": [ + { + "field": "addresses", + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "country", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-14", + "desc": "customer address must include country", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + }, + { + "guard": "B2B invoice", + "subsets": [ + { + "field": "tax", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-09", + "desc": "invoice document type must be one of the Flow 10 permitted UNTDID 1001 codes (380, 389, 393, 501, 386, 500, 384, 471, 472, 473, 381, 261, 396, 502, 503)", + "tests": "allowed Flow 10 document type" + }, + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-11", + "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-FLOW10-BILL-INVOICE-12", + "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-FLOW10-BILL-INVOICE-01", + "desc": "supplier is required for Flow 10 B2B invoice (G2.19)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-02", + "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-FLOW10-BILL-INVOICE-03", + "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-FLOW10-BILL-INVOICE-07", + "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-FLOW10-BILL-INVOICE-15", + "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-FLOW10-BILL-INVOICE-04", + "desc": "customer is required for Flow 10 B2B invoice (G2.19)", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-05", + "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-FLOW10-BILL-INVOICE-06", + "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-FLOW10-BILL-PAYMENT", + "object": "bill.Payment", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-07", + "desc": "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates" + } + ], + "subsets": [ + { + "field": "type", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-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-FLOW10-BILL-PAYMENT-02", + "desc": "payment value_date (settlement date) is required", + "tests": "present" + } + ] + }, + { + "field": "supplier", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-08", + "desc": "supplier is required", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-09", + "desc": "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002)", + "tests": "party has SIREN" + } + ] + }, + { + "guard": "B2B payment", + "subsets": [ + { + "field": "lines", + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "document", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-04", + "desc": "each payment line must reference a document (invoice) on B2B payments", + "tests": "present" + } + ], + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-05", + "desc": "payment line document code (invoice ID) is required on B2B payments", + "tests": "present" + } + ] + }, + { + "field": "issue_date", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-06", + "desc": "payment line document issue_date (invoice issue date) is required on B2B payments", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/data/rules/fr-ctc-flow2.json b/data/rules/fr-ctc-flow2.json index 893d744d5..60395411f 100644 --- a/data/rules/fr-ctc-flow2.json +++ b/data/rules/fr-ctc-flow2.json @@ -42,12 +42,12 @@ "assert": [ { "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-03", - "desc": "corrective invoices must have exactly one preceding invoice reference (BR-FR-CO-04)", + "desc": "corrective invoices must reference the original invoice in preceding (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)", + "desc": "corrective invoices must reference exactly one preceding invoice — multiple references are not allowed (BR-FR-CO-04)", "tests": "length between 1 and 1" } ] @@ -107,7 +107,7 @@ "assert": [ { "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-09", - "desc": "advance payment document types not allowed for factoring billing modes (BR-FR-CO-08)", + "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]" } ] @@ -305,12 +305,12 @@ "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)", + "desc": "ordering.contracts is required 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)", + "desc": "ordering.contracts must contain at least one entry for consolidated credit notes (BR-FR-CO-03)", "tests": "length between 1 and 0" } ] diff --git a/data/rules/fr-ctc-flow6.json b/data/rules/fr-ctc-flow6.json new file mode 100644 index 000000000..88ea3c5fa --- /dev/null +++ b/data/rules/fr-ctc-flow6.json @@ -0,0 +1,206 @@ +{ + "id": "GOBL-FR-CTC-FLOW6", + "package": "fr-ctc-flow6", + "guard": "context: addon in [fr-ctc-flow6-v1]", + "subsets": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS", + "object": "bill.Status", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-08", + "desc": "Status.Type must match the Type implied by each StatusLine.Key", + "tests": "status type consistent with line keys" + } + ], + "subsets": [ + { + "field": "type", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-01", + "desc": "status type must be one of: response, update", + "tests": "one of [response, update]" + } + ] + }, + { + "field": "supplier", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-02", + "desc": "supplier is required on Flow 6 status messages", + "tests": "present" + }, + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-03", + "desc": "supplier must have an identity with ISO/IEC 6523 scheme 0002 (SIREN)", + "tests": "supplier has SIREN" + } + ] + }, + { + "field": "lines", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-04", + "desc": "exactly one status line is required (CDAR carries a single status per CDV message)", + "tests": "exactly one line" + } + ], + "subsets": [ + { + "each": true, + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-06", + "desc": "status line key must be a recognised Flow 6 event", + "tests": "known Flow 6 status event" + }, + { + "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-BILL-STATUS-07", + "desc": "status line with key 'paid' (CDAR 212) must carry a Characteristic complement with Amount (value + currency) set — this is the MEN", + "tests": "amount received set when paid" + }, + { + "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-BILL-STATUS-10", + "desc": "Characteristic.TypeCode must be one of the MDT-207 values: MEN, MPA, RAP, ESC, RAB, REM, MAP, MAPTTC, MNA, MNATTC, CBB, DIV, DVA, MAJ", + "tests": "characteristic type code known" + } + ], + "subsets": [ + { + "field": "doc", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-05", + "desc": "status line must reference a document (BR-FR-CDV-10)", + "tests": "present" + } + ], + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-11", + "desc": "referenced invoice code is required (BR-FR-CDV-10)", + "tests": "present" + } + ] + }, + { + "field": "issue_date", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-12", + "desc": "referenced invoice issue date is required (BR-FR-CDV-11)", + "tests": "present" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-FR-CTC-FLOW6-BILL-REASON", + "object": "bill.Reason", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-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-FLOW6-BILL-ACTION", + "object": "bill.Action", + "subsets": [ + { + "field": "key", + "subsets": [ + { + "guard": "present", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-ORG-PARTY", + "object": "org.Party", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-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", + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-FLOW6-ORG-PARTY-02", + "desc": "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", + "tests": "scheme in allowed set" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/data/schemas/addons/fr/ctc/flow6/characteristic.json b/data/schemas/addons/fr/ctc/flow6/characteristic.json new file mode 100644 index 000000000..e14af23e7 --- /dev/null +++ b/data/schemas/addons/fr/ctc/flow6/characteristic.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gobl.org/draft-0/addons/fr/ctc/flow6/characteristic", + "$ref": "#/$defs/flow6.Characteristic", + "$defs": { + "flow6.Characteristic": { + "properties": { + "id": { + "type": "string", + "title": "ID", + "description": "ID optionally identifies the characteristic. Used by CDAR to\ncorrelate a correction with a previously reported field." + }, + "type_code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Type Code", + "description": "TypeCode is the CDAR CharacteristicTypeCode. See the TypeCode*\nconstants for reserved values Flow 6 interprets directly." + }, + "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. Only meaningful on\nrejection / dispute / partial-acceptance lines." + }, + "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 — typically\n\"+\" or \"-\" when Changed is true." + }, + "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. Used\nfor the MEN on paid lines and for any price/total correction." + }, + "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..42c0b1a7b 100644 --- a/data/schemas/tax/addon-list.json +++ b/data/schemas/tax/addon-list.json @@ -55,10 +55,18 @@ "const": "fr-choruspro-v1", "title": "Chorus Pro" }, + { + "const": "fr-ctc-flow10-v1", + "title": "France CTC Flow 10" + }, { "const": "fr-ctc-flow2-v1", "title": "France CTC Flow 2" }, + { + "const": "fr-ctc-flow6-v1", + "title": "France CTC Flow 6" + }, { "const": "fr-facturx-v1", "title": "French Factur-X v1" From 952c51bc1ac863b6b88975a71ef87fd58c260988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 09:48:10 +0000 Subject: [PATCH 06/26] Convert flow2 tests to internal package; cover defensive branches The flow2 test files were external (package flow2_test) so the defensive nil / wrong-type paths in unexported helpers were unreachable, dragging coverage below the rest of the suite. Switching to package flow2 lets the same one-test-per-source layout reach the helpers; adding a focused set of nil/wrong-type tests pushes coverage to 97.5%. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow2/bill_invoice_test.go | 167 ++++++++++++++++------- addons/fr/ctc/flow2/org_test.go | 25 ++-- 2 files changed, 131 insertions(+), 61 deletions(-) diff --git a/addons/fr/ctc/flow2/bill_invoice_test.go b/addons/fr/ctc/flow2/bill_invoice_test.go index 9970779c0..b7dbc963f 100644 --- a/addons/fr/ctc/flow2/bill_invoice_test.go +++ b/addons/fr/ctc/flow2/bill_invoice_test.go @@ -1,9 +1,8 @@ -package flow2_test +package flow2 import ( "testing" - ctc "github.com/invopop/gobl/addons/fr/ctc/flow2" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/catalogues/iso" @@ -24,13 +23,13 @@ func testInvoiceB2BStandard(t *testing.T) *bill.Invoice { t.Helper() i := &bill.Invoice{ Regime: tax.WithRegime("FR"), - Addons: tax.WithAddons(ctc.V1), + Addons: tax.WithAddons(V1), Code: "FAC-2024-001", Currency: "EUR", Type: bill.InvoiceTypeStandard, Tax: &bill.Tax{ Ext: tax.ExtensionsOf(tax.ExtMap{ - ctc.ExtKeyBillingMode: ctc.BillingModeS1, + ExtKeyBillingMode: BillingModeS1, untdid.ExtKeyDocumentType: "380", }), }, @@ -633,18 +632,18 @@ func TestBillingModeNormalization(t *testing.T) { inv := testInvoiceB2BStandard(t) inv.Tax = &bill.Tax{ Ext: tax.ExtensionsOf(tax.ExtMap{ - ctc.ExtKeyBillingMode: ctc.BillingModeS5, // Subcontractor + ExtKeyBillingMode: BillingModeS5, // Subcontractor }), } require.NoError(t, inv.Calculate()) - assert.Equal(t, ctc.BillingModeS5.String(), inv.Tax.Ext.Get(ctc.ExtKeyBillingMode).String()) + assert.Equal(t, BillingModeS5.String(), inv.Tax.Ext.Get(ExtKeyBillingMode).String()) }) t.Run("invalid billing mode rejected - B8", func(t *testing.T) { inv := testInvoiceB2BStandard(t) inv.Tax = &bill.Tax{ Ext: tax.ExtensionsOf(tax.ExtMap{ - ctc.ExtKeyBillingMode: cbc.Code("B8"), // Not allowed + ExtKeyBillingMode: cbc.Code("B8"), // Not allowed }), } require.NoError(t, inv.Calculate()) @@ -662,7 +661,7 @@ func TestBillingModeNormalization(t *testing.T) { inv := testInvoiceB2BStandard(t) inv.Tax = &bill.Tax{ Ext: tax.ExtensionsOf(tax.ExtMap{ - ctc.ExtKeyBillingMode: cbc.Code("B5"), // Not allowed + ExtKeyBillingMode: cbc.Code("B5"), // Not allowed }), } require.NoError(t, inv.Calculate()) @@ -1362,7 +1361,7 @@ 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.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) inv.Payment = nil require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -1373,7 +1372,7 @@ func TestFinalInvoicePaymentValidation(t *testing.T) { 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.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS2) inv.Payment = nil require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -1384,7 +1383,7 @@ func TestFinalInvoicePaymentValidation(t *testing.T) { 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.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeM2) inv.Payment = nil require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -1599,7 +1598,7 @@ func TestPaymentDueDateValidation(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) // Set up final invoice totals (BR-FR-CO-09) totalWithTax := inv.Totals.TotalWithTax inv.Totals.Advances = &totalWithTax @@ -1618,7 +1617,7 @@ func TestPaymentDueDateValidation(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS2) // Set up final invoice totals (BR-FR-CO-09) totalWithTax := inv.Totals.TotalWithTax inv.Totals.Advances = &totalWithTax @@ -1648,7 +1647,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB4) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB4) // Set advance payment document type 386 setDocumentType(inv, "386") err := rules.Validate(inv) @@ -1663,7 +1662,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS4) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS4) // Set advance payment document type 500 setDocumentType(inv, "500") err := rules.Validate(inv) @@ -1678,7 +1677,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeM4) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeM4) // Set advance payment document type 503 setDocumentType(inv, "503") err := rules.Validate(inv) @@ -1693,7 +1692,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB4) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB4) // Standard invoice type 380 is already set by scenarios setDocumentType(inv, "380") err := rules.Validate(inv) @@ -1707,7 +1706,7 @@ func TestBillingModeDocumentTypeCompatibility(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) // Set up final invoice totals (BR-FR-CO-09) totalWithTax := inv.Totals.TotalWithTax inv.Totals.Advances = &totalWithTax @@ -1737,7 +1736,7 @@ func TestFinalInvoiceValidation(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) // Manually set the totals to simulate fully paid invoice // Advance = TotalWithTax, Payable = 0 @@ -1758,7 +1757,7 @@ func TestFinalInvoiceValidation(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) // No advance amount set inv.Totals.Advances = nil @@ -1776,7 +1775,7 @@ func TestFinalInvoiceValidation(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) // Set advance amount to something other than TotalWithTax wrongAmount := num.MakeAmount(5000, 2) // Wrong amount @@ -1795,7 +1794,7 @@ func TestFinalInvoiceValidation(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS2) // Set advance amount correctly totalWithTax := inv.Totals.TotalWithTax @@ -1817,7 +1816,7 @@ func TestFinalInvoiceValidation(t *testing.T) { if inv.Tax.Ext.IsZero() { inv.Tax.Ext = tax.MakeExtensions() } - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeM2) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeM2) // Set amounts correctly totalWithTax := inv.Totals.TotalWithTax @@ -1998,7 +1997,7 @@ func TestAdvancedInvoiceTypes(t *testing.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) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB1) require.NoError(t, inv.Calculate()) @@ -2012,7 +2011,7 @@ func TestAdvancedInvoiceTypes(t *testing.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) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB1) require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -2026,7 +2025,7 @@ func TestFinalInvoiceTypes(t *testing.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) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeM4) require.NoError(t, inv.Calculate()) @@ -2040,7 +2039,7 @@ func TestFinalInvoiceTypes(t *testing.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) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS4) require.NoError(t, inv.Calculate()) @@ -2051,7 +2050,7 @@ func TestFinalInvoiceTypes(t *testing.T) { } func TestInvoiceNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) t.Run("normalizes invoice with existing tax", func(t *testing.T) { inv := testInvoiceB2BStandard(t) @@ -2162,7 +2161,7 @@ func TestHelperFunctionEdgeCases(t *testing.T) { require.NoError(t, inv.Calculate()) // Remove billing mode extension - inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) + inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) // Should not panic err := rules.Validate(inv) @@ -2329,7 +2328,7 @@ func TestDeliveryAndTotalsValidation(t *testing.T) { func withAddonContext() rules.WithContext { return func(rc *rules.Context) { - rc.Set(rules.ContextKey(ctc.V1), tax.AddonForKey(ctc.V1)) + rc.Set(rules.ContextKey(V1), tax.AddonForKey(V1)) } } @@ -2424,7 +2423,7 @@ func TestAdditionalBillingModes(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(ExtKeyBillingMode, BillingModeB4) inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "456") require.NoError(t, inv.Calculate()) @@ -2438,7 +2437,7 @@ func TestAdditionalBillingModes(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(ExtKeyBillingMode, BillingModeS4) inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "501") require.NoError(t, inv.Calculate()) @@ -2452,7 +2451,7 @@ func TestAdditionalBillingModes(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(ExtKeyBillingMode, BillingModeM4) inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "456") require.NoError(t, inv.Calculate()) @@ -2466,7 +2465,7 @@ func TestAdditionalBillingModes(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(ExtKeyBillingMode, BillingModeS5) inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "381") // Add preceding @@ -2481,7 +2480,7 @@ func TestAdditionalBillingModes(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(ExtKeyBillingMode, BillingModeS6) inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "502") // Add preceding @@ -2496,7 +2495,7 @@ func TestAdditionalBillingModes(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(ExtKeyBillingMode, BillingModeB7) inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "503") // Add preceding @@ -2511,7 +2510,7 @@ func TestAdditionalBillingModes(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(ExtKeyBillingMode, BillingModeS7) inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "380") require.NoError(t, inv.Calculate()) @@ -2519,6 +2518,7 @@ func TestAdditionalBillingModes(t *testing.T) { assert.NoError(t, err) }) } + // TestRequiredNoteCodesValidation exercises the BR-FR-05 rule by stripping // notes AFTER Calculate so the addon's default-note normalization does // not refill them. This simulates a downstream consumer that drops the @@ -2603,7 +2603,7 @@ func TestValidationNilChecks(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.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS1) inv.Payment.Terms = nil // Nil terms should be handled gracefully require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -2617,7 +2617,7 @@ func TestValidationNilChecks(t *testing.T) { require.NoError(t, inv.Calculate()) // Then set nil due date after calculation - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeS1) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeS1) var nilDueDate *pay.DueDate inv.Payment.Terms.DueDates = []*pay.DueDate{nilDueDate} @@ -2629,7 +2629,7 @@ func TestValidationNilChecks(t *testing.T) { 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.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB2) inv.Totals = nil require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -2654,7 +2654,7 @@ func TestValidationNilChecks(t *testing.T) { require.NoError(t, inv.Calculate()) // Normalizer recreates Tax and fills the mandatory extensions. require.NotNil(t, inv.Tax) - assert.NotEmpty(t, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) + assert.NotEmpty(t, inv.Tax.Ext.Get(ExtKeyBillingMode)) assert.NoError(t, rules.Validate(inv)) }) } @@ -2663,29 +2663,100 @@ func TestNormalizeBillingModeDefaultsM1(t *testing.T) { inv := testInvoiceB2BStandard(t) // Strip any billing mode the fixture provided so we exercise the // default path. - inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) + inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) require.NoError(t, inv.Calculate()) - assert.Equal(t, ctc.BillingModeM1, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) + assert.Equal(t, BillingModeM1, inv.Tax.Ext.Get(ExtKeyBillingMode)) } func TestNormalizeBillingModeDefaultsM2WhenPaid(t *testing.T) { inv := testInvoiceB2BStandard(t) - inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) + inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) require.NoError(t, inv.Calculate()) // Re-apply M-prefix default by simulating a paid invoice via Totals.Due. due := num.MakeAmount(0, 2) inv.Totals.Due = &due // Re-run normalize: clear the billing mode and call Calculate again so // the addon normalizer picks up the now-paid totals. - inv.Tax.Ext = inv.Tax.Ext.Delete(ctc.ExtKeyBillingMode) + inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) require.NoError(t, inv.Calculate()) - assert.Equal(t, ctc.BillingModeM2, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) + assert.Equal(t, BillingModeM2, inv.Tax.Ext.Get(ExtKeyBillingMode)) } func TestNormalizeBillingModePreservesUserValue(t *testing.T) { inv := testInvoiceB2BStandard(t) // Fixture sets S1 — re-set explicitly to make the assertion clear. - inv.Tax.Ext = inv.Tax.Ext.Set(ctc.ExtKeyBillingMode, ctc.BillingModeB7) + inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB7) require.NoError(t, inv.Calculate()) - assert.Equal(t, ctc.BillingModeB7, inv.Tax.Ext.Get(ctc.ExtKeyBillingMode)) + assert.Equal(t, BillingModeB7, inv.Tax.Ext.Get(ExtKeyBillingMode)) +} + +// --- Internal helper coverage (defensive nil / wrong-type branches) ----- + +func TestIsB2BTransactionNilInvoice(t *testing.T) { + assert.False(t, isB2BTransaction(nil)) +} + +func TestIsB2BTransactionNoNotes(t *testing.T) { + assert.False(t, isB2BTransaction(&bill.Invoice{})) +} + +func TestIsSelfBilledInvoiceNilInvoice(t *testing.T) { + assert.False(t, isSelfBilledInvoice(nil)) +} + +func TestIsSelfBilledInvoiceMissingDocType(t *testing.T) { + inv := &bill.Invoice{Tax: &bill.Tax{Ext: tax.ExtensionsOf(tax.ExtMap{"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 TestIdentitiesHasSIRENWrongType(t *testing.T) { + assert.True(t, identitiesHasSIREN(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 TestNotesValidBARTextWrongType(t *testing.T) { + assert.True(t, notesValidBARText(42)) +} + +func TestInvoiceDueDatesValidWrongType(t *testing.T) { + assert.True(t, invoiceDueDatesValid(42)) +} + +func TestFinalInvoicePayableZeroWrongType(t *testing.T) { + assert.True(t, finalInvoicePayableZero(42)) } diff --git a/addons/fr/ctc/flow2/org_test.go b/addons/fr/ctc/flow2/org_test.go index 4896808d0..3499d0059 100644 --- a/addons/fr/ctc/flow2/org_test.go +++ b/addons/fr/ctc/flow2/org_test.go @@ -1,10 +1,9 @@ -package flow2_test +package flow2 import ( "strings" "testing" - ctc "github.com/invopop/gobl/addons/fr/ctc/flow2" "github.com/invopop/gobl/catalogues/iso" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" @@ -166,7 +165,7 @@ func TestElectronicAddressValidation(t *testing.T) { } func TestPeppolKeyNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) t.Run("peppol key set on SIREN inbox when none exist", func(t *testing.T) { party := &org.Party{ @@ -382,7 +381,7 @@ func TestIdentitySchemeFormatValidation(t *testing.T) { } func TestPrivateIDNormalization(t *testing.T) { - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) t.Run("private-id key sets ISO scheme ID 0224", func(t *testing.T) { party := &org.Party{ @@ -449,7 +448,7 @@ func TestPrivateIDNormalization(t *testing.T) { } func TestSIRENGenerationFromSIRET(t *testing.T) { - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) t.Run("generated SIREN from SIRET", func(t *testing.T) { party := &org.Party{ @@ -598,7 +597,7 @@ func TestValidatePartyEdgeCases(t *testing.T) { func TestNormalizePartyEdgeCases(t *testing.T) { t.Run("normalize nil party", func(_ *testing.T) { - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) ad.Normalizer((*org.Party)(nil)) // Should not crash }) @@ -607,7 +606,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { party := &org.Party{ Name: "Test Party", } - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) ad.Normalizer(party) assert.Len(t, party.Identities, 0) }) @@ -624,7 +623,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) ad.Normalizer(party) // Should have generated SIREN from SIRET, plus the original SIRET, plus 1 nil @@ -664,7 +663,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) ad.Normalizer(party) // Should have generated SIREN @@ -703,7 +702,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) ad.Normalizer(party) // Should not generate duplicate SIREN @@ -726,7 +725,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) ad.Normalizer(party) assert.Equal(t, cbc.Key("peppol"), party.Inboxes[0].Key) }) @@ -746,7 +745,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { }, }, } - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) 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) @@ -766,7 +765,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { nilInbox, // Another nil for good measure }, } - ad := tax.AddonForKey(ctc.V1) + ad := tax.AddonForKey(V1) ad.Normalizer(party) // Should still have 3 elements (2 nils + 1 valid inbox) From 06fa753ea270be199ab84c1fe93f5add44148f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 09:53:24 +0000 Subject: [PATCH 07/26] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d087d3078..33628d3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- `fr-ctc-flow6`: new addon for status documents in France. +- `fr-ctc-flow10`: new addon for e-reporting documents in France. - `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. From ab9bffd793b28884a2a6144dada6ddfb5ae2ae15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 10:02:43 +0000 Subject: [PATCH 08/26] Extract repeated string literals to constants Lint flagged "TXD", "MEMBRE_ASSUJETTI_UNIQUE", and "peppol" as repeated literals. Lift the first two into noteSubjectTXD / stcMembreAssujettiUnique constants in flow2/bill_invoice.go, and switch all "peppol" references to the existing org.InboxKeyPeppol constant. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow2/bill_invoice.go | 16 ++++++++++++---- addons/fr/ctc/flow2/bill_invoice_test.go | 24 ++++++++++++------------ addons/fr/ctc/flow2/org.go | 4 ++-- addons/fr/ctc/flow2/org_test.go | 22 +++++++++++----------- 4 files changed, 37 insertions(+), 29 deletions(-) diff --git a/addons/fr/ctc/flow2/bill_invoice.go b/addons/fr/ctc/flow2/bill_invoice.go index 595536156..f161e81fb 100644 --- a/addons/fr/ctc/flow2/bill_invoice.go +++ b/addons/fr/ctc/flow2/bill_invoice.go @@ -104,6 +104,14 @@ var allowedAttachmentDescriptions = []string{ 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 for + // suppliers operating under a single-VAT-group identity (scheme 0231). + stcMembreAssujettiUnique = "MEMBRE_ASSUJETTI_UNIQUE" ) // normalizeInvoice ensures invoice settings comply with French CTC requirements @@ -133,14 +141,14 @@ func normalizeSTCNote(inv *bill.Invoice) { return } for _, n := range inv.Notes { - if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == "TXD" && n.Text == "MEMBRE_ASSUJETTI_UNIQUE" { + if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectTXD && n.Text == stcMembreAssujettiUnique { return } } inv.Notes = append(inv.Notes, &org.Note{ Key: org.NoteKeyLegal, - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "TXD"}), + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), }) } @@ -746,7 +754,7 @@ func notesHaveTXD(val any) bool { } for _, note := range notes { if note != nil && !note.Ext.IsZero() { - if code := note.Ext.Get(untdid.ExtKeyTextSubject); code == "TXD" && note.Text == "MEMBRE_ASSUJETTI_UNIQUE" { + if code := note.Ext.Get(untdid.ExtKeyTextSubject); code == noteSubjectTXD && note.Text == stcMembreAssujettiUnique { return true } } diff --git a/addons/fr/ctc/flow2/bill_invoice_test.go b/addons/fr/ctc/flow2/bill_invoice_test.go index b7dbc963f..4a5ec65a7 100644 --- a/addons/fr/ctc/flow2/bill_invoice_test.go +++ b/addons/fr/ctc/flow2/bill_invoice_test.go @@ -1197,8 +1197,8 @@ func TestSTCSupplierValidation(t *testing.T) { } // Add TXD note inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "TXD"}), + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), }) require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -1230,8 +1230,8 @@ func TestSTCSupplierValidation(t *testing.T) { } // Add TXD note inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "TXD"}), + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), }) require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -1267,8 +1267,8 @@ func TestSTCSupplierValidation(t *testing.T) { } // Add TXD note inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "TXD"}), + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), }) require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -1288,8 +1288,8 @@ func TestSTCSupplierValidation(t *testing.T) { inv.Ordering = nil // Add TXD note inv.Notes = append(inv.Notes, &org.Note{ - Text: "MEMBRE_ASSUJETTI_UNIQUE", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "TXD"}), + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), }) require.NoError(t, inv.Calculate()) err := rules.Validate(inv) @@ -1318,7 +1318,7 @@ func TestSTCSupplierValidation(t *testing.T) { // downstream consumer that drops it before validation. kept := inv.Notes[:0] for _, n := range inv.Notes { - if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == "TXD" { + if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectTXD { continue } kept = append(kept, n) @@ -1326,8 +1326,8 @@ func TestSTCSupplierValidation(t *testing.T) { inv.Notes = kept err := rules.Validate(inv) assert.Error(t, err) - assert.ErrorContains(t, err, "TXD") - assert.ErrorContains(t, err, "MEMBRE_ASSUJETTI_UNIQUE") + assert.ErrorContains(t, err, string(noteSubjectTXD)) + assert.ErrorContains(t, err, stcMembreAssujettiUnique) }) t.Run("STC supplier auto-fills TXD note via normalizer", func(t *testing.T) { @@ -1348,7 +1348,7 @@ func TestSTCSupplierValidation(t *testing.T) { require.NoError(t, rules.Validate(inv)) var found bool for _, n := range inv.Notes { - if n.Ext.Get(untdid.ExtKeyTextSubject) == "TXD" && n.Text == "MEMBRE_ASSUJETTI_UNIQUE" { + if n.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectTXD && n.Text == stcMembreAssujettiUnique { found = true break } diff --git a/addons/fr/ctc/flow2/org.go b/addons/fr/ctc/flow2/org.go index 672f36a1c..f8da5e011 100644 --- a/addons/fr/ctc/flow2/org.go +++ b/addons/fr/ctc/flow2/org.go @@ -120,7 +120,7 @@ func normalizeInboxes(party *org.Party) { if inbox == nil { continue } - if inbox.Key == "peppol" { + if inbox.Key == org.InboxKeyPeppol { hasPeppol = true } if inbox.Scheme == inboxSchemeSIREN { @@ -130,6 +130,6 @@ func normalizeInboxes(party *org.Party) { // 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 } } diff --git a/addons/fr/ctc/flow2/org_test.go b/addons/fr/ctc/flow2/org_test.go index 3499d0059..a83b0a20a 100644 --- a/addons/fr/ctc/flow2/org_test.go +++ b/addons/fr/ctc/flow2/org_test.go @@ -188,7 +188,7 @@ func TestPeppolKeyNormalization(t *testing.T) { } 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) { @@ -205,7 +205,7 @@ func TestPeppolKeyNormalization(t *testing.T) { }, Inboxes: []*org.Inbox{ { - Key: "peppol", + Key: org.InboxKeyPeppol, Scheme: "0088", Code: "1234567890123", }, @@ -217,8 +217,8 @@ func TestPeppolKeyNormalization(t *testing.T) { } 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) + assert.Equal(t, cbc.Key(""), party.Inboxes[1].Key) }) t.Run("peppol key set even for non-French party", func(t *testing.T) { @@ -236,7 +236,7 @@ func TestPeppolKeyNormalization(t *testing.T) { } 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) { @@ -260,7 +260,7 @@ func TestPeppolKeyNormalization(t *testing.T) { } 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) }) } @@ -727,7 +727,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { } ad := tax.AddonForKey(V1) ad.Normalizer(party) - assert.Equal(t, cbc.Key("peppol"), party.Inboxes[0].Key) + assert.Equal(t, org.InboxKeyPeppol, party.Inboxes[0].Key) }) t.Run("normalize inbox does not override existing peppol key", func(t *testing.T) { @@ -735,7 +735,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { Name: "Test Party", Inboxes: []*org.Inbox{ { - Key: "peppol", + Key: org.InboxKeyPeppol, Scheme: "0088", Code: "existing", }, @@ -748,8 +748,8 @@ func TestNormalizePartyEdgeCases(t *testing.T) { ad := tax.AddonForKey(V1) 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, org.InboxKeyPeppol, party.Inboxes[0].Key) + assert.NotEqual(t, org.InboxKeyPeppol, party.Inboxes[1].Key) }) t.Run("normalize inbox with nil element in array", func(t *testing.T) { @@ -777,7 +777,7 @@ func TestNormalizePartyEdgeCases(t *testing.T) { for _, inbox := range party.Inboxes { if inbox != nil { nonNilCount++ - if inbox.Key == "peppol" { + if inbox.Key == org.InboxKeyPeppol { hasPeppol = true } } From 0f2b5d316d027014a7d5ccec45e6c17c2ff25754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 10:20:58 +0000 Subject: [PATCH 09/26] Lift BAR string literals to constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull "BAR" / "B2B" out of bill_invoice.go into noteSubjectBAR and barTreatmentB2B so the goconst lint stops complaining and the isB2BTransaction / notesValidBARText helpers read clearly. No behaviour change: the BAR note is intentionally not auto-defaulted — its value (B2B / B2BINT / B2C / OUTOFSCOPE / ARCHIVEONLY) is transaction-specific and must come from the caller. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow2/bill_invoice.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/addons/fr/ctc/flow2/bill_invoice.go b/addons/fr/ctc/flow2/bill_invoice.go index f161e81fb..8579031e6 100644 --- a/addons/fr/ctc/flow2/bill_invoice.go +++ b/addons/fr/ctc/flow2/bill_invoice.go @@ -112,6 +112,16 @@ const ( // stcMembreAssujettiUnique is the fixed text that pairs with TXD for // suppliers operating under a single-VAT-group identity (scheme 0231). stcMembreAssujettiUnique = "MEMBRE_ASSUJETTI_UNIQUE" + + // noteSubjectBAR is the UNTDID 4451 text-subject code that carries + // the transaction category for French CTC (B2B / B2BINT / B2C / etc.). + noteSubjectBAR cbc.Code = "BAR" + + // barTreatmentB2B is the BAR value Flow 2 reads when discriminating + // B2B vs other treatments inside isB2BTransaction. The default is + // not auto-applied — callers must add the BAR note explicitly with + // one of the values listed in allowedBARTreatments. + barTreatmentB2B = "B2B" ) // normalizeInvoice ensures invoice settings comply with French CTC requirements @@ -225,7 +235,7 @@ func isB2BTransaction(inv *bill.Invoice) bool { for _, note := range inv.Notes { if note != nil && !note.Ext.IsZero() { - if note.Ext.Get(untdid.ExtKeyTextSubject) == "BAR" && note.Text == "B2B" { + if note.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectBAR && note.Text == barTreatmentB2B { // Check if note text indicates B2B transaction (B2B or B2BINT) return true } @@ -813,7 +823,7 @@ func notesValidBARText(val any) bool { } for _, note := range notes { if note != nil && !note.Ext.IsZero() { - if note.Ext.Get(untdid.ExtKeyTextSubject) == "BAR" { + if note.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectBAR { if note.Text != "" && !slices.Contains(allowedBARTreatments, note.Text) { return false } From 078dde4dca16641dcefa725b740da96b25beccbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 10:44:59 +0000 Subject: [PATCH 10/26] flow2: default to B2B treatment when BAR note is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per BR-FR-22 ("règle à exécuter si la facture fait l'objet d'un traitement B2B ou si elle contient une note (BG-1) avec un code sujet (BT-21) = BAR et un contenu (BT-22) = B2B"), the B2B-specific rules should fire whenever flow2 is in scope — flow2 *is* the B2B addon — unless the caller explicitly opts the invoice out via a BAR note with a non-B2B treatment (B2BINT / B2C / OUTOFSCOPE / ARCHIVEONLY). Previously isB2BTransaction returned true only when an explicit BAR=B2B note was present, which silently let invoices skip the customer-SIREN, supplier-SIREN-inbox and self-billed-customer-inbox checks. Flip the default so absence of a BAR note keeps the B2B path active. The international b2bint example is updated to carry an explicit BAR=B2BINT note to opt out, with no Key on the note so the en16931 normalizer doesn't rewrite the subject code based on note.Key. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow2/bill_invoice.go | 26 +++++++++----- addons/fr/ctc/flow2/bill_invoice_test.go | 36 ++++++++++++++----- examples/fr/invoice-fr-de-ctc-b2bint.yaml | 3 ++ examples/fr/out/invoice-fr-de-ctc-b2bint.json | 8 ++++- 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/addons/fr/ctc/flow2/bill_invoice.go b/addons/fr/ctc/flow2/bill_invoice.go index 8579031e6..68d116319 100644 --- a/addons/fr/ctc/flow2/bill_invoice.go +++ b/addons/fr/ctc/flow2/bill_invoice.go @@ -226,23 +226,31 @@ func normalizeBillingMode(inv *bill.Invoice) { inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, mode) } -// isB2BTransaction determines if the transaction is B2B (business to business) -// by checking for a note with code "BAR" and text containing "B2B" +// isB2BTransaction determines whether the invoice should be treated as a +// B2B transaction. Per BR-FR-22, the rule applies "if the invoice is +// processed B2B or carries a BAR note with text B2B". Flow 2 *is* the +// B2B addon, so the default treatment is B2B; the helper returns false +// only when a BAR note explicitly classifies the invoice as something +// else (B2BINT / B2C / OUTOFSCOPE / ARCHIVEONLY). func isB2BTransaction(inv *bill.Invoice) bool { - if inv == nil || len(inv.Notes) == 0 { + if inv == nil { return false } for _, note := range inv.Notes { - if note != nil && !note.Ext.IsZero() { - if note.Ext.Get(untdid.ExtKeyTextSubject) == noteSubjectBAR && note.Text == barTreatmentB2B { - // Check if note text indicates B2B transaction (B2B or B2BINT) - return true - } + if note == nil || note.Ext.IsZero() { + continue } + if note.Ext.Get(untdid.ExtKeyTextSubject) != noteSubjectBAR { + continue + } + // An explicit BAR note overrides the default: only B2B counts as + // B2B; any other treatment opts the invoice out of the B2B rules. + return note.Text == barTreatmentB2B } - return false + // No BAR note → the addon's B2B default applies. + return true } // isSelfBilledInvoice checks if the invoice is self-billed based on document type diff --git a/addons/fr/ctc/flow2/bill_invoice_test.go b/addons/fr/ctc/flow2/bill_invoice_test.go index 4a5ec65a7..77ef09723 100644 --- a/addons/fr/ctc/flow2/bill_invoice_test.go +++ b/addons/fr/ctc/flow2/bill_invoice_test.go @@ -462,15 +462,18 @@ func TestInvoiceValidation(t *testing.T) { 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", - }, + {Scheme: "0088", Code: "1234567890123"}, } require.NoError(t, inv.Calculate()) - // No B2B note, so not a B2B transaction + // Append BAR=B2C *after* Calculate so the en16931 normalizer + // (which rewrites the subject based on note.Key) doesn't replace + // "BAR" with "ABL" on a Key=legal note. + inv.Notes = append(inv.Notes, &org.Note{ + Key: org.NoteKeyLegal, + Text: "B2C", + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectBAR}), + }) err := rules.Validate(inv) assert.NoError(t, err) }) @@ -2696,8 +2699,25 @@ func TestIsB2BTransactionNilInvoice(t *testing.T) { assert.False(t, isB2BTransaction(nil)) } -func TestIsB2BTransactionNoNotes(t *testing.T) { - assert.False(t, isB2BTransaction(&bill.Invoice{})) +func TestIsB2BTransactionNoNotesDefaultsTrue(t *testing.T) { + // Flow 2 is the B2B addon — absence of a BAR note means B2B by default. + assert.True(t, isB2BTransaction(&bill.Invoice{})) +} + +func TestIsB2BTransactionExplicitB2C(t *testing.T) { + inv := &bill.Invoice{Notes: []*org.Note{{ + Text: "B2C", + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectBAR}), + }}} + assert.False(t, isB2BTransaction(inv)) +} + +func TestIsB2BTransactionExplicitB2B(t *testing.T) { + inv := &bill.Invoice{Notes: []*org.Note{{ + Text: barTreatmentB2B, + Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectBAR}), + }}} + assert.True(t, isB2BTransaction(inv)) } func TestIsSelfBilledInvoiceNilInvoice(t *testing.T) { diff --git a/examples/fr/invoice-fr-de-ctc-b2bint.yaml b/examples/fr/invoice-fr-de-ctc-b2bint.yaml index bb5a36314..4f3fe862b 100644 --- a/examples/fr/invoice-fr-de-ctc-b2bint.yaml +++ b/examples/fr/invoice-fr-de-ctc-b2bint.yaml @@ -77,6 +77,9 @@ notes: text: "No discount offered for early payment." ext: untdid-text-subject: "AAB" + - text: "B2BINT" + ext: + untdid-text-subject: "BAR" payment: instructions: diff --git a/examples/fr/out/invoice-fr-de-ctc-b2bint.json b/examples/fr/out/invoice-fr-de-ctc-b2bint.json index 4a8223902..cf8ad87e0 100644 --- a/examples/fr/out/invoice-fr-de-ctc-b2bint.json +++ b/examples/fr/out/invoice-fr-de-ctc-b2bint.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "a92840e74805e6d90ff322344e421e9e0edabc54c306b54ab8d82b6d18496f3b" + "val": "9c3cf2a4f68045fb039c62df2498ed0eaf1cf02b5860fb4b339a49a36a8d6372" } }, "doc": { @@ -179,6 +179,12 @@ "ext": { "untdid-text-subject": "AAB" } + }, + { + "text": "B2BINT", + "ext": { + "untdid-text-subject": "BAR" + } } ] } From 46dba6f8258763c0b60294f3bb955e5882deb0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 11:22:27 +0000 Subject: [PATCH 11/26] flow10: detect B2C from absent customer instead of a tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The b2c distinction is naturally captured by Customer presence — a B2C sale is to an unidentified consumer, so the Customer slot is left unset. Drop the addon-specific TagB2C tag, the tags.go file, and the Tags wiring on the addon definition; flip invoiceIsB2C / paymentIsB2C to read Customer == nil. The b2c example loses its $tags and customer block accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow10/bill_invoice.go | 6 +++- addons/fr/ctc/flow10/bill_invoice_test.go | 1 - addons/fr/ctc/flow10/bill_payment.go | 4 ++- addons/fr/ctc/flow10/bill_payment_test.go | 1 - addons/fr/ctc/flow10/flow10.go | 4 --- addons/fr/ctc/flow10/tags.go | 34 ------------------- examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml | 10 ------ .../fr/out/invoice-fr-fr-ctc-flow10-b2c.json | 16 +-------- 8 files changed, 9 insertions(+), 67 deletions(-) delete mode 100644 addons/fr/ctc/flow10/tags.go diff --git a/addons/fr/ctc/flow10/bill_invoice.go b/addons/fr/ctc/flow10/bill_invoice.go index ee0ea831e..f320b27db 100644 --- a/addons/fr/ctc/flow10/bill_invoice.go +++ b/addons/fr/ctc/flow10/bill_invoice.go @@ -372,8 +372,12 @@ var vatKeyToUNTDIDCategory = map[cbc.Key]cbc.Code{ tax.KeyOutsideScope: "O", } +// invoiceIsB2C reports whether the invoice is a business-to-consumer +// transaction. Flow 10 distinguishes B2C from B2B by the presence of a +// Customer party — a B2C sale is to an unidentified consumer and so +// the Customer slot is left unset. func invoiceIsB2C(inv *bill.Invoice) bool { - return inv != nil && inv.Tags.HasTags(TagB2C) + return inv != nil && inv.Customer == nil } func normalizeInvoice(inv *bill.Invoice) { diff --git a/addons/fr/ctc/flow10/bill_invoice_test.go b/addons/fr/ctc/flow10/bill_invoice_test.go index 48465f11f..e9bbff724 100644 --- a/addons/fr/ctc/flow10/bill_invoice_test.go +++ b/addons/fr/ctc/flow10/bill_invoice_test.go @@ -90,7 +90,6 @@ func testInvoiceB2C(t *testing.T) *bill.Invoice { inv := &bill.Invoice{ Regime: tax.WithRegime("FR"), Addons: tax.WithAddons(V1), - Tags: tax.WithTags(TagB2C), Code: "INV-2026-B2C-001", Currency: "EUR", IssueDate: cal.MakeDate(2026, 1, 15), diff --git a/addons/fr/ctc/flow10/bill_payment.go b/addons/fr/ctc/flow10/bill_payment.go index d4680bfa3..10debcf1e 100644 --- a/addons/fr/ctc/flow10/bill_payment.go +++ b/addons/fr/ctc/flow10/bill_payment.go @@ -7,8 +7,10 @@ import ( "github.com/invopop/gobl/tax" ) +// paymentIsB2C reports whether the payment reports a B2C settlement, +// determined by the absence of a Customer party. func paymentIsB2C(pmt *bill.Payment) bool { - return pmt != nil && pmt.Tags.HasTags(TagB2C) + return pmt != nil && pmt.Customer == nil } func paymentIsB2BAny(v any) bool { diff --git a/addons/fr/ctc/flow10/bill_payment_test.go b/addons/fr/ctc/flow10/bill_payment_test.go index 6c8cef6c8..70b171dd0 100644 --- a/addons/fr/ctc/flow10/bill_payment_test.go +++ b/addons/fr/ctc/flow10/bill_payment_test.go @@ -47,7 +47,6 @@ func testPaymentB2C(t *testing.T) *bill.Payment { return &bill.Payment{ Regime: tax.WithRegime("FR"), Addons: tax.WithAddons(V1), - Tags: tax.WithTags(TagB2C), Type: bill.PaymentTypeReceipt, Code: "PAY-2026-B2C-001", Currency: "EUR", diff --git a/addons/fr/ctc/flow10/flow10.go b/addons/fr/ctc/flow10/flow10.go index e9ffb2a0c..34bb3bf9c 100644 --- a/addons/fr/ctc/flow10/flow10.go +++ b/addons/fr/ctc/flow10/flow10.go @@ -72,10 +72,6 @@ func newAddon() *tax.AddonDef { }, }, Extensions: extensions, - Tags: []*tax.TagSet{ - invoiceTags, - paymentTags, - }, Scenarios: scenarios, Normalizer: normalize, } diff --git a/addons/fr/ctc/flow10/tags.go b/addons/fr/ctc/flow10/tags.go deleted file mode 100644 index b2aabb9da..000000000 --- a/addons/fr/ctc/flow10/tags.go +++ /dev/null @@ -1,34 +0,0 @@ -package flow10 - -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/tax" -) - -// French CTC Flow 10 tag keys. Absence of a tag means the document is B2B. -const ( - // TagB2C marks a document as reporting a business-to-consumer transaction. - // Applied to both bill.Invoice and bill.Payment, it switches Flow 10 - // validation into the B2C rule set (no customer SIREN required, etc.). - TagB2C cbc.Key = "b2c" -) - -var b2cTagDef = &cbc.Definition{ - Key: TagB2C, - Name: i18n.String{ - i18n.EN: "B2C", - i18n.FR: "B2C", - }, -} - -var invoiceTags = &tax.TagSet{ - Schema: bill.ShortSchemaInvoice, - List: []*cbc.Definition{b2cTagDef}, -} - -var paymentTags = &tax.TagSet{ - Schema: bill.ShortSchemaPayment, - List: []*cbc.Definition{b2cTagDef}, -} diff --git a/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml b/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml index c73e973ef..17bd90300 100644 --- a/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml +++ b/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml @@ -1,8 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/invoice" $addons: - "fr-ctc-flow10-v1" -$tags: - - "b2c" uuid: "8c2b4a1d-d7e2-4d6e-9c1f-3a8b9d4f2e10" currency: "EUR" issue_date: "2026-04-15" @@ -29,14 +27,6 @@ supplier: code: "75015" country: "FR" -customer: - name: "Client Particulier" - addresses: - - street: "4 Rue Lafayette" - locality: "Lyon" - code: "69001" - country: "FR" - lines: - quantity: 1 item: diff --git a/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json index 187b79916..45e0695ed 100644 --- a/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json +++ b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "fc1e96ec7f527ed114e719694a9abcdce3c19457ee7ef950b3c294d61ad68901" + "val": "0fb627224d6cf49dc1cb7b5c06bf0ec0fdb20c5f6a11f0e5842d76599f5897dd" } }, "doc": { @@ -13,9 +13,6 @@ "$addons": [ "fr-ctc-flow10-v1" ], - "$tags": [ - "b2c" - ], "uuid": "8c2b4a1d-d7e2-4d6e-9c1f-3a8b9d4f2e10", "type": "standard", "series": "B2C", @@ -52,17 +49,6 @@ } ] }, - "customer": { - "name": "Client Particulier", - "addresses": [ - { - "street": "4 Rue Lafayette", - "locality": "Lyon", - "code": "69001", - "country": "FR" - } - ] - }, "lines": [ { "i": 1, From 8fcc56769c4a3cb51b755c95562b7fd484dfab63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 12:37:04 +0000 Subject: [PATCH 12/26] flow10: run party normalization on B2C invoices too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supplier-SIREN rule fires on both B2B and B2C, so the addon must also derive a SIREN-scheme identity from a French TaxID for B2C suppliers that ship without an Identities entry. Move normalizeParty calls before the B2C early return — Customer is nil for B2C so its call is a harmless no-op — and keep the B2C-specific bits (default TNT1 category) after. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow10/bill_invoice.go | 8 ++++++-- addons/fr/ctc/flow10/bill_invoice_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/addons/fr/ctc/flow10/bill_invoice.go b/addons/fr/ctc/flow10/bill_invoice.go index f320b27db..99300d6a1 100644 --- a/addons/fr/ctc/flow10/bill_invoice.go +++ b/addons/fr/ctc/flow10/bill_invoice.go @@ -385,12 +385,16 @@ func normalizeInvoice(inv *bill.Invoice) { return } normalizeInvoiceTaxCategories(inv) + // Party normalization (e.g. deriving a SIREN identity from a French + // TaxID) applies to both B2B and B2C: the supplier-SIREN rule fires + // in both branches, and on B2C the Customer slot is unset so the + // second call is a no-op. + normalizeParty(inv.Supplier) + normalizeParty(inv.Customer) if invoiceIsB2C(inv) { normalizeB2CCategoryOnInvoice(inv) return } - normalizeParty(inv.Supplier) - normalizeParty(inv.Customer) normalizeInvoiceBillingMode(inv) } diff --git a/addons/fr/ctc/flow10/bill_invoice_test.go b/addons/fr/ctc/flow10/bill_invoice_test.go index e9bbff724..ea71b12ce 100644 --- a/addons/fr/ctc/flow10/bill_invoice_test.go +++ b/addons/fr/ctc/flow10/bill_invoice_test.go @@ -275,6 +275,24 @@ func TestNormalizeGeneratesSIRENFromFrenchTaxID(t *testing.T) { assert.True(t, found, "expected SIREN identity to be generated from TaxID") } +// TestNormalizeB2CGeneratesSIRENFromFrenchTaxID confirms that party +// normalization also runs for B2C invoices, so a French supplier with +// only a TaxID set is still normalized into a SIREN-scheme identity +// (otherwise the supplier-SIREN rule would fail despite valid input). +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 (bill_invoice.go) ------------------------- func TestExtensionsValueNilPointer(t *testing.T) { From 8975a72523299d9eb2e5a5eaea3e8e730a4ff438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 27 Apr 2026 12:40:19 +0000 Subject: [PATCH 13/26] Fix flow2 / flow10 billing-mode constant comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-code Go comments said "Deposit of an already paid X invoice" (and similar) which contradicted the documented suffix semantics — the 2-suffix means "already paid", not "deposit". Rewrite each comment to state the actual meaning (B/S/M = goods / services / mixed; suffix = payment context), and add a header summarising the convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow10/extensions.go | 38 ++++++++++++++++++------------ addons/fr/ctc/flow2/extensions.go | 38 ++++++++++++++++++------------ 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/addons/fr/ctc/flow10/extensions.go b/addons/fr/ctc/flow10/extensions.go index a0bc308ec..f7499212c 100644 --- a/addons/fr/ctc/flow10/extensions.go +++ b/addons/fr/ctc/flow10/extensions.go @@ -34,37 +34,45 @@ const ( B2CCategoryMargin cbc.Code = "TMA1" ) -// Billing mode codes (Cadre de Facturation) -// The prefix indicates the invoice nature: +// Billing mode codes (Cadre de Facturation). +// The prefix denotes the invoice nature: // - B: Goods invoice (Biens) // - S: Services invoice // - M: Mixed/dual invoice (goods and services that are not accessory to each other) +// +// The numeric suffix encodes the payment context: +// - 1: standard invoice (payment outstanding) +// - 2: invoice already paid at issue +// - 4: final invoice issued after a down payment +// - 5: service invoice issued by a subcontractor +// - 6: service invoice issued by a co-contractor +// - 7: invoice subject to e-reporting (VAT already collected) const ( - // BillingModeB1: Deposit of a goods invoice + // BillingModeB1: goods invoice — payment outstanding. BillingModeB1 cbc.Code = "B1" - // BillingModeB2: Deposit of an already paid goods invoice + // BillingModeB2: goods invoice — already paid at issue. BillingModeB2 cbc.Code = "B2" - // BillingModeB4: Deposit of a final invoice (after down payment) for goods + // BillingModeB4: final goods invoice after a down payment. BillingModeB4 cbc.Code = "B4" - // BillingModeB7: Deposit of a goods invoice subject to e-reporting (VAT already collected) + // BillingModeB7: goods invoice subject to e-reporting (VAT already collected). BillingModeB7 cbc.Code = "B7" - // BillingModeS1: Deposit of a service invoice + // BillingModeS1: service invoice — payment outstanding. BillingModeS1 cbc.Code = "S1" - // BillingModeS2: Deposit of an already paid service invoice + // BillingModeS2: service invoice — already paid at issue. BillingModeS2 cbc.Code = "S2" - // BillingModeS4: Deposit of a final invoice (after down payment) for services + // BillingModeS4: final service invoice after a down payment. BillingModeS4 cbc.Code = "S4" - // BillingModeS5: Deposit by a subcontractor of a service invoice + // BillingModeS5: service invoice issued by a subcontractor. BillingModeS5 cbc.Code = "S5" - // BillingModeS6: Deposit by a co-contractor of a service invoice + // BillingModeS6: service invoice issued by a co-contractor. BillingModeS6 cbc.Code = "S6" - // BillingModeS7: Deposit of a service invoice subject to e-reporting (VAT already collected) + // BillingModeS7: service invoice subject to e-reporting (VAT already collected). BillingModeS7 cbc.Code = "S7" - // BillingModeM1: Deposit of a dual invoice (goods and services) + // BillingModeM1: mixed invoice (goods and services) — payment outstanding. BillingModeM1 cbc.Code = "M1" - // BillingModeM2: Deposit of an already paid dual invoice + // BillingModeM2: mixed invoice — already paid at issue. BillingModeM2 cbc.Code = "M2" - // BillingModeM4: Deposit of a final invoice (after down payment) - dual + // BillingModeM4: final mixed invoice after a down payment. BillingModeM4 cbc.Code = "M4" ) diff --git a/addons/fr/ctc/flow2/extensions.go b/addons/fr/ctc/flow2/extensions.go index 2d046b16b..22ac2f897 100644 --- a/addons/fr/ctc/flow2/extensions.go +++ b/addons/fr/ctc/flow2/extensions.go @@ -12,37 +12,45 @@ const ( ExtKeyBillingMode cbc.Key = "fr-ctc-billing-mode" ) -// Billing mode codes (Cadre de Facturation) -// The prefix indicates the invoice nature: +// Billing mode codes (Cadre de Facturation). +// The prefix denotes the invoice nature: // - B: Goods invoice (Biens) // - S: Services invoice // - M: Mixed/dual invoice (goods and services that are not accessory to each other) +// +// The numeric suffix encodes the payment context: +// - 1: standard invoice (payment outstanding) +// - 2: invoice already paid at issue +// - 4: final invoice issued after a down payment +// - 5: service invoice issued by a subcontractor +// - 6: service invoice issued by a co-contractor +// - 7: invoice subject to e-reporting (VAT already collected) const ( - // BillingModeB1: Deposit of a goods invoice + // BillingModeB1: goods invoice — payment outstanding. BillingModeB1 cbc.Code = "B1" - // BillingModeB2: Deposit of an already paid goods invoice + // BillingModeB2: goods invoice — already paid at issue. BillingModeB2 cbc.Code = "B2" - // BillingModeB4: Deposit of a final invoice (after down payment) for goods + // BillingModeB4: final goods invoice after a down payment. BillingModeB4 cbc.Code = "B4" - // BillingModeB7: Deposit of a goods invoice subject to e-reporting (VAT already collected) + // BillingModeB7: goods invoice subject to e-reporting (VAT already collected). BillingModeB7 cbc.Code = "B7" - // BillingModeS1: Deposit of a service invoice + // BillingModeS1: service invoice — payment outstanding. BillingModeS1 cbc.Code = "S1" - // BillingModeS2: Deposit of an already paid service invoice + // BillingModeS2: service invoice — already paid at issue. BillingModeS2 cbc.Code = "S2" - // BillingModeS4: Deposit of a final invoice (after down payment) for services + // BillingModeS4: final service invoice after a down payment. BillingModeS4 cbc.Code = "S4" - // BillingModeS5: Deposit by a subcontractor of a service invoice + // BillingModeS5: service invoice issued by a subcontractor. BillingModeS5 cbc.Code = "S5" - // BillingModeS6: Deposit by a co-contractor of a service invoice + // BillingModeS6: service invoice issued by a co-contractor. BillingModeS6 cbc.Code = "S6" - // BillingModeS7: Deposit of a service invoice subject to e-reporting (VAT already collected) + // BillingModeS7: service invoice subject to e-reporting (VAT already collected). BillingModeS7 cbc.Code = "S7" - // BillingModeM1: Deposit of a dual invoice (goods and services) + // BillingModeM1: mixed invoice (goods and services) — payment outstanding. BillingModeM1 cbc.Code = "M1" - // BillingModeM2: Deposit of an already paid dual invoice + // BillingModeM2: mixed invoice — already paid at issue. BillingModeM2 cbc.Code = "M2" - // BillingModeM4: Deposit of a final invoice (after down payment) - dual + // BillingModeM4: final mixed invoice after a down payment. BillingModeM4 cbc.Code = "M4" ) From 4e37e2616c76d311a9c080ed42a9aa8773d62595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Fri, 8 May 2026 13:51:30 +0000 Subject: [PATCH 14/26] flow6: tighten CDV validation + auto-derive party roles from key/type - Require Issuer and Recipient (MDG-16, MDG-23) with valid roles per BR-FR-CDV-CL-03 / CL-04; recipient must carry an inbox unless its role is WK or DFH (BR-FR-CDV-08). - Add a CDV "side" mapping (Annexe A "Acteurs CDV") that pins each process code to buyer-issued / seller-issued / platform-issued, and use it in normalize to fill the fr-ctc-role on Issuer and Recipient when the caller leaves it empty. - Propagate the SE-roled party's SIREN onto Supplier when missing so ref.IssuerTradeParty (MDT-129) is populated without forcing the caller to repeat the seller in a separate slot. - Add the BR-FR-CDV-CL-09 per-status allowed-reason-code table and validate it at the bill.Status level. - Reuse stock GOBL keys: paid + update -> 211, paid + response -> 212, acknowledged + response -> 202, querying + response -> 208 (drop payment-forwarded / received-by-platform / suspended additions). - Move the BR-FR-CDV-14 paid-MEN check to the status level so it only fires for response-phase paid (CDV-212), not transmission (CDV-211). - Refresh the FR Flow 6 examples to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow6/bill_status.go | 228 ++++++++++++++++-- addons/fr/ctc/flow6/bill_status_test.go | 108 ++++++++- addons/fr/ctc/flow6/codes.go | 179 ++++++++++++-- addons/fr/ctc/flow6/codes_test.go | 30 ++- .../out/status-fr-fr-ctc-flow6-accepted.json | 36 ++- .../fr/out/status-fr-fr-ctc-flow6-paid.json | 37 ++- .../fr/status-fr-fr-ctc-flow6-accepted.yaml | 24 +- examples/fr/status-fr-fr-ctc-flow6-paid.yaml | 28 ++- 8 files changed, 598 insertions(+), 72 deletions(-) diff --git a/addons/fr/ctc/flow6/bill_status.go b/addons/fr/ctc/flow6/bill_status.go index 922bd6854..b24af60b5 100644 --- a/addons/fr/ctc/flow6/bill_status.go +++ b/addons/fr/ctc/flow6/bill_status.go @@ -31,19 +31,131 @@ func normalizeStatus(st *bill.Status) { } } } - // Default party role for the two structural slots. Issuer and - // Recipient are left untouched: their role is context-dependent. - setPartyRoleDefault(st.Supplier, RoleSE) - setPartyRoleDefault(st.Customer, RoleBY) + + // Deduce the fr-ctc-role on Issuer / Recipient from the line's + // (key, type) → side mapping per Annexe A "Acteurs CDV". Saves the + // caller from spelling out a role that's already implied by the + // process code. If the caller already set a role, leave it alone. + 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. + // The seller's SIREN is what populates ref.IssuerTradeParty + // (MDT-129, BR-FR-CDV-13); when the seller already shows up as + // Issuer or Recipient, the caller shouldn't have to repeat the + // identity on Supplier. Only copies the SIREN identity (other + // fields stay caller-controlled). + if siren := siRENFromSEParty(st.Issuer, st.Recipient); siren != nil { + st.Supplier = ensureSIRENOnSupplier(st.Supplier, siren) + } +} + +// siRENFromSEParty returns the first SIREN identity carried by an +// SE-roled party among the given candidates, or nil. +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() == schemeIDSIREN { + 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. The identity is shallow-copied so caller-side mutations on +// the source don't leak. +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() == schemeIDSIREN && + 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. Empty strings mean "no derivation possible" — +// the caller must supply the role explicitly (e.g. platform-issued +// codes, where Issuer is WK and the recipient role varies). +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 || p.Ext.Get(ExtKeyRole) != "" { + 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) != "" +} + +// recipientHasInboxWhenRequired enforces BR-FR-CDV-08: if the recipient +// role is not WK or DFH, the URIID (inbox) is mandatory. +func recipientHasInboxWhenRequired(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", @@ -52,13 +164,32 @@ func billStatusRules() *rules.Set { ), ), rules.Field("supplier", - rules.Assert("02", "supplier is required on Flow 6 status messages", + rules.Assert("02", "supplier is required — its SIREN populates ref.IssuerTradeParty (MDT-129, 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", partyHasSIRENIdentity), ), ), + rules.Field("issuer", + rules.Assert("14", "issuer is required — maps to ExchangedDocument/IssuerTradeParty (MDG-16) per BR-FR-CDV-CL-03", + is.Present, + ), + rules.Assert("15", "issuer.ext.fr-ctc-role must be set; the allowed values depend on ack TypeCode (BR-FR-CDV-CL-03)", + is.Func("issuer has fr-ctc-role", partyHasRole), + ), + ), + rules.Field("recipient", + rules.Assert("16", "recipient is required — maps to ExchangedDocument/RecipientTradeParty (MDG-23) per BR-FR-CDV-CL-04", + is.Present, + ), + rules.Assert("17", "recipient.ext.fr-ctc-role must be set (BR-FR-CDV-CL-04: BY/DL/SE/AB/SR/PE/PR/II/IV/WK/DFH)", + 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", recipientHasInboxWhenRequired), + ), + ), rules.Field("lines", rules.Assert("04", "exactly one status line is required (CDAR carries a single status per CDV message)", is.Func("exactly one line", statusHasExactlyOneLine), @@ -85,9 +216,6 @@ func billStatusRules() *rules.Set { 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("07", "status line with key 'paid' (CDAR 212) must carry a Characteristic complement with Amount (value + currency) set — this is the MEN", - is.Func("amount received set when paid", statusLinePaidHasAmount), - ), 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), ), @@ -96,9 +224,15 @@ func billStatusRules() *rules.Set { ), ), ), - rules.Assert("08", "Status.Type must match the Type implied by each StatusLine.Key", + 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), + ), ) } @@ -135,23 +269,38 @@ func statusLineKeyKnown(v any) bool { if !ok || line == nil { return false } - _, ok = statusTypeForKey(line.Key) - return ok + return statusKeyKnown(line.Key) } -// statusLinePaidHasAmount checks that a paid StatusLine carries a -// Characteristic complement with TypeCode=MEN and Amount populated -// (both value and currency). Other payment-related TypeCodes (MPA, -// RAP, etc.) may coexist on the same line but do not substitute for -// the mandatory MEN. -func statusLinePaidHasAmount(v any) bool { - line, ok := v.(*bill.StatusLine) - if !ok || line == nil { +// 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. The same +// `paid` key on an update status (CDAR 211 Paiement transmis) does +// not require the MEN — that's why this rule is at the status level +// and gated on st.Type. +func statusPaidResponseHasAmount(v any) bool { + st, ok := v.(*bill.Status) + if !ok || st == nil { return true } - if line.Key != bill.StatusEventPaid { + 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 +// flow6.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 @@ -238,9 +387,44 @@ func lineHasReasonCode(line *bill.StatusLine, code cbc.Code) bool { var reasonRequiredStatusKeys = []cbc.Key{ bill.StatusEventRejected, bill.StatusEventError, + bill.StatusEventQuerying, StatusEventDisputed, StatusEventPartiallyAccepted, - StatusEventSuspended, +} + +// statusReasonCodesAllowed enforces BR-FR-CDV-CL-09 at the +// bill.Status level: each Reason on each line must carry an +// fr-ctc-reason-code permitted for the (line.Key, st.Type) → +// ProcessConditionCode pair. Lives at the status level because the +// pair-lookup needs Type — line-only keys like `paid` are ambiguous +// (211 update vs 212 response) without it. +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 { diff --git a/addons/fr/ctc/flow6/bill_status_test.go b/addons/fr/ctc/flow6/bill_status_test.go index d0ed4b804..b4179724b 100644 --- a/addons/fr/ctc/flow6/bill_status_test.go +++ b/addons/fr/ctc/flow6/bill_status_test.go @@ -58,6 +58,9 @@ func testStatus(t *testing.T) *bill.Status { IssueDate: cal.MakeDate(2026, 2, 2), Code: "STA-2026-0001", Supplier: frPartyWithSIREN(), + Customer: customerParty(), + Issuer: issuerParty(), + Recipient: recipientParty(), Lines: []*bill.StatusLine{ { Key: bill.StatusEventAccepted, @@ -71,6 +74,50 @@ func testStatus(t *testing.T) *bill.Status { } } +// issuerParty returns a buyer-end-party Issuer suitable for ack 23 +// (BR-FR-CDV-CL-03 allowed list: BY/AB/DL/SE/SR/PE/PR/II/IV). It +// carries an inbox so the GOBL-side "sender reachable" rule passes +// without forcing a Supplier inbox in every test. +func issuerParty() *org.Party { + return &org.Party{ + Name: "ACHETEUR", + Identities: []*org.Identity{{ + Code: "200000008", + Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, schemeIDSIREN), + }}, + Inboxes: []*org.Inbox{{Scheme: "0225", Code: "200000008_PEP"}}, + Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleBY), + } +} + +// customerParty returns a buyer-side counterpart used as the +// (optionally derived) Issuer/Recipient party. +func customerParty() *org.Party { + return &org.Party{ + Name: "ACHETEUR", + Identities: []*org.Identity{{ + Code: "200000008", + Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, schemeIDSIREN), + }}, + Inboxes: []*org.Inbox{{Scheme: "0225", Code: "200000008_PEP"}}, + Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleBY), + } +} + +// recipientParty returns the seller-end-party counterpart with an +// inbox, satisfying BR-FR-CDV-08 (recipient role ≠ WK/DFH ⇒ URIID). +func recipientParty() *org.Party { + return &org.Party{ + Name: "VENDEUR", + Identities: []*org.Identity{{ + Code: "100000009", + Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, schemeIDSIREN), + }}, + Inboxes: []*org.Inbox{{Scheme: "0225", Code: "100000009_PEP"}}, + Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleSE), + } +} + // --- bill.Status validation ---------------------------------------------- func TestStatusHappyPath(t *testing.T) { @@ -78,7 +125,6 @@ func TestStatusHappyPath(t *testing.T) { runNormalize(t, st) require.NoError(t, rules.Validate(st)) assert.Equal(t, bill.StatusTypeResponse, st.Type) - assert.Equal(t, RoleSE, st.Supplier.Ext.Get(ExtKeyRole)) } func TestStatusRejectsSystemType(t *testing.T) { @@ -92,17 +138,37 @@ func TestStatusRejectsSystemType(t *testing.T) { func TestStatusSupplierSIRENRequired(t *testing.T) { st := testStatus(t) st.Supplier.Identities = nil + // Strip the SE party's identity too so the normaliser can't + // auto-populate Supplier from it. + st.Recipient.Identities = nil runNormalize(t, st) err := rules.Validate(st) assert.ErrorContains(t, err, "SIREN") } +// TestStatusSupplierSIRENFilledFromSEParty exercises the normaliser: +// when Supplier is missing its SIREN identity and an SE-roled party +// (Issuer or Recipient) carries one, the SIREN propagates onto +// Supplier so the writer can populate ref.IssuerTradeParty (MDT-129) +// without the caller repeating the seller in two places. +func TestStatusSupplierSIRENFilledFromSEParty(t *testing.T) { + st := testStatus(t) + st.Supplier = nil // recipient is SE-roled with SIREN 100000009 + 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("100000009"), st.Supplier.Identities[0].Code) + assert.Equal(t, schemeIDSIREN, + st.Supplier.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) +} + 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 match") + assert.ErrorContains(t, err, "Status.Type must be a valid pair") } func TestStatusRejectsMultipleLines(t *testing.T) { @@ -185,7 +251,8 @@ func TestStatusDisputedRequiresReason(t *testing.T) { func TestStatusSuspendedRequiresReason(t *testing.T) { st := testStatus(t) - st.Lines[0].Key = StatusEventSuspended + // CDV-208 Suspendue maps to stock `querying + response`. + st.Lines[0].Key = bill.StatusEventQuerying runNormalize(t, st) err := rules.Validate(st) assert.ErrorContains(t, err, "require at least one reason") @@ -218,6 +285,7 @@ func TestStatusAcceptedDoesNotRequireReason(t *testing.T) { func TestStatusPaidRequiresAmount(t *testing.T) { st := testStatus(t) st.Lines[0].Key = bill.StatusEventPaid + st.Type = bill.StatusTypeResponse // pin: paid pairs with both update and response runNormalize(t, st) err := rules.Validate(st) assert.ErrorContains(t, err, "MEN") @@ -226,6 +294,9 @@ func TestStatusPaidRequiresAmount(t *testing.T) { func TestStatusPaidSatisfiedByComplement(t *testing.T) { st := testStatus(t) st.Lines[0].Key = bill.StatusEventPaid + // `paid` maps to two CDAR codes (211/update, 212/response) so the + // normaliser cannot default Type from the key alone — pin it. + st.Type = bill.StatusTypeResponse obj, err := schema.NewObject(&Characteristic{ TypeCode: TypeCodeAmountReceived, Amount: ¤cy.Amount{ @@ -242,6 +313,7 @@ func TestStatusPaidSatisfiedByComplement(t *testing.T) { 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)}, @@ -256,6 +328,7 @@ func TestStatusPaidWithoutMENFailsEvenWithOtherTypes(t *testing.T) { 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)}, @@ -290,7 +363,7 @@ func TestStatusCharacteristicReasonLinkMismatch(t *testing.T) { st.Lines[0].Key = bill.StatusEventRejected st.Lines[0].Reasons = []*bill.Reason{{ Key: bill.ReasonKeyItems, - Ext: tax.ExtensionsOf(tax.ExtMap{ExtKeyReasonCode: "ART_ERR"}), + Ext: tax.ExtensionsOf(tax.ExtMap{ExtKeyReasonCode: "TX_TVA_ERR"}), }} obj, err := schema.NewObject(&Characteristic{ ReasonCode: "QTE_ERR", // not matching any sibling reason @@ -308,11 +381,11 @@ func TestStatusCharacteristicReasonLinkMatch(t *testing.T) { st := testStatus(t) st.Lines[0].Key = bill.StatusEventRejected st.Lines[0].Reasons = []*bill.Reason{{ - Key: bill.ReasonKeyItems, - Ext: tax.ExtensionsOf(tax.ExtMap{ExtKeyReasonCode: "ART_ERR"}), + Key: bill.ReasonKeyLegal, + Ext: tax.ExtensionsOf(tax.ExtMap{ExtKeyReasonCode: "TX_TVA_ERR"}), }} obj, err := schema.NewObject(&Characteristic{ - ReasonCode: "ART_ERR", + ReasonCode: "TX_TVA_ERR", Name: "description", Value: "corrected", }) @@ -407,12 +480,25 @@ func TestStatusLineKeyKnownWrongType(t *testing.T) { assert.False(t, statusLineKeyKnown("x")) } -func TestStatusLinePaidHasAmountWrongType(t *testing.T) { - assert.True(t, statusLinePaidHasAmount(42)) +func TestStatusPaidResponseHasAmountWrongType(t *testing.T) { + assert.True(t, statusPaidResponseHasAmount(42)) } -func TestStatusLinePaidHasAmountNonPaidLine(t *testing.T) { - assert.True(t, statusLinePaidHasAmount(&bill.StatusLine{Key: bill.StatusEventAccepted})) +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) { + // `paid + update` (CDV-211) does NOT require MEN. + st := &bill.Status{ + Type: bill.StatusTypeUpdate, + Lines: []*bill.StatusLine{{Key: bill.StatusEventPaid}}, + } + assert.True(t, statusPaidResponseHasAmount(st)) } func TestStatusLineTypeCodesKnownWrongType(t *testing.T) { diff --git a/addons/fr/ctc/flow6/codes.go b/addons/fr/ctc/flow6/codes.go index fc523cbc0..b1277f991 100644 --- a/addons/fr/ctc/flow6/codes.go +++ b/addons/fr/ctc/flow6/codes.go @@ -1,28 +1,44 @@ package flow6 import ( + "slices" + "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" ) -// Extended bill.StatusLine.Key values added by Flow 6 so every CDAR -// ProcessConditionCode maps 1:1 to a (key, type) pair. GOBL ships the -// "plain" keys (issued, processing, accepted, rejected, paid, error); -// the ones marked here are France-specific additions needed for CDAR. +// 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) +// 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 ( - StatusEventIssuedByPlatform cbc.Key = "issued-by-platform" - StatusEventReceivedByPlatform cbc.Key = "received-by-platform" - StatusEventMadeAvailable cbc.Key = "made-available" - StatusEventPartiallyAccepted cbc.Key = "partially-accepted" - StatusEventDisputed cbc.Key = "disputed" - StatusEventSuspended cbc.Key = "suspended" - StatusEventCompleted cbc.Key = "completed" - StatusEventPaymentForwarded cbc.Key = "payment-forwarded" + StatusEventIssuedByPlatform cbc.Key = "issued-by-platform" + 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 it -// implies. For Flow 6 the pair is always fixed per key: the Type is a -// property of the CDAR code, not a disambiguator. +// processEntry pairs a bill.StatusLine.Key with the bill.Status.Type +// the CDV expects, alongside the wire ProcessConditionCode. A key may +// appear in multiple entries when its (key, type) pairs map to +// different CDAR codes — that is what allows us to share `paid` +// across CDV-211 (update) and CDV-212 (response). type processEntry struct { Key cbc.Key Type cbc.Key @@ -34,16 +50,16 @@ type processEntry struct { var processTable = []processEntry{ {bill.StatusEventIssued, bill.StatusTypeUpdate, "200"}, {StatusEventIssuedByPlatform, bill.StatusTypeUpdate, "201"}, - {StatusEventReceivedByPlatform, bill.StatusTypeResponse, "202"}, + {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"}, - {StatusEventSuspended, bill.StatusTypeResponse, "208"}, + {bill.StatusEventQuerying, bill.StatusTypeResponse, "208"}, {StatusEventCompleted, bill.StatusTypeResponse, "209"}, {bill.StatusEventRejected, bill.StatusTypeResponse, "210"}, - {StatusEventPaymentForwarded, bill.StatusTypeUpdate, "211"}, + {bill.StatusEventPaid, bill.StatusTypeUpdate, "211"}, {bill.StatusEventPaid, bill.StatusTypeResponse, "212"}, {bill.StatusEventError, bill.StatusTypeResponse, "213"}, } @@ -71,16 +87,38 @@ func StatusKeyFor(code string) (cbc.Key, cbc.Key, bool) { return "", "", false } -// statusTypeForKey returns the fixed Status.Type associated with a -// StatusLine.Key for Flow 6. The second return is false if the key has -// no CDAR entry. +// statusTypeForKey returns the Status.Type associated with a +// StatusLine.Key for Flow 6 *if the key has exactly one*. Returns +// ("", false) when the key is unknown OR when the same key is shared +// across multiple types (e.g. `paid` covers both update/211 and +// response/212) — in that case the caller must specify Type explicitly. 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 (regardless of how many types it +// pairs with). +func statusKeyKnown(key cbc.Key) bool { for _, e := range processTable { if e.Key == key { - return e.Type, true + return true } } - return "", false + return false } // reasonEntry pairs a CDAR ReasonCode with its bucket bill.Reason.Key @@ -184,6 +222,101 @@ var actionTable = []struct { {"OTH", bill.ActionKeyOther}, } +// CDVSide reports which end-party plays the Issuer role on a CDV +// message of the given process code. Used by the cii writer to +// auto-fill IssuerTradeParty / RecipientTradeParty from Supplier and +// Customer when the caller has not set them explicitly. The mapping +// follows Annexe A "Acteurs CDV". +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. The caller must supply st.Issuer (and typically + // st.Recipient) explicitly. + CDVSidePlatform CDVSide = "platform" +) + +// SideForCode returns which end-party issues a CDV with the given +// CDAR ProcessConditionCode (per Annexe A "Acteurs CDV", treatment +// phase). Returns CDVSideUnknown for codes not in the table. +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. Codes not listed here either don't carry +// reasons (200, 201, 202, 203, 204, 205, 209, 211, 212) or carry any +// reason (the table is the strict list per Annexe A "Tableau des motifs +// de STATUTS"). +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. Returns true when the process code does not constrain the +// reason set (i.e. it isn't in the BR-FR-CDV-CL-09 table). +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) { diff --git a/addons/fr/ctc/flow6/codes_test.go b/addons/fr/ctc/flow6/codes_test.go index 82529106b..14881c673 100644 --- a/addons/fr/ctc/flow6/codes_test.go +++ b/addons/fr/ctc/flow6/codes_test.go @@ -30,8 +30,8 @@ func TestProcessCode201IssuedByPlatform(t *testing.T) { assertProcessRoundTrip(t, "201", StatusEventIssuedByPlatform, bill.StatusTypeUpdate) } -func TestProcessCode202ReceivedByPlatform(t *testing.T) { - assertProcessRoundTrip(t, "202", StatusEventReceivedByPlatform, bill.StatusTypeResponse) +func TestProcessCode202Acknowledged(t *testing.T) { + assertProcessRoundTrip(t, "202", bill.StatusEventAcknowledged, bill.StatusTypeResponse) } func TestProcessCode203MadeAvailable(t *testing.T) { @@ -54,8 +54,8 @@ func TestProcessCode207Disputed(t *testing.T) { assertProcessRoundTrip(t, "207", StatusEventDisputed, bill.StatusTypeResponse) } -func TestProcessCode208Suspended(t *testing.T) { - assertProcessRoundTrip(t, "208", StatusEventSuspended, bill.StatusTypeResponse) +func TestProcessCode208Querying(t *testing.T) { + assertProcessRoundTrip(t, "208", bill.StatusEventQuerying, bill.StatusTypeResponse) } func TestProcessCode209Completed(t *testing.T) { @@ -66,11 +66,11 @@ func TestProcessCode210Rejected(t *testing.T) { assertProcessRoundTrip(t, "210", bill.StatusEventRejected, bill.StatusTypeResponse) } -func TestProcessCode211PaymentForwarded(t *testing.T) { - assertProcessRoundTrip(t, "211", StatusEventPaymentForwarded, bill.StatusTypeUpdate) +func TestProcessCode211PaidUpdate(t *testing.T) { + assertProcessRoundTrip(t, "211", bill.StatusEventPaid, bill.StatusTypeUpdate) } -func TestProcessCode212Paid(t *testing.T) { +func TestProcessCode212PaidResponse(t *testing.T) { assertProcessRoundTrip(t, "212", bill.StatusEventPaid, bill.StatusTypeResponse) } @@ -84,11 +84,23 @@ func TestProcessCodeUnknownReturnsFalse(t *testing.T) { } func TestProcessKeyTypeMismatchReturnsFalse(t *testing.T) { - // paid is a response code; querying with Type=update must miss. - _, ok := CDARProcessCodeFor(bill.StatusEventPaid, bill.StatusTypeUpdate) + // `accepted` is response-only (CDV-205); querying with Type=update + // must miss. + _, ok := CDARProcessCodeFor(bill.StatusEventAccepted, bill.StatusTypeUpdate) assert.False(t, ok) } +// `paid` is the one key that pairs with both Status.Type values: +// update→211 (Paiement transmis), response→212 (Encaissée). +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) { diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json index 3dd6aed5a..09b576bea 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "e231e613bbb31ad364cd5b9815c69193b739eb5977cbbf533d0e985ebe2b3cfc" + "val": "bee2436e00a40f03233ce58ee43fd3a86268f8e60f0bb2ce328809b9d4ef616d" } }, "doc": { @@ -17,7 +17,6 @@ "issue_date": "2026-04-16", "code": "STA-2026-0001", "supplier": { - "name": "Plateforme Agreee SARL", "identities": [ { "type": "SIREN", @@ -26,6 +25,39 @@ "iso-scheme-id": "0002" } } + ] + }, + "issuer": { + "name": "ACHETEUR SARL", + "identities": [ + { + "type": "SIREN", + "code": "200000008", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "ext": { + "fr-ctc-role": "BY" + } + }, + "recipient": { + "name": "VENDEUR SARL", + "identities": [ + { + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "inboxes": [ + { + "scheme": "0225", + "code": "732829320_PEP" + } ], "ext": { "fr-ctc-role": "SE" diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json index 3a8ae04fd..0d1369478 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "ed8422596919262af603b76fb2038d9d7f60d4440af7c8abfa6793bd07b3aee2" + "val": "f791ae8f734b6259df60c7a10200022b76483f40db8ddcc8955b7d21b82d44fb" } }, "doc": { @@ -17,7 +17,19 @@ "issue_date": "2026-05-02", "code": "STA-2026-0002", "supplier": { - "name": "Plateforme Agreee SARL", + "name": "VENDEUR SARL", + "identities": [ + { + "type": "SIREN", + "code": "732829320", + "ext": { + "iso-scheme-id": "0002" + } + } + ] + }, + "issuer": { + "name": "VENDEUR SARL", "identities": [ { "type": "SIREN", @@ -31,6 +43,27 @@ "fr-ctc-role": "SE" } }, + "recipient": { + "name": "ACHETEUR SARL", + "identities": [ + { + "type": "SIREN", + "code": "200000008", + "ext": { + "iso-scheme-id": "0002" + } + } + ], + "inboxes": [ + { + "scheme": "0225", + "code": "200000008_PEP" + } + ], + "ext": { + "fr-ctc-role": "BY" + } + }, "lines": [ { "index": 1, diff --git a/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml index 1d6c294f5..867b44587 100644 --- a/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml +++ b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml @@ -5,13 +5,33 @@ uuid: "2a5b6c7d-8e9f-4a1b-2c3d-4e5f6a7b8c01" issue_date: "2026-04-16" code: "STA-2026-0001" -supplier: +# 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" + +recipient: + name: "VENDEUR SARL" identities: - type: "SIREN" code: "732829320" ext: iso-scheme-id: "0002" - name: "Plateforme Agreee SARL" + inboxes: + - scheme: "0225" + code: "732829320_PEP" lines: - key: "accepted" diff --git a/examples/fr/status-fr-fr-ctc-flow6-paid.yaml b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml index 086f0ab63..ae3c0cef3 100644 --- a/examples/fr/status-fr-fr-ctc-flow6-paid.yaml +++ b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml @@ -4,14 +4,40 @@ $addons: 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" - name: "Plateforme Agreee SARL" + +# `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" + +recipient: + name: "ACHETEUR SARL" + identities: + - type: "SIREN" + code: "200000008" + ext: + iso-scheme-id: "0002" + inboxes: + - scheme: "0225" + code: "200000008_PEP" lines: - key: "paid" From 27218617cb301927728a444dc032e618e4b1127c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 13:00:39 +0000 Subject: [PATCH 15/26] flow6: require issuer inbox on bill.Status (BR-FR-CDV-08) Share the inbox-when-not-WK/DFH helper across issuer and recipient. CDAR DocumentTypeCode 23 is always the case for this addon, so BR-FR-CDV-08 applies unconditionally to both parties; malformed imports are rejected on the CII side instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow6/bill_status.go | 16 ++++++++++------ addons/fr/ctc/flow6/bill_status_test.go | 4 ++-- addons/fr/ctc/flow6/extensions.go | 5 +++-- .../fr/out/status-fr-fr-ctc-flow6-accepted.json | 8 +++++++- examples/fr/out/status-fr-fr-ctc-flow6-paid.json | 8 +++++++- examples/fr/status-fr-fr-ctc-flow6-accepted.yaml | 3 +++ examples/fr/status-fr-fr-ctc-flow6-paid.yaml | 3 +++ 7 files changed, 35 insertions(+), 12 deletions(-) diff --git a/addons/fr/ctc/flow6/bill_status.go b/addons/fr/ctc/flow6/bill_status.go index b24af60b5..22b4bf719 100644 --- a/addons/fr/ctc/flow6/bill_status.go +++ b/addons/fr/ctc/flow6/bill_status.go @@ -137,9 +137,11 @@ func partyHasRole(v any) bool { return p.Ext.Get(ExtKeyRole) != "" } -// recipientHasInboxWhenRequired enforces BR-FR-CDV-08: if the recipient -// role is not WK or DFH, the URIID (inbox) is mandatory. -func recipientHasInboxWhenRequired(v any) bool { +// partyHasInboxWhenRequired enforces BR-FR-CDV-08: a party whose role +// is not WK (legal representative) or DFH (declarant for VAT grouping) +// must carry a URIID (electronic inbox). We enforce for both issuer +// and supplier to simplify emmission. +func partyHasInboxWhenRequired(v any) bool { p, ok := v.(*org.Party) if !ok || p == nil { return true @@ -178,6 +180,9 @@ func billStatusRules() *rules.Set { rules.Assert("15", "issuer.ext.fr-ctc-role must be set; the allowed values depend on ack TypeCode (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 — maps to ExchangedDocument/RecipientTradeParty (MDG-23) per BR-FR-CDV-CL-04", @@ -187,7 +192,7 @@ func billStatusRules() *rules.Set { 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", recipientHasInboxWhenRequired), + is.Func("recipient has inbox unless WK/DFH", partyHasInboxWhenRequired), ), ), rules.Field("lines", @@ -276,8 +281,7 @@ func statusLineKeyKnown(v any) bool { // key=paid on a response status (CDAR 212 Encaissée) must carry a // Characteristic with TypeCode=MEN and Amount populated. The same // `paid` key on an update status (CDAR 211 Paiement transmis) does -// not require the MEN — that's why this rule is at the status level -// and gated on st.Type. +// not require the MEN. func statusPaidResponseHasAmount(v any) bool { st, ok := v.(*bill.Status) if !ok || st == nil { diff --git a/addons/fr/ctc/flow6/bill_status_test.go b/addons/fr/ctc/flow6/bill_status_test.go index b4179724b..81664d67f 100644 --- a/addons/fr/ctc/flow6/bill_status_test.go +++ b/addons/fr/ctc/flow6/bill_status_test.go @@ -76,8 +76,8 @@ func testStatus(t *testing.T) *bill.Status { // issuerParty returns a buyer-end-party Issuer suitable for ack 23 // (BR-FR-CDV-CL-03 allowed list: BY/AB/DL/SE/SR/PE/PR/II/IV). It -// carries an inbox so the GOBL-side "sender reachable" rule passes -// without forcing a Supplier inbox in every test. +// carries an inbox so BR-FR-CDV-08 (issuer role ≠ WK/DFH ⇒ URIID) +// is satisfied. func issuerParty() *org.Party { return &org.Party{ Name: "ACHETEUR", diff --git a/addons/fr/ctc/flow6/extensions.go b/addons/fr/ctc/flow6/extensions.go index f5fa706ef..b0e12a15d 100644 --- a/addons/fr/ctc/flow6/extensions.go +++ b/addons/fr/ctc/flow6/extensions.go @@ -11,7 +11,8 @@ import ( const ( // ExtKeyRole carries the CDAR RoleCode for a party (UNCL 3035 subset). // Applied per populated party (Supplier / Customer / Issuer / Recipient) - // on a bill.Status message. + // on a bill.Status message. For example, an issuer party with RoleCode=SE + // is the seller. ExtKeyRole cbc.Key = "fr-ctc-role" // ExtKeyReasonCode pins the exact CDAR ReasonCode for a bill.Reason. @@ -56,7 +57,7 @@ var extensions = []*cbc.Definition{ {Code: RoleWK, Name: i18n.String{i18n.EN: "Work / Service Receiver"}}, {Code: RoleDFH, Name: i18n.String{i18n.EN: "Delivery From"}}, {Code: RoleAB, Name: i18n.String{i18n.EN: "Bank"}}, - {Code: RoleSR, Name: i18n.String{i18n.EN: "Sender"}}, + {Code: RoleSR, Name: i18n.String{i18n.EN: "Sender / Issuer on behalf of"}}, {Code: RoleDL, Name: i18n.String{i18n.EN: "Dealer"}}, {Code: RolePE, Name: i18n.String{i18n.EN: "Payee"}}, {Code: RolePR, Name: i18n.String{i18n.EN: "Payer"}}, diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json index 09b576bea..a1cd2295b 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "bee2436e00a40f03233ce58ee43fd3a86268f8e60f0bb2ce328809b9d4ef616d" + "val": "02881541cc28ba94c5de53383f785273cbc21403fe2955ae90378081bc48c470" } }, "doc": { @@ -38,6 +38,12 @@ } } ], + "inboxes": [ + { + "scheme": "0225", + "code": "200000008_PEP" + } + ], "ext": { "fr-ctc-role": "BY" } diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json index 0d1369478..87a8c7ba8 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "f791ae8f734b6259df60c7a10200022b76483f40db8ddcc8955b7d21b82d44fb" + "val": "853810a6d1af963e160c3ac127462b05957997bf002f658ee20bac900922ecc2" } }, "doc": { @@ -39,6 +39,12 @@ } } ], + "inboxes": [ + { + "scheme": "0225", + "code": "732829320_PEP" + } + ], "ext": { "fr-ctc-role": "SE" } diff --git a/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml index 867b44587..bb7db3df6 100644 --- a/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml +++ b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml @@ -21,6 +21,9 @@ issuer: code: "200000008" ext: iso-scheme-id: "0002" + inboxes: + - scheme: "0225" + code: "200000008_PEP" recipient: name: "VENDEUR SARL" diff --git a/examples/fr/status-fr-fr-ctc-flow6-paid.yaml b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml index ae3c0cef3..87f8008e1 100644 --- a/examples/fr/status-fr-fr-ctc-flow6-paid.yaml +++ b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml @@ -27,6 +27,9 @@ issuer: code: "732829320" ext: iso-scheme-id: "0002" + inboxes: + - scheme: "0225" + code: "732829320_PEP" recipient: name: "ACHETEUR SARL" From f7d680d7ef36a7cf299d3c3d3c4e330b734d7049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 13:03:38 +0000 Subject: [PATCH 16/26] flow6: drop issued-by-platform; reuse issued + response for CDV-201 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the paid/211/212 pattern: the `issued` key now covers both update/200 (Déposée) and response/201 (Émise par la plateforme), with Status.Type distinguishing the two. Callers reporting platform-side issuance no longer need a separate event key. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow6/codes.go | 25 +++++++++++++------------ addons/fr/ctc/flow6/codes_test.go | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/addons/fr/ctc/flow6/codes.go b/addons/fr/ctc/flow6/codes.go index b1277f991..af29bd62b 100644 --- a/addons/fr/ctc/flow6/codes.go +++ b/addons/fr/ctc/flow6/codes.go @@ -14,20 +14,21 @@ import ( // semantic but distinguish transmission vs treatment phase via Type. // // Stock keys carrying CDAR codes: -// issued + update → 200 (Déposée) -// 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) +// +// 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 ( - StatusEventIssuedByPlatform cbc.Key = "issued-by-platform" StatusEventMadeAvailable cbc.Key = "made-available" StatusEventPartiallyAccepted cbc.Key = "partially-accepted" StatusEventDisputed cbc.Key = "disputed" @@ -49,7 +50,7 @@ type processEntry struct { // Flow 6 CDAR messages. Order is stable and matches the spec table. var processTable = []processEntry{ {bill.StatusEventIssued, bill.StatusTypeUpdate, "200"}, - {StatusEventIssuedByPlatform, bill.StatusTypeUpdate, "201"}, + {bill.StatusEventIssued, bill.StatusTypeResponse, "201"}, {bill.StatusEventAcknowledged, bill.StatusTypeResponse, "202"}, {StatusEventMadeAvailable, bill.StatusTypeResponse, "203"}, {bill.StatusEventProcessing, bill.StatusTypeResponse, "204"}, diff --git a/addons/fr/ctc/flow6/codes_test.go b/addons/fr/ctc/flow6/codes_test.go index 14881c673..3ceb8e9a0 100644 --- a/addons/fr/ctc/flow6/codes_test.go +++ b/addons/fr/ctc/flow6/codes_test.go @@ -27,7 +27,7 @@ func TestProcessCode200Issued(t *testing.T) { } func TestProcessCode201IssuedByPlatform(t *testing.T) { - assertProcessRoundTrip(t, "201", StatusEventIssuedByPlatform, bill.StatusTypeUpdate) + assertProcessRoundTrip(t, "201", bill.StatusEventIssued, bill.StatusTypeResponse) } func TestProcessCode202Acknowledged(t *testing.T) { From 6692dfe3589fe7c0950ca24832a4b88bd5849eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 13:10:20 +0000 Subject: [PATCH 17/26] flow6: surface CDAR ProcessConditionCode as bill.Status ext Add fr-ctc-status-code, populated by the normalizer from (line.Key, Status.Type) and cross-checked at validation time. Makes the wire-level event identifier visible on the GOBL document and gives callers round-tripping a parsed CDV a place to pin a specific code. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow6/bill_status.go | 35 +++++++++++ addons/fr/ctc/flow6/extensions.go | 58 +++++++++++++++++++ .../out/status-fr-fr-ctc-flow6-accepted.json | 5 +- .../fr/out/status-fr-fr-ctc-flow6-paid.json | 5 +- 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/addons/fr/ctc/flow6/bill_status.go b/addons/fr/ctc/flow6/bill_status.go index 22b4bf719..cd316eea6 100644 --- a/addons/fr/ctc/flow6/bill_status.go +++ b/addons/fr/ctc/flow6/bill_status.go @@ -55,6 +55,16 @@ func normalizeStatus(st *bill.Status) { if siren := siRENFromSEParty(st.Issuer, st.Recipient); siren != nil { st.Supplier = ensureSIRENOnSupplier(st.Supplier, siren) } + + // Surface the CDAR ProcessConditionCode on the document so the + // wire-level event identifier is visible without consulting the + // converter. Skip when the caller already pinned a code (e.g. + // round-tripping a parsed CDV) — the validator catches mismatches. + 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 @@ -238,9 +248,34 @@ func billStatusRules() *rules.Set { 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), + ), ) } +// statusCodeMatchesLine ensures the fr-ctc-status-code ext, when set, +// is consistent with the (line.Key, Status.Type) pair. Empty ext is +// permitted on input — the normalizer fills it. +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 — a single line on the // bill.Status. Multiple lines would map to multiple CDARs and must be diff --git a/addons/fr/ctc/flow6/extensions.go b/addons/fr/ctc/flow6/extensions.go index b0e12a15d..24954a215 100644 --- a/addons/fr/ctc/flow6/extensions.go +++ b/addons/fr/ctc/flow6/extensions.go @@ -19,6 +19,13 @@ const ( // When set, takes precedence over the default_for_key lookup that the // converter would otherwise perform from Reason.Key. ExtKeyReasonCode cbc.Key = "fr-ctc-reason-code" + + // ExtKeyStatusCode surfaces the CDAR ProcessConditionCode (MDT-9) + // on a bill.Status. Normalised from the (line.Key, Status.Type) + // pair via the Flow 6 process table; carried on the GOBL document + // so the wire-level event identifier is visible without consulting + // the converter. + ExtKeyStatusCode cbc.Key = "fr-ctc-status-code" ) // Flow 6 party role codes (UNCL 3035 subset accepted by CDAR). @@ -83,6 +90,23 @@ var extensions = []*cbc.Definition{ }, 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 @@ -113,3 +137,37 @@ func reasonCodeDefinitions() []*cbc.Definition { } return out } + +// processCodeLabels carries the official CDAR libellé for each +// ProcessConditionCode. Kept next to the extension definition so the +// extension catalogue stays self-documenting. +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 — single source of truth for the +// codes the addon accepts. +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/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json index a1cd2295b..5b3ec3323 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "02881541cc28ba94c5de53383f785273cbc21403fe2955ae90378081bc48c470" + "val": "6a106b9fc25e7a10e3e0e048753e1161c171c6a4c69848a58ed85fb43ff0b7e7" } }, "doc": { @@ -16,6 +16,9 @@ "type": "response", "issue_date": "2026-04-16", "code": "STA-2026-0001", + "ext": { + "fr-ctc-status-code": "205" + }, "supplier": { "identities": [ { diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json index 87a8c7ba8..8b17a0009 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "853810a6d1af963e160c3ac127462b05957997bf002f658ee20bac900922ecc2" + "val": "f0f6363de7f3234d2e5f0f47b8659eddaa3531c276f837bdc423440068f3edc9" } }, "doc": { @@ -16,6 +16,9 @@ "type": "response", "issue_date": "2026-05-02", "code": "STA-2026-0002", + "ext": { + "fr-ctc-status-code": "212" + }, "supplier": { "name": "VENDEUR SARL", "identities": [ From 313498f458d773e9f46d2ff6762b9a4be3b0b67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 13:11:21 +0000 Subject: [PATCH 18/26] flow6: derive line.Key + Status.Type from fr-ctc-status-code Mirror normalizeReason: the ext and the (key, type) pair are two sides of the same lookup. When the caller pins a CDAR ProcessConditionCode but leaves the line's Key (and the Status.Type) blank, the normalizer fills them from the process table. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/flow6/bill_status.go | 16 ++++++++++++++++ addons/fr/ctc/flow6/bill_status_test.go | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/addons/fr/ctc/flow6/bill_status.go b/addons/fr/ctc/flow6/bill_status.go index cd316eea6..4035e7a2c 100644 --- a/addons/fr/ctc/flow6/bill_status.go +++ b/addons/fr/ctc/flow6/bill_status.go @@ -18,6 +18,22 @@ 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. Mirrors the + // reason-code direction in normalizeReason: ext and key are two + // sides of the same lookup. + 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 — each Flow 6 line key has // exactly one associated Status.Type in the process table. if st.Type == "" { diff --git a/addons/fr/ctc/flow6/bill_status_test.go b/addons/fr/ctc/flow6/bill_status_test.go index 81664d67f..7954fd6dd 100644 --- a/addons/fr/ctc/flow6/bill_status_test.go +++ b/addons/fr/ctc/flow6/bill_status_test.go @@ -163,6 +163,21 @@ func TestStatusSupplierSIRENFilledFromSEParty(t *testing.T) { st.Supplier.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) } +// TestStatusKeyFilledFromStatusCodeExt exercises the reverse-direction +// normalisation: when the caller pins fr-ctc-status-code but leaves +// line.Key / Status.Type blank, both fields get filled from the +// process table. +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) From a3121914702aff92404ac7a8c86bd8baf39f43e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 15:17:09 +0000 Subject: [PATCH 19/26] fr-ctc: consolidate flow2 / flow6 / flow10 into one addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the three former sub-addons (fr-ctc-flow2-v1, fr-ctc-flow6-v1, fr-ctc-flow10-v1) into a single fr-ctc-v1 addon under addons/fr/ctc. Invoice rule sets are now dispatched at validation time based on whether both parties resolve as French (SIREN identity or French tax ID): both French → Flow 2 clearance, otherwise → Flow 10 e-reporting. Flow 6 lifecycle messages (bill.Status) and payment receipts (bill.Payment) run their own rule sets unconditionally. eu-en16931-v2017 is no longer a hard Requires; the Flow 2 branch enforces it as a soft assertion so Flow 10 / Flow 6 callers don't have to pull it in. Examples renamed for clarity: payment-fr-fr-ctc-flow10-b2b is replaced by payment-fr-de-ctc-b2bint (cross-border B2B receipt), and invoice-fr-fr-ctc-flow10-b2b is removed (a domestic FR-FR B2B invoice belongs in Flow 2, not Flow 10). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +- addons/addons.go | 4 +- addons/fr/ctc/bill_invoice.go | 1195 +++++++ addons/fr/ctc/{flow10 => }/bill_payment.go | 38 +- addons/fr/ctc/{flow6 => }/bill_status.go | 90 +- addons/fr/ctc/{flow6 => }/codes.go | 46 +- addons/fr/ctc/{flow6 => }/complements.go | 21 +- addons/fr/ctc/ctc.go | 140 + addons/fr/ctc/extensions.go | 291 ++ addons/fr/ctc/flow10/bill_invoice.go | 453 --- addons/fr/ctc/flow10/bill_invoice_test.go | 400 --- addons/fr/ctc/flow10/bill_payment_test.go | 164 - addons/fr/ctc/flow10/extensions.go | 190 -- addons/fr/ctc/flow10/flow10.go | 87 - addons/fr/ctc/flow10/party.go | 144 - addons/fr/ctc/flow10/party_test.go | 121 - addons/fr/ctc/flow2/bill_invoice.go | 895 ------ addons/fr/ctc/flow2/bill_invoice_test.go | 2782 ----------------- addons/fr/ctc/flow2/extensions.go | 184 -- addons/fr/ctc/flow2/flow2.go | 107 - addons/fr/ctc/flow2/org.go | 135 - addons/fr/ctc/flow2/org_test.go | 978 ------ addons/fr/ctc/flow2/tags.go | 47 - addons/fr/ctc/flow6/bill_status_test.go | 558 ---- addons/fr/ctc/flow6/codes_test.go | 373 --- addons/fr/ctc/flow6/extensions.go | 173 - addons/fr/ctc/flow6/extensions_test.go | 26 - addons/fr/ctc/flow6/flow6.go | 99 - addons/fr/ctc/flow6/org_party.go | 74 - addons/fr/ctc/flow6/org_party_test.go | 56 - addons/fr/ctc/org.go | 332 ++ addons/fr/ctc/{flow2 => }/org_party.go | 42 +- addons/fr/ctc/{flow10 => }/scenarios.go | 25 +- data/addons/fr-ctc-flow10-v1.json | 362 --- data/addons/fr-ctc-flow2-v1.json | 160 - data/addons/fr-ctc-flow6-v1.json | 384 --- data/addons/fr-ctc-v1.json | 680 +++- data/rules/fr-ctc-flow10.json | 307 -- data/rules/fr-ctc-flow2.json | 584 ---- data/rules/fr-ctc-flow6.json | 206 -- data/rules/fr-ctc.json | 1113 +++++++ .../fr/ctc/{flow6 => }/characteristic.json | 16 +- data/schemas/tax/addon-list.json | 12 +- examples/fr/invoice-fr-de-ctc-b2bint.yaml | 8 +- examples/fr/invoice-fr-fr-ctc-advance.yaml | 3 +- examples/fr/invoice-fr-fr-ctc-b2b.yaml | 3 +- .../fr/invoice-fr-fr-ctc-credit-note.yaml | 3 +- examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml | 52 - examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml | 10 +- examples/fr/out/invoice-fr-de-ctc-b2bint.json | 38 +- .../fr/out/invoice-fr-fr-ctc-advance.json | 4 +- examples/fr/out/invoice-fr-fr-ctc-b2b.json | 4 +- .../fr/out/invoice-fr-fr-ctc-credit-note.json | 4 +- .../fr/out/invoice-fr-fr-ctc-flow10-b2b.json | 127 - .../fr/out/invoice-fr-fr-ctc-flow10-b2c.json | 16 +- ...b2b.json => payment-fr-de-ctc-b2bint.json} | 19 +- .../out/status-fr-fr-ctc-flow6-accepted.json | 9 +- .../fr/out/status-fr-fr-ctc-flow6-paid.json | 11 +- ...b2b.yaml => payment-fr-de-ctc-b2bint.yaml} | 15 +- .../fr/status-fr-fr-ctc-flow6-accepted.yaml | 2 +- examples/fr/status-fr-fr-ctc-flow6-paid.yaml | 4 +- internal/ops/bulk_test.go | 2 +- 62 files changed, 3955 insertions(+), 10476 deletions(-) create mode 100644 addons/fr/ctc/bill_invoice.go rename addons/fr/ctc/{flow10 => }/bill_payment.go (61%) rename addons/fr/ctc/{flow6 => }/bill_status.go (77%) rename addons/fr/ctc/{flow6 => }/codes.go (84%) rename addons/fr/ctc/{flow6 => }/complements.go (85%) create mode 100644 addons/fr/ctc/ctc.go create mode 100644 addons/fr/ctc/extensions.go delete mode 100644 addons/fr/ctc/flow10/bill_invoice.go delete mode 100644 addons/fr/ctc/flow10/bill_invoice_test.go delete mode 100644 addons/fr/ctc/flow10/bill_payment_test.go delete mode 100644 addons/fr/ctc/flow10/extensions.go delete mode 100644 addons/fr/ctc/flow10/flow10.go delete mode 100644 addons/fr/ctc/flow10/party.go delete mode 100644 addons/fr/ctc/flow10/party_test.go delete mode 100644 addons/fr/ctc/flow2/bill_invoice.go delete mode 100644 addons/fr/ctc/flow2/bill_invoice_test.go delete mode 100644 addons/fr/ctc/flow2/extensions.go delete mode 100644 addons/fr/ctc/flow2/flow2.go delete mode 100644 addons/fr/ctc/flow2/org.go delete mode 100644 addons/fr/ctc/flow2/org_test.go delete mode 100644 addons/fr/ctc/flow2/tags.go delete mode 100644 addons/fr/ctc/flow6/bill_status_test.go delete mode 100644 addons/fr/ctc/flow6/codes_test.go delete mode 100644 addons/fr/ctc/flow6/extensions.go delete mode 100644 addons/fr/ctc/flow6/extensions_test.go delete mode 100644 addons/fr/ctc/flow6/flow6.go delete mode 100644 addons/fr/ctc/flow6/org_party.go delete mode 100644 addons/fr/ctc/flow6/org_party_test.go create mode 100644 addons/fr/ctc/org.go rename addons/fr/ctc/{flow2 => }/org_party.go (79%) rename addons/fr/ctc/{flow10 => }/scenarios.go (81%) delete mode 100644 data/addons/fr-ctc-flow10-v1.json delete mode 100644 data/addons/fr-ctc-flow2-v1.json delete mode 100644 data/addons/fr-ctc-flow6-v1.json delete mode 100644 data/rules/fr-ctc-flow10.json delete mode 100644 data/rules/fr-ctc-flow2.json delete mode 100644 data/rules/fr-ctc-flow6.json create mode 100644 data/rules/fr-ctc.json rename data/schemas/addons/fr/ctc/{flow6 => }/characteristic.json (86%) delete mode 100644 examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml delete mode 100644 examples/fr/out/invoice-fr-fr-ctc-flow10-b2b.json rename examples/fr/out/{payment-fr-fr-ctc-flow10-b2b.json => payment-fr-de-ctc-b2bint.json} (82%) rename examples/fr/{payment-fr-fr-ctc-flow10-b2b.yaml => payment-fr-de-ctc-b2bint.yaml} (78%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33628d3a7..6dae170da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- `fr-ctc-flow6`: new addon for status documents in France. -- `fr-ctc-flow10`: new addon for e-reporting documents in France. +- `addons/fr/ctc`: consolidated French CTC support. The previous `fr-ctc-flow2`, `fr-ctc-flow6` and `fr-ctc-flow10` addons are folded into a single `fr-ctc-v1` addon: invoice rule sets are dispatched at validation time (both parties French → Flow 2 clearance, otherwise Flow 10 e-reporting), and Flow 6 lifecycle messages (`bill.Status`) run unconditionally. `eu-en16931-v2017` is no longer a hard `Requires` — the Flow 2 branch enforces it as a soft assertion so Flow 10 / Flow 6 callers don't have to pull it in. - `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/addons.go b/addons/addons.go index 31f878f55..e11c87802 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -21,9 +21,7 @@ import ( _ "github.com/invopop/gobl/addons/es/verifactu" _ "github.com/invopop/gobl/addons/eu/en16931" _ "github.com/invopop/gobl/addons/fr/choruspro" - _ "github.com/invopop/gobl/addons/fr/ctc/flow10" - _ "github.com/invopop/gobl/addons/fr/ctc/flow2" - _ "github.com/invopop/gobl/addons/fr/ctc/flow6" + _ "github.com/invopop/gobl/addons/fr/ctc" _ "github.com/invopop/gobl/addons/fr/facturx" _ "github.com/invopop/gobl/addons/gr/mydata" _ "github.com/invopop/gobl/addons/it/sdi" diff --git a/addons/fr/ctc/bill_invoice.go b/addons/fr/ctc/bill_invoice.go new file mode 100644 index 000000000..9091a6d99 --- /dev/null +++ b/addons/fr/ctc/bill_invoice.go @@ -0,0 +1,1195 @@ +package ctc + +import ( + "regexp" + "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/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", +} + +// allowedVATRates is the whitelist of VAT percentages authorised on a +// Flow 10 invoice / payment (G1.24). +var allowedVATRates = 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) +} + +// en16931V2017Key is the addon key the Flow 2 ruleset requires to be +// declared on the invoice. Hard-coded to avoid importing the en16931 +// package — that import would make en16931 a static dependency of +// fr-ctc, which is exactly what the Flow 2 en16931-addon rule below +// was added to avoid. +const en16931V2017Key cbc.Key = "eu-en16931-v2017" + +// invoiceHasEN16931Addon reports whether the invoice carries the +// eu-en16931-v2017 addon. Used by the Flow 2 dispatcher to make +// en16931 a soft requirement only for domestic French B2B. +func invoiceHasEN16931Addon(v any) bool { + inv, ok := v.(*bill.Invoice) + if !ok || inv == nil { + return true + } + return slices.Contains(inv.Addons.List, en16931V2017Key) +} + +// -- 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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{untdid.ExtKeyTextSubject: "PMD"}), + }, + { + Key: org.NoteKeyPaymentTerm, + Text: "Aucun escompte n'est accordé pour paiement anticipé.", + Ext: tax.ExtensionsOf(tax.ExtMap{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", + is.Func("has eu-en16931-v2017 addon", invoiceHasEN16931Addon), + ), + // 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 rate 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 rate must be one of the Flow 10 permitted percentages (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 rates", invoiceVATRatesAllowed), + ), + ), + 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 { + if !strings.HasPrefix(string(inbox.Code), siren) { + return false + } + return true + } + } + 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 invoiceVATRatesAllowed(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, allowedVATRates) { + 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/flow10/bill_payment.go b/addons/fr/ctc/bill_payment.go similarity index 61% rename from addons/fr/ctc/flow10/bill_payment.go rename to addons/fr/ctc/bill_payment.go index 10debcf1e..b6e2f92fa 100644 --- a/addons/fr/ctc/flow10/bill_payment.go +++ b/addons/fr/ctc/bill_payment.go @@ -1,4 +1,4 @@ -package flow10 +package ctc import ( "github.com/invopop/gobl/bill" @@ -8,12 +8,19 @@ import ( ) // paymentIsB2C reports whether the payment reports a B2C settlement, -// determined by the absence of a Customer party. +// 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 } -func paymentIsB2BAny(v any) bool { +// 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) } @@ -26,46 +33,39 @@ func billPaymentRules() *rules.Set { is.In(bill.PaymentTypeReceipt), ), ), - // Payment date and at least one line (needed to report the amount - // per rate) apply to both B2B and B2C payments. rules.Field("value_date", rules.Assert("02", "payment value_date (settlement date) is required", is.Present, ), ), - // VAT rates reported on payment lines are constrained to the same - // G1.24 whitelist as invoices, applied to both B2B and B2C. - rules.Assert("07", "every VAT line rate must be one of the Flow 10 permitted percentages (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", + rules.Assert("03", "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates", paymentVATRatesAllowed), ), - // Supplier SIREN identifies the French reporting party on the - // payment. Required for both B2B and B2C. rules.Field("supplier", - rules.Assert("08", "supplier is required", + rules.Assert("04", "supplier is required", is.Present, ), - rules.Assert("09", "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002)", + rules.Assert("05", "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002)", is.Func("party has SIREN", partyHasSIREN), ), ), - // Only B2B payments must carry an invoice reference per line - // (invoice ID and issue date) so they can be reconciled against - // the settled invoice. + // Per-line invoice references are required when the payment + // carries a Customer (cleared invoice receipts), not B2C settlements. rules.When( - is.Func("B2B payment", paymentIsB2BAny), + is.Func("payment has customer", paymentHasCustomerAny), rules.Field("lines", rules.Each( rules.Field("document", - rules.Assert("04", "each payment line must reference a document (invoice) on B2B payments", + rules.Assert("06", "each payment line must reference a document (invoice) when a customer is present", is.Present, ), rules.Field("code", - rules.Assert("05", "payment line document code (invoice ID) is required on B2B payments", + rules.Assert("07", "payment line document code (invoice ID) is required when a customer is present", is.Present, ), ), rules.Field("issue_date", - rules.Assert("06", "payment line document issue_date (invoice issue date) is required on B2B payments", + rules.Assert("08", "payment line document issue_date (invoice issue date) is required when a customer is present", is.Present, ), ), diff --git a/addons/fr/ctc/flow6/bill_status.go b/addons/fr/ctc/bill_status.go similarity index 77% rename from addons/fr/ctc/flow6/bill_status.go rename to addons/fr/ctc/bill_status.go index 4035e7a2c..ebc66509f 100644 --- a/addons/fr/ctc/flow6/bill_status.go +++ b/addons/fr/ctc/bill_status.go @@ -1,4 +1,4 @@ -package flow6 +package ctc import ( "slices" @@ -11,18 +11,13 @@ import ( "github.com/invopop/gobl/rules/is" ) -// schemeIDSIREN is the ISO/IEC 6523 scheme for SIREN identities. -const schemeIDSIREN = "0002" - 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. Mirrors the - // reason-code direction in normalizeReason: ext and key are two - // sides of the same lookup. + // 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 == "" { @@ -34,8 +29,7 @@ func normalizeStatus(st *bill.Status) { } } - // Default Type from the first line's Key — each Flow 6 line key has - // exactly one associated Status.Type in the process table. + // Default Type from the first line's Key. if st.Type == "" { for _, line := range st.Lines { if line == nil { @@ -49,9 +43,7 @@ func normalizeStatus(st *bill.Status) { } // Deduce the fr-ctc-role on Issuer / Recipient from the line's - // (key, type) → side mapping per Annexe A "Acteurs CDV". Saves the - // caller from spelling out a role that's already implied by the - // process code. If the caller already set a role, leave it alone. + // (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 != "" { @@ -63,19 +55,11 @@ func normalizeStatus(st *bill.Status) { } // Propagate the SE-roled party's SIREN onto Supplier when missing. - // The seller's SIREN is what populates ref.IssuerTradeParty - // (MDT-129, BR-FR-CDV-13); when the seller already shows up as - // Issuer or Recipient, the caller shouldn't have to repeat the - // identity on Supplier. Only copies the SIREN identity (other - // fields stay caller-controlled). if siren := siRENFromSEParty(st.Issuer, st.Recipient); siren != nil { st.Supplier = ensureSIRENOnSupplier(st.Supplier, siren) } - // Surface the CDAR ProcessConditionCode on the document so the - // wire-level event identifier is visible without consulting the - // converter. Skip when the caller already pinned a code (e.g. - // round-tripping a parsed CDV) — the validator catches mismatches. + // 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)) @@ -97,7 +81,7 @@ func siRENFromSEParty(candidates ...*org.Party) *org.Identity { if id == nil || id.Ext.IsZero() { continue } - if id.Ext.Get(iso.ExtKeySchemeID).String() == schemeIDSIREN { + if id.Ext.Get(iso.ExtKeySchemeID).String() == identitySchemeIDSIREN { return id } } @@ -108,8 +92,7 @@ func siRENFromSEParty(candidates ...*org.Party) *org.Identity { // 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. The identity is shallow-copied so caller-side mutations on -// the source don't leak. +// SIREN. func ensureSIRENOnSupplier(p *org.Party, siren *org.Identity) *org.Party { clone := *siren if p == nil { @@ -119,7 +102,7 @@ func ensureSIRENOnSupplier(p *org.Party, siren *org.Identity) *org.Party { if id == nil || id.Ext.IsZero() { continue } - if id.Ext.Get(iso.ExtKeySchemeID).String() == schemeIDSIREN && + if id.Ext.Get(iso.ExtKeySchemeID).String() == identitySchemeIDSIREN && id.Code == siren.Code { return p } @@ -129,9 +112,7 @@ func ensureSIRENOnSupplier(p *org.Party, siren *org.Identity) *org.Party { } // rolesForSide returns the (Issuer.role, Recipient.role) pair implied -// by an Annexe A side. Empty strings mean "no derivation possible" — -// the caller must supply the role explicitly (e.g. platform-issued -// codes, where Issuer is WK and the recipient role varies). +// by an Annexe A side. func rolesForSide(side CDVSide) (issuer, recipient cbc.Code) { switch side { case CDVSideBuyer: @@ -165,8 +146,7 @@ func partyHasRole(v any) bool { // partyHasInboxWhenRequired enforces BR-FR-CDV-08: a party whose role // is not WK (legal representative) or DFH (declarant for VAT grouping) -// must carry a URIID (electronic inbox). We enforce for both issuer -// and supplier to simplify emmission. +// must carry a URIID (electronic inbox). func partyHasInboxWhenRequired(v any) bool { p, ok := v.(*org.Party) if !ok || p == nil { @@ -192,18 +172,18 @@ func billStatusRules() *rules.Set { ), ), rules.Field("supplier", - rules.Assert("02", "supplier is required — its SIREN populates ref.IssuerTradeParty (MDT-129, BR-FR-CDV-13)", + 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", partyHasSIRENIdentity), + is.Func("supplier has SIREN", statusPartyHasSIRENIdentity), ), ), rules.Field("issuer", - rules.Assert("14", "issuer is required — maps to ExchangedDocument/IssuerTradeParty (MDG-16) per BR-FR-CDV-CL-03", + rules.Assert("14", "issuer is required (BR-FR-CDV-CL-03)", is.Present, ), - rules.Assert("15", "issuer.ext.fr-ctc-role must be set; the allowed values depend on ack TypeCode (BR-FR-CDV-CL-03)", + 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)", @@ -211,10 +191,10 @@ func billStatusRules() *rules.Set { ), ), rules.Field("recipient", - rules.Assert("16", "recipient is required — maps to ExchangedDocument/RecipientTradeParty (MDG-23) per BR-FR-CDV-CL-04", + rules.Assert("16", "recipient is required (BR-FR-CDV-CL-04)", is.Present, ), - rules.Assert("17", "recipient.ext.fr-ctc-role must be set (BR-FR-CDV-CL-04: BY/DL/SE/AB/SR/PE/PR/II/IV/WK/DFH)", + 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)", @@ -222,7 +202,7 @@ func billStatusRules() *rules.Set { ), ), rules.Field("lines", - rules.Assert("04", "exactly one status line is required (CDAR carries a single status per CDV message)", + rules.Assert("04", "exactly one status line is required", is.Func("exactly one line", statusHasExactlyOneLine), ), rules.Each( @@ -250,7 +230,7 @@ func billStatusRules() *rules.Set { 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: MEN, MPA, RAP, ESC, RAB, REM, MAP, MAPTTC, MNA, MNATTC, CBB, DIV, DVA, MAJ", + rules.Assert("10", "Characteristic.TypeCode must be one of the MDT-207 values", is.Func("characteristic type code known", statusLineTypeCodesKnown), ), ), @@ -271,8 +251,7 @@ func billStatusRules() *rules.Set { } // statusCodeMatchesLine ensures the fr-ctc-status-code ext, when set, -// is consistent with the (line.Key, Status.Type) pair. Empty ext is -// permitted on input — the normalizer fills it. +// is consistent with the (line.Key, Status.Type) pair. func statusCodeMatchesLine(v any) bool { st, ok := v.(*bill.Status) if !ok || st == nil { @@ -293,9 +272,7 @@ func statusCodeMatchesLine(v any) bool { } // statusHasExactlyOneLine enforces the CDAR invariant that a CDV -// message carries one and only one status — a single line on the -// bill.Status. Multiple lines would map to multiple CDARs and must be -// split into separate documents. +// message carries one and only one status. func statusHasExactlyOneLine(v any) bool { lines, ok := v.([]*bill.StatusLine) if !ok { @@ -304,7 +281,7 @@ func statusHasExactlyOneLine(v any) bool { return len(lines) == 1 } -func partyHasSIRENIdentity(v any) bool { +func statusPartyHasSIRENIdentity(v any) bool { p, ok := v.(*org.Party) if !ok || p == nil { return false @@ -313,7 +290,7 @@ func partyHasSIRENIdentity(v any) bool { if id == nil || id.Ext.IsZero() { continue } - if id.Ext.Get(iso.ExtKeySchemeID).String() == schemeIDSIREN { + if id.Ext.Get(iso.ExtKeySchemeID).String() == identitySchemeIDSIREN { return true } } @@ -330,9 +307,7 @@ func statusLineKeyKnown(v any) bool { // 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. The same -// `paid` key on an update status (CDAR 211 Paiement transmis) does -// not require the MEN. +// Characteristic with TypeCode=MEN and Amount populated. func statusPaidResponseHasAmount(v any) bool { st, ok := v.(*bill.Status) if !ok || st == nil { @@ -353,7 +328,7 @@ func statusPaidResponseHasAmount(v any) bool { } // lineHasMENAmount returns true if the given line carries a -// flow6.Characteristic complement with TypeCode=MEN and a populated +// Characteristic complement with TypeCode=MEN and a populated // Amount (value + currency). func lineHasMENAmount(line *bill.StatusLine) bool { for _, obj := range line.Complements { @@ -399,7 +374,7 @@ func statusLineTypeCodesKnown(v any) bool { // 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. An unset ReasonCode is allowed. +// sibling bill.Reason on the same line. func statusLineReasonLinksResolve(v any) bool { line, ok := v.(*bill.StatusLine) if !ok || line == nil { @@ -436,9 +411,7 @@ func lineHasReasonCode(line *bill.StatusLine, code cbc.Code) bool { } // reasonRequiredStatusKeys lists the Flow 6 status-line keys that BR-FR-CDV-15 -// designates as carrying mandatory motifs. The 501 "IRRECEVABLE" status -// from the CSV is not in our process table (it's PPF-ingress-only) and -// is deliberately omitted — if we ever model it, add it here. +// designates as carrying mandatory motifs. var reasonRequiredStatusKeys = []cbc.Key{ bill.StatusEventRejected, bill.StatusEventError, @@ -448,11 +421,7 @@ var reasonRequiredStatusKeys = []cbc.Key{ } // statusReasonCodesAllowed enforces BR-FR-CDV-CL-09 at the -// bill.Status level: each Reason on each line must carry an -// fr-ctc-reason-code permitted for the (line.Key, st.Type) → -// ProcessConditionCode pair. Lives at the status level because the -// pair-lookup needs Type — line-only keys like `paid` are ambiguous -// (211 update vs 212 response) without it. +// bill.Status level. func statusReasonCodesAllowed(v any) bool { st, ok := v.(*bill.Status) if !ok || st == nil { @@ -516,8 +485,7 @@ func statusTypeMatchesLines(v any) bool { // -- bill.Reason -------------------------------------------------------- // normalizeReason fills in the other side of the Reason.Key ↔ Ext -// relationship when exactly one side is set. The extension carries the -// exact CDAR ReasonCode; the Key is the bucket. +// relationship when exactly one side is set. func normalizeReason(r *bill.Reason) { if r == nil { return @@ -577,8 +545,6 @@ func reasonExtMatchesKey(v any) bool { if !ok { return false } - // Key may be empty when normalization has not yet run; the - // normalizer fills it from the ext. if r.Key == "" { return true } diff --git a/addons/fr/ctc/flow6/codes.go b/addons/fr/ctc/codes.go similarity index 84% rename from addons/fr/ctc/flow6/codes.go rename to addons/fr/ctc/codes.go index af29bd62b..b338c3501 100644 --- a/addons/fr/ctc/flow6/codes.go +++ b/addons/fr/ctc/codes.go @@ -1,4 +1,4 @@ -package flow6 +package ctc import ( "slices" @@ -36,10 +36,7 @@ const ( ) // processEntry pairs a bill.StatusLine.Key with the bill.Status.Type -// the CDV expects, alongside the wire ProcessConditionCode. A key may -// appear in multiple entries when its (key, type) pairs map to -// different CDAR codes — that is what allows us to share `paid` -// across CDV-211 (update) and CDV-212 (response). +// the CDV expects, alongside the wire ProcessConditionCode. type processEntry struct { Key cbc.Key Type cbc.Key @@ -66,8 +63,7 @@ var processTable = []processEntry{ } // CDARProcessCodeFor returns the CDAR ProcessConditionCode for a bill -// StatusLine.Key + Status.Type pair. Returns ("", false) if the pair is -// unknown or the Type does not match the fixed Type for the key. +// 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 { @@ -78,7 +74,7 @@ func CDARProcessCodeFor(key cbc.Key, typ cbc.Key) (string, bool) { } // StatusKeyFor returns the (StatusLine.Key, Status.Type) pair for a CDAR -// ProcessConditionCode. Returns ("", "", false) if the code is unknown. +// ProcessConditionCode. func StatusKeyFor(code string) (cbc.Key, cbc.Key, bool) { for _, e := range processTable { if e.Code == code { @@ -89,10 +85,7 @@ func StatusKeyFor(code string) (cbc.Key, cbc.Key, bool) { } // statusTypeForKey returns the Status.Type associated with a -// StatusLine.Key for Flow 6 *if the key has exactly one*. Returns -// ("", false) when the key is unknown OR when the same key is shared -// across multiple types (e.g. `paid` covers both update/211 and -// response/212) — in that case the caller must specify Type explicitly. +// 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 { @@ -111,8 +104,7 @@ func statusTypeForKey(key cbc.Key) (cbc.Key, bool) { } // statusKeyKnown reports whether the key appears in the Flow 6 -// process table at least once (regardless of how many types it -// pairs with). +// process table at least once. func statusKeyKnown(key cbc.Key) bool { for _, e := range processTable { if e.Key == key { @@ -133,7 +125,7 @@ type reasonEntry struct { // 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 (see CDARReasonCodeFor). +// 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}, @@ -186,8 +178,7 @@ var reasonTable = []reasonEntry{ } // CDARReasonCodeFor returns the default CDAR ReasonCode for a -// bill.Reason.Key. Used on generate when the caller did not pin an -// exact code via Reason.Ext["fr-ctc-reason-code"]. +// bill.Reason.Key. func CDARReasonCodeFor(key cbc.Key) (string, bool) { for _, e := range reasonTable { if e.Key == key && e.IsDefault { @@ -198,8 +189,7 @@ func CDARReasonCodeFor(key cbc.Key) (string, bool) { } // ReasonKeyFor returns the bucket bill.Reason.Key for a CDAR -// ReasonCode. Used on parse and by the normalizer to fill Reason.Key -// from the extension. +// ReasonCode. func ReasonKeyFor(code string) (cbc.Key, bool) { for _, e := range reasonTable { if e.Code == code { @@ -224,10 +214,7 @@ var actionTable = []struct { } // CDVSide reports which end-party plays the Issuer role on a CDV -// message of the given process code. Used by the cii writer to -// auto-fill IssuerTradeParty / RecipientTradeParty from Supplier and -// Customer when the caller has not set them explicitly. The mapping -// follows Annexe A "Acteurs CDV". +// message of the given process code. type CDVSide string const ( @@ -239,14 +226,13 @@ const ( 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. The caller must supply st.Issuer (and typically - // st.Recipient) explicitly. + // 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). Returns CDVSideUnknown for codes not in the table. +// phase). func SideForCode(code string) CDVSide { switch code { case "204", "205", "206", "207", "208", "210", "211": @@ -271,10 +257,7 @@ func SideForKeyType(key, typ cbc.Key) CDVSide { // 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. Codes not listed here either don't carry -// reasons (200, 201, 202, 203, 204, 205, 209, 211, 212) or carry any -// reason (the table is the strict list per Annexe A "Tableau des motifs -// de STATUTS"). +// the schematron will accept. var allowedReasonsByProcessCode = map[string][]string{ "200": {"NON_TRANSMISE"}, "206": { @@ -308,8 +291,7 @@ var allowedReasonsByProcessCode = map[string][]string{ // ReasonCodeAllowedForProcessCode reports whether the given CDAR // ReasonCode is permitted on a status line whose ProcessConditionCode is -// processCode. Returns true when the process code does not constrain the -// reason set (i.e. it isn't in the BR-FR-CDV-CL-09 table). +// processCode. func ReasonCodeAllowedForProcessCode(reasonCode, processCode string) bool { allowed, ok := allowedReasonsByProcessCode[processCode] if !ok { diff --git a/addons/fr/ctc/flow6/complements.go b/addons/fr/ctc/complements.go similarity index 85% rename from addons/fr/ctc/flow6/complements.go rename to addons/fr/ctc/complements.go index 85b7b26af..28c2e1567 100644 --- a/addons/fr/ctc/flow6/complements.go +++ b/addons/fr/ctc/complements.go @@ -1,4 +1,4 @@ -package flow6 +package ctc import ( "github.com/invopop/gobl/cal" @@ -23,17 +23,14 @@ import ( // 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. Used by CDAR to - // correlate a correction with a previously reported field. + // ID optionally identifies the characteristic. ID string `json:"id,omitempty" jsonschema:"title=ID"` - // TypeCode is the CDAR CharacteristicTypeCode. See the TypeCode* - // constants for reserved values Flow 6 interprets directly. + // 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. Only meaningful on - // rejection / dispute / partial-acceptance lines. + // 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. @@ -43,8 +40,7 @@ type Characteristic struct { // correction (true) or is being reported unchanged (false). Changed *bool `json:"changed,omitempty" jsonschema:"title=Changed"` - // Direction carries the CDAR AdjustmentDirectionCode — typically - // "+" or "-" when Changed is true. + // Direction carries the CDAR AdjustmentDirectionCode. Direction cbc.Code `json:"direction,omitempty" jsonschema:"title=Direction"` // Name is the semantic label of the field the characteristic @@ -64,8 +60,7 @@ type Characteristic struct { // 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. Used - // for the MEN on paid lines and for any price/total correction. + // 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. @@ -81,9 +76,7 @@ type Characteristic struct { DateTime *cal.DateTime `json:"date_time,omitempty" jsonschema:"title=Date Time"` } -// Characteristic.TypeCode values (MDT-207). The list comes from the -// French CTC Flow 6 specification; additional codes may be added as -// the spec evolves. +// Characteristic.TypeCode values (MDT-207). const ( // Payment-related amounts TypeCodeAmountReceived cbc.Code = "MEN" // Montant encaissé (TTC) diff --git a/addons/fr/ctc/ctc.go b/addons/fr/ctc/ctc.go new file mode 100644 index 000000000..f5d580d5b --- /dev/null +++ b/addons/fr/ctc/ctc.go @@ -0,0 +1,140 @@ +// 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/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "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 ( + // Key identifies the French CTC addon family. + Key cbc.Key = "fr-ctc" + + // 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( + Key.String(), + rules.GOBL.Add("FR-CTC"), + is.InContext(tax.AddonIn(V1)), + billInvoiceRules(), + billPaymentRules(), + billStatusRules(), + billReasonRules(), + billActionRules(), + orgPartyRules(), + orgIdentityRules(), + orgInboxRules(), + orgItemRules(), + ) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V1, + Name: i18n.String{ + 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) + e-invoicing and e-reporting reform. + + The addon covers three of the flows defined by the French + specification: + + - 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. + + 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 la réforme française CTC (Contrôle Continu + des Transactions) de la facturation et du e-reporting. + + L'addon couvre trois flux du cahier des charges : + + - 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. + + 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{ + { + Title: i18n.String{ + i18n.EN: "External Specifications", + i18n.FR: "Spécifications Externes", + }, + URL: "https://www.impots.gouv.fr/specifications-externes-b2b", + }, + }, + Extensions: extensions, + Scenarios: scenarios, + Normalizer: normalize, + } +} + +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: + normalizeIdentity(obj) + } +} diff --git a/addons/fr/ctc/extensions.go b/addons/fr/ctc/extensions.go new file mode 100644 index 000000000..3dfce6023 --- /dev/null +++ b/addons/fr/ctc/extensions.go @@ -0,0 +1,291 @@ +package ctc + +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. +const ( + // 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" +) + +// 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 cbc.Code = "B1" + BillingModeB2 cbc.Code = "B2" + BillingModeB4 cbc.Code = "B4" + BillingModeB7 cbc.Code = "B7" + BillingModeS1 cbc.Code = "S1" + BillingModeS2 cbc.Code = "S2" + BillingModeS4 cbc.Code = "S4" + BillingModeS5 cbc.Code = "S5" + BillingModeS6 cbc.Code = "S6" + BillingModeS7 cbc.Code = "S7" + BillingModeM1 cbc.Code = "M1" + BillingModeM2 cbc.Code = "M2" + BillingModeM4 cbc.Code = "M4" +) + +// Flow 6 party role codes (UNCL 3035 subset accepted by CDAR). +const ( + RoleSE cbc.Code = "SE" // Seller + RoleBY cbc.Code = "BY" // Buyer + RoleWK cbc.Code = "WK" // Work/Service receiver + RoleDFH cbc.Code = "DFH" // Delivery from + RoleAB cbc.Code = "AB" // Bank + RoleSR cbc.Code = "SR" // Sender / issuer on behalf of + RoleDL cbc.Code = "DL" // Dealer / intermediary + RolePE cbc.Code = "PE" // Payee + RolePR cbc.Code = "PR" // Payer + RoleII cbc.Code = "II" // Issuer of invoice + RoleIV cbc.Code = "IV" // Invoicee +) + +var extensions = []*cbc.Definition{ + { + Key: ExtKeyBillingMode, + Name: i18n.String{ + i18n.EN: "Billing Mode", + i18n.FR: "Cadre de Facturation", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Code used to describe the billing framework of the invoice. The billing mode + indicates the nature of goods/services and the payment context. + + Code prefixes indicate the invoice nature: + - "B": Goods invoice (Biens) + - "S": Services invoice + - "M": Mixed/dual invoice (goods and services that are not accessory to each other) + + The numeric suffix indicates the payment type (1=deposit, 2=already paid, + 4=final after down payment, 5=subcontractor, 6=co-contractor, 7=e-reporting). + `), + i18n.FR: here.Doc(` + Code utilisé pour décrire le cadre de facturation de la facture. Le mode de + facturation indique la nature des biens/services et le contexte de paiement. + + Les préfixes de code indiquent la nature de la facture : + - "B" : Facture de biens + - "S" : Facture de services + - "M" : Facture mixte (biens et services qui ne sont pas accessoires l'un de l'autre) + + Le suffixe numérique indique le type de paiement (1=dépôt, 2=déjà payée, + 4=définitive après acompte, 5=sous-traitant, 6=cotraitant, 7=e-reporting). + `), + }, + 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)"}}, + }, + }, + { + 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 for each + populated party on a Flow 6 lifecycle message. The normalizer + fills the obvious defaults (Supplier → SE, Customer → BY) + and leaves the rest for the caller to set explicitly. + `), + }, + Values: []*cbc.Definition{ + {Code: RoleSE, Name: i18n.String{i18n.EN: "Seller"}}, + {Code: RoleBY, Name: i18n.String{i18n.EN: "Buyer"}}, + {Code: RoleWK, Name: i18n.String{i18n.EN: "Work / Service Receiver"}}, + {Code: RoleDFH, Name: i18n.String{i18n.EN: "Delivery From"}}, + {Code: RoleAB, Name: i18n.String{i18n.EN: "Bank"}}, + {Code: RoleSR, Name: i18n.String{i18n.EN: "Sender / Issuer on behalf of"}}, + {Code: RoleDL, Name: i18n.String{i18n.EN: "Dealer"}}, + {Code: RolePE, Name: i18n.String{i18n.EN: "Payee"}}, + {Code: RolePR, Name: i18n.String{i18n.EN: "Payer"}}, + {Code: RoleII, Name: i18n.String{i18n.EN: "Issuer of Invoice"}}, + {Code: RoleIV, Name: i18n.String{i18n.EN: "Invoicee"}}, + }, + }, + { + 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/flow10/bill_invoice.go b/addons/fr/ctc/flow10/bill_invoice.go deleted file mode 100644 index 99300d6a1..000000000 --- a/addons/fr/ctc/flow10/bill_invoice.go +++ /dev/null @@ -1,453 +0,0 @@ -package flow10 - -import ( - "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/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" -) - -// A VAT rate key other than "standard" or "zero" is treated as an -// exemption for Flow 10 purposes: it must be paired with a matching -// exemption reason (tax.Note with the same Key and non-empty Text). -// Translating the key to the final UNTDID category code is the -// converter's job, not this addon's. - -// finalAfterAdvanceBillingModes are the billing-mode codes that mark an -// invoice as a "final invoice after down payment" (G1.60): B4, S4, M4. -// Under these modes the invoice may not be an advance-payment document -// type (386/500/503). -var finalAfterAdvanceBillingModes = []cbc.Code{ - BillingModeB4, BillingModeS4, BillingModeM4, -} - -// advancePaymentDocumentTypes are the UNTDID 1001 codes representing -// advance-payment invoices and their credit memo (G1.60 forbids them -// combined with B4/S4/M4 billing modes). -var advancePaymentDocumentTypes = []cbc.Code{ - "386", // Advance payment invoice - "500", // Self-billed advance payment - "503", // Down-payment credit memo -} - -func billInvoiceRules() *rules.Set { - return rules.For(new(bill.Invoice), - // B2C rules: category, supplier SIREN, VAT rate whitelist. - rules.When( - is.Func("B2C invoice", invoiceIsB2CAny), - rules.Field("tax", - rules.Field("ext", - rules.Assert("16", "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("17", "supplier is required on B2C invoice", - is.Present, - ), - rules.Assert("18", "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002) on a B2C invoice", - is.Func("party has SIREN", partyHasSIREN), - ), - ), - rules.Assert("19", "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates", invoiceVATRatesAllowed), - ), - ), - // Flow 10 reports to the French authority in EUR: if the invoice is - // issued in a different currency, an exchange rate must be provided. - rules.Assert("10", "invoice must be in EUR or provide an exchange rate to EUR", - currency.CanConvertTo(currency.EUR), - ), - // When a party carries any postal address, the country on that - // address must be populated. The address itself remains optional. - rules.Field("supplier", - rules.Field("addresses", - rules.Each( - rules.Field("country", - rules.Assert("13", "supplier address must include country", - is.Present, - ), - ), - ), - ), - ), - rules.Field("customer", - rules.Field("addresses", - rules.Each( - rules.Field("country", - rules.Assert("14", "customer address must include country", - is.Present, - ), - ), - ), - ), - ), - // B2B: both supplier and customer must be present, each with a legal - // identity declaring an allowed ICD 6523 scheme (G2.19). If that scheme - // is SIREN (0002) or EU VAT (0223), a matching TaxID must also be set - // (G2.33). - rules.When( - is.Func("B2B invoice", invoiceIsB2BAny), - rules.Field("tax", - rules.Field("ext", - rules.Assert("09", "invoice document type must be one of the Flow 10 permitted UNTDID 1001 codes (380, 389, 393, 501, 386, 500, 384, 471, 472, 473, 381, 261, 396, 502, 503)", - is.Func("allowed Flow 10 document type", invoiceDocumentTypeAllowed), - ), - rules.Assert("11", "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("12", "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("01", "supplier is required for Flow 10 B2B invoice (G2.19)", - is.Present, - ), - rules.Assert("02", "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("03", "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("07", "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("15", "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("04", "customer is required for Flow 10 B2B invoice (G2.19)", - is.Present, - ), - rules.Assert("05", "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("06", "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), - ), - ), - ), - ) -} - -func invoiceIsB2BAny(v any) bool { - inv, ok := v.(*bill.Invoice) - return ok && !invoiceIsB2C(inv) -} - -func invoiceIsB2CAny(v any) bool { - inv, ok := v.(*bill.Invoice) - return ok && invoiceIsB2C(inv) -} - -// allowedVATRates is the whitelist of VAT percentages authorised on a -// Flow 10 invoice (G1.24). Comparison is numeric — "20", "20.0" and -// "20.00" are all equivalent, handled by num.Percentage.Compare. -var allowedVATRates = 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 partyHasSIREN(v any) bool { - party, ok := v.(*org.Party) - if !ok || party == nil { - return false - } - for _, id := range party.Identities { - if id == nil || id.Ext.IsZero() { - continue - } - if id.Ext.Get(iso.ExtKeySchemeID).String() == schemeIDSIREN { - return true - } - } - return false -} - -func invoiceVATRatesAllowed(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, allowedVATRates) { - return false - } - } - } - return true -} - -func percentageInList(p num.Percentage, list []num.Percentage) bool { - for _, a := range list { - if p.Compare(a) == 0 { - return true - } - } - return false -} - -func extensionsHaveB2CCategory(v any) bool { - return extensionsValue(v).Get(ExtKeyB2CCategory) != "" -} - -// extensionsValue extracts a tax.Extensions from either a value- or -// pointer-typed argument. The rules engine currently passes fields of -// struct type by pointer, so both forms must be handled. -func extensionsValue(v any) tax.Extensions { - switch ext := v.(type) { - case tax.Extensions: - return ext - case *tax.Extensions: - if ext == nil { - return tax.Extensions{} - } - return *ext - default: - return tax.Extensions{} - } -} - -func partyHasAllowedLegalScheme(v any) bool { - party, ok := v.(*org.Party) - if !ok || party == nil { - return false - } - return slices.Contains(allowedPartySchemeIDs, partyLegalSchemeID(party)) -} - -func extensionsHaveBillingMode(v any) bool { - return extensionsValue(v).Get(ExtKeyBillingMode) != "" -} - -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, extensionsValue(v).Get(untdid.ExtKeyDocumentType)) -} - -// invoiceDocumentTypeAllowed reads the untdid-document-type extension set -// by the Flow 10 scenarios and confirms it is one of the permitted codes. -func invoiceDocumentTypeAllowed(v any) bool { - return slices.Contains(allowedDocumentTypes, extensionsValue(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. Per the Flow 10 spec, invoices with an -// exempt VAT breakdown must carry at least one of these two VAT IDs. -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 != "" -} - -// invoiceHasExemptVATCategory reports whether any line on the invoice -// carries a VAT combo tagged with UNTDID 5305 category code "E" (exempt). -// We inspect line-level combos rather than the aggregated totals because -// the untdid-tax-category extension is carried on the combo itself, and -// the totals breakdown is only populated after Calculate has run. -// invoiceHasExemptCombo reports whether the invoice has any VAT combo -// whose UNTDID 5305 tax-category extension is "E" (exempt). Reading the -// extension rather than the combo Key lets upstream converters or -// manual entries declare exemption directly via the UNTDID code. -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 != "" -} - -// vatKeyToUNTDIDCategory maps each supported GOBL VAT rate key to its -// UNTDID 5305 category code. The Canary Islands (IGIC / "L") and -// Ceuta/Melilla (IPSI / "M") categories are intentionally absent since -// they are not applicable to Flow 10. -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", -} - -// invoiceIsB2C reports whether the invoice is a business-to-consumer -// transaction. Flow 10 distinguishes B2C from B2B by the presence of a -// Customer party — a B2C sale is to an unidentified consumer and so -// the Customer slot is left unset. -func invoiceIsB2C(inv *bill.Invoice) bool { - return inv != nil && inv.Customer == nil -} - -func normalizeInvoice(inv *bill.Invoice) { - if inv == nil { - return - } - normalizeInvoiceTaxCategories(inv) - // Party normalization (e.g. deriving a SIREN identity from a French - // TaxID) applies to both B2B and B2C: the supplier-SIREN rule fires - // in both branches, and on B2C the Customer slot is unset so the - // second call is a no-op. - normalizeParty(inv.Supplier) - normalizeParty(inv.Customer) - if invoiceIsB2C(inv) { - normalizeB2CCategoryOnInvoice(inv) - return - } - normalizeInvoiceBillingMode(inv) -} - -// normalizeB2CCategoryOnInvoice defaults the B2C transaction category to -// TNT1 (not subject to French VAT) when the caller has not supplied one. -// TNT1 is the safest default: it covers B2C sales that would otherwise -// require explicit per-case classification (intra-EU distance sales, -// out-of-scope, etc.), and a user wanting a narrower code must set it -// explicitly. -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. Combos whose key we do not -// map (IGIC / IPSI, or unknown) are left untouched. -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 code, ok := vatKeyToUNTDIDCategory[combo.Key]; ok { - combo.Ext = combo.Ext.Set(untdid.ExtKeyTaxCategory, code) - } - } - } -} - -// normalizeInvoiceBillingMode picks a sensible default for the Flow 10 -// billing-mode extension when the user has not supplied one. We default -// to the Mixed (M) prefix since it is the safest without line-level -// analysis: M2 when the invoice is already paid in full, M1 otherwise. -// The user can override by setting the extension explicitly. -func normalizeInvoiceBillingMode(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) -} diff --git a/addons/fr/ctc/flow10/bill_invoice_test.go b/addons/fr/ctc/flow10/bill_invoice_test.go deleted file mode 100644 index ea71b12ce..000000000 --- a/addons/fr/ctc/flow10/bill_invoice_test.go +++ /dev/null @@ -1,400 +0,0 @@ -package flow10 - -import ( - "testing" - - "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/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/tax" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -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(tax.ExtMap{ - iso.ExtKeySchemeID: "0002", - }), - }, - }, - Addresses: []*org.Address{{Country: "FR"}}, - } -} - -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(tax.ExtMap{ - iso.ExtKeySchemeID: "0002", - }), - }, - }, - Addresses: []*org.Address{{Country: "FR"}}, - } -} - -func testInvoiceB2B(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: frCustomerWithSIREN(), - 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 testInvoiceB2C(t *testing.T) *bill.Invoice { - t.Helper() - inv := &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(tax.ExtMap{ - 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)}, - }, - }, - }, - } - return inv -} - -func TestInvoiceB2BHappyPath(t *testing.T) { - inv := testInvoiceB2B(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 TestInvoiceCurrencyRequiresEURConversion(t *testing.T) { - inv := testInvoiceB2B(t) - inv.Currency = "USD" - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "EUR") -} - -func TestInvoiceCurrencyUSDWithExchangeRate(t *testing.T) { - inv := testInvoiceB2B(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 TestInvoiceB2BDocTypeNotAllowed(t *testing.T) { - inv := testInvoiceB2B(t) - require.NoError(t, inv.Calculate()) - // Force a document type that is not in the Flow 10 whitelist. - 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 TestInvoiceB2BMissingBillingMode(t *testing.T) { - inv := testInvoiceB2B(t) - require.NoError(t, inv.Calculate()) - // Normalization defaults the billing mode; clear it to simulate a - // downstream consumer that strips the extension. - inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "billing mode") -} - -func TestInvoiceB2BFinalAfterAdvanceRejectsDepositDocType(t *testing.T) { - inv := testInvoiceB2B(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 TestInvoiceB2BSupplierRequiresAllowedScheme(t *testing.T) { - inv := testInvoiceB2B(t) - require.NoError(t, inv.Calculate()) - // Strip the supplier's identities so no allowed scheme remains. - inv.Supplier.Identities = nil - err := rules.Validate(inv) - assert.ErrorContains(t, err, "supplier must declare a legal identity") -} - -func TestInvoiceB2BAddressRequiresCountry(t *testing.T) { - inv := testInvoiceB2B(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 TestInvoiceB2BExemptRequiresSellerVATID(t *testing.T) { - inv := testInvoiceB2B(t) - inv.Lines[0].Taxes = tax.Set{ - {Category: tax.CategoryVAT, Key: tax.KeyExempt}, - } - require.NoError(t, inv.Calculate()) - // Drop both potential VAT IDs; no ordering.seller either. - inv.Supplier.TaxID = nil - inv.Ordering = nil - err := rules.Validate(inv) - assert.ErrorContains(t, err, "supplier VAT ID or ordering.seller") -} - -func TestInvoiceB2BExemptRequiresExemptTaxNote(t *testing.T) { - inv := testInvoiceB2B(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 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) - // Clear both TaxID and Identities — party normalization would - // otherwise regenerate a SIREN from the French TaxID. - 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)}, // 17%, not allowed - } - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.ErrorContains(t, err, "G1.24") -} - -func TestNormalizeDefaultBillingModeM1(t *testing.T) { - inv := testInvoiceB2B(t) - require.NoError(t, inv.Calculate()) - assert.Equal(t, BillingModeM1, inv.Tax.Ext.Get(ExtKeyBillingMode)) -} - -func TestNormalizeTaxCategorySetFromKey(t *testing.T) { - inv := testInvoiceB2B(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 TestNormalizeGeneratesSIRENFromFrenchTaxID(t *testing.T) { - inv := testInvoiceB2B(t) - inv.Supplier.Identities = nil - require.NoError(t, inv.Calculate()) - // normalizeParty should have injected a SIREN-scheme identity. - 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") -} - -// TestNormalizeB2CGeneratesSIRENFromFrenchTaxID confirms that party -// normalization also runs for B2C invoices, so a French supplier with -// only a TaxID set is still normalized into a SIREN-scheme identity -// (otherwise the supplier-SIREN rule would fail despite valid input). -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 (bill_invoice.go) ------------------------- - -func TestExtensionsValueNilPointer(t *testing.T) { - assert.True(t, extensionsValue((*tax.Extensions)(nil)).IsZero()) -} - -func TestExtensionsValueUnknownType(t *testing.T) { - assert.True(t, extensionsValue(42).IsZero()) -} - -func TestExtensionsValueValue(t *testing.T) { - e := tax.ExtensionsOf(tax.ExtMap{"k": "v"}) - assert.False(t, extensionsValue(e).IsZero()) -} - -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(tax.ExtMap{iso.ExtKeySchemeID: "0227"}), - }}} - assert.True(t, partyHasTaxIDWhenRequired(p)) -} - -func TestInvoiceIsB2BWrongType(t *testing.T) { - assert.False(t, invoiceIsB2BAny("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, invoiceVATRatesAllowed("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 TestNormalizeInvoiceBillingModeDefaultsM2WhenPaid(t *testing.T) { - due := num.MakeAmount(0, 2) - inv := &bill.Invoice{ - Totals: &bill.Totals{Due: &due}, - Tax: &bill.Tax{}, - } - normalizeInvoiceBillingMode(inv) - assert.Equal(t, BillingModeM2, inv.Tax.Ext.Get(ExtKeyBillingMode)) -} diff --git a/addons/fr/ctc/flow10/bill_payment_test.go b/addons/fr/ctc/flow10/bill_payment_test.go deleted file mode 100644 index 70b171dd0..000000000 --- a/addons/fr/ctc/flow10/bill_payment_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package flow10 - -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, - Method: &pay.Instructions{Key: pay.MeansKeyCreditTransfer}, - 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, - Method: &pay.Instructions{Key: pay.MeansKeyCreditTransfer}, - 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) - // A B2C payment line has no Document at all — should still pass. - 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 (bill.go) --------------------------------- - -func TestPaymentIsB2BWrongType(t *testing.T) { - assert.False(t, paymentIsB2BAny("x")) -} - -func TestPaymentVATRatesAllowedWrongType(t *testing.T) { - assert.True(t, paymentVATRatesAllowed("x")) -} - -func TestPaymentVATRatesAllowedNilLine(t *testing.T) { - p := &bill.Payment{Lines: []*bill.PaymentLine{nil}} - assert.True(t, paymentVATRatesAllowed(p)) -} diff --git a/addons/fr/ctc/flow10/extensions.go b/addons/fr/ctc/flow10/extensions.go deleted file mode 100644 index f7499212c..000000000 --- a/addons/fr/ctc/flow10/extensions.go +++ /dev/null @@ -1,190 +0,0 @@ -package flow10 - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" -) - -// French CTC extension keys for e-reporting -const ( - // ExtKeyBillingMode defines the billing framework mode (B1-B8, S1-S8, M1-M8). - // Shared conceptually with Flow 2: both flows use the same underlying key - // "fr-ctc-billing-mode" and identical value set; each addon declares the - // definition independently so consumers can opt into either flow in - // isolation. - ExtKeyBillingMode cbc.Key = "fr-ctc-billing-mode" - - // ExtKeyB2CCategory classifies the B2C transaction for PPF reporting - // (G1.68). Required on B2C invoices and B2C payments. - ExtKeyB2CCategory cbc.Key = "fr-ctc-b2c-category" -) - -// 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). -// The prefix denotes the invoice nature: -// - B: Goods invoice (Biens) -// - S: Services invoice -// - M: Mixed/dual invoice (goods and services that are not accessory to each other) -// -// The numeric suffix encodes the payment context: -// - 1: standard invoice (payment outstanding) -// - 2: invoice already paid at issue -// - 4: final invoice issued after a down payment -// - 5: service invoice issued by a subcontractor -// - 6: service invoice issued by a co-contractor -// - 7: invoice subject to e-reporting (VAT already collected) -const ( - // BillingModeB1: goods invoice — payment outstanding. - BillingModeB1 cbc.Code = "B1" - // BillingModeB2: goods invoice — already paid at issue. - BillingModeB2 cbc.Code = "B2" - // BillingModeB4: final goods invoice after a down payment. - BillingModeB4 cbc.Code = "B4" - // BillingModeB7: goods invoice subject to e-reporting (VAT already collected). - BillingModeB7 cbc.Code = "B7" - // BillingModeS1: service invoice — payment outstanding. - BillingModeS1 cbc.Code = "S1" - // BillingModeS2: service invoice — already paid at issue. - BillingModeS2 cbc.Code = "S2" - // BillingModeS4: final service invoice after a down payment. - BillingModeS4 cbc.Code = "S4" - // BillingModeS5: service invoice issued by a subcontractor. - BillingModeS5 cbc.Code = "S5" - // BillingModeS6: service invoice issued by a co-contractor. - BillingModeS6 cbc.Code = "S6" - // BillingModeS7: service invoice subject to e-reporting (VAT already collected). - BillingModeS7 cbc.Code = "S7" - // BillingModeM1: mixed invoice (goods and services) — payment outstanding. - BillingModeM1 cbc.Code = "M1" - // BillingModeM2: mixed invoice — already paid at issue. - BillingModeM2 cbc.Code = "M2" - // BillingModeM4: final mixed invoice after a down payment. - BillingModeM4 cbc.Code = "M4" -) - -var extensions = []*cbc.Definition{ - { - 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 Flow 10 reporting to the PPF - (G1.68). Required on B2C invoices and B2C payments. - - - 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 reporting Flux 10 au PPF (G1.68). - Obligatoire sur les factures et paiements B2C. - - - 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: ExtKeyBillingMode, - Name: i18n.String{ - i18n.EN: "Billing Mode", - i18n.FR: "Cadre de Facturation", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - Code used to describe the billing framework of the invoice. The billing mode - indicates the nature of goods/services and the payment context. - - Code prefixes indicate the invoice nature: - - "B": Goods invoice (Biens) - - "S": Services invoice - - "M": Mixed/dual invoice (goods and services that are not accessory to each other) - - The numeric suffix indicates the payment type (1=deposit, 2=already paid, - 4=final after down payment, 5=subcontractor, 6=co-contractor, 7=e-reporting). - `), - i18n.FR: here.Doc(` - Code utilisé pour décrire le cadre de facturation de la facture. Le mode de - facturation indique la nature des biens/services et le contexte de paiement. - - Les préfixes de code indiquent la nature de la facture : - - "B" : Facture de biens - - "S" : Facture de services - - "M" : Facture mixte (biens et services qui ne sont pas accessoires l'un de l'autre) - - Le suffixe numérique indique le type de paiement (1=dépôt, 2=déjà payée, - 4=définitive après acompte, 5=sous-traitant, 6=cotraitant, 7=e-reporting). - `), - }, - 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)"}}, - }, - }, -} diff --git a/addons/fr/ctc/flow10/flow10.go b/addons/fr/ctc/flow10/flow10.go deleted file mode 100644 index 34bb3bf9c..000000000 --- a/addons/fr/ctc/flow10/flow10.go +++ /dev/null @@ -1,87 +0,0 @@ -// Package flow10 handles the extensions and validation rules for the French -// CTC (Cycle de Traitement de la Commande) Flow 10 e-reporting requirements -// for transactions not subject to Flow 2 domestic B2B clearance. -package flow10 - -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/pkg/here" - "github.com/invopop/gobl/rules" - "github.com/invopop/gobl/rules/is" - "github.com/invopop/gobl/tax" -) - -const ( - // Key identifies the French CTC Flow 10 addon family. - Key cbc.Key = "fr-ctc-flow10" - - // V1 is the key for the French CTC Flow 10 addon - V1 cbc.Key = Key + "-v1" -) - -func init() { - tax.RegisterAddonDef(newAddon()) - rules.RegisterWithGuard( - Key.String(), - rules.GOBL.Add("FR-CTC-FLOW10"), - is.InContext(tax.AddonIn(V1)), - billInvoiceRules(), - billPaymentRules(), - ) -} - -func newAddon() *tax.AddonDef { - return &tax.AddonDef{ - Key: V1, - Name: i18n.String{ - i18n.EN: "France CTC Flow 10", - i18n.FR: "France CTC Flux 10", - }, - Description: i18n.String{ - i18n.EN: here.Doc(` - Support for the French CTC (Continuous Transaction Control) Flow 10 - e-reporting requirements from the French electronic invoicing reform. - - Flow 10 covers transactions that must be reported to the tax authority - but are not subject to the domestic B2B clearance flow (Flow 2). This - includes B2C, cross-border, and out-of-scope transactions where VAT - data and payment data must still be transmitted to the PPF. - `), - i18n.FR: here.Doc(` - Support pour le CTC (Contrôle Continu des Transactions) français Flux 10 - pour les exigences de e-reporting de la réforme française de la - facturation électronique. - - Le Flux 10 couvre les transactions qui doivent être déclarées à - l'administration fiscale mais qui ne sont pas soumises au flux B2B - domestique (Flux 2). Cela inclut les transactions B2C, transfrontalières - et hors champ pour lesquelles les données de TVA et de paiement doivent - tout de même être transmises au PPF. - `), - }, - Sources: []*cbc.Source{ - { - Title: i18n.String{ - i18n.EN: "External Specifications", - i18n.FR: "Spécifications Externes", - }, - URL: "https://www.impots.gouv.fr/specifications-externes-b2b", - }, - }, - Extensions: extensions, - Scenarios: scenarios, - Normalizer: normalize, - } -} - -func normalize(doc any) { - switch obj := doc.(type) { - case *bill.Invoice: - normalizeInvoice(obj) - case *org.Party: - normalizeParty(obj) - } -} diff --git a/addons/fr/ctc/flow10/party.go b/addons/fr/ctc/flow10/party.go deleted file mode 100644 index 1e059d903..000000000 --- a/addons/fr/ctc/flow10/party.go +++ /dev/null @@ -1,144 +0,0 @@ -package flow10 - -import ( - "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" -) - -// ICD 6523 scheme IDs accepted for Flow 10 party identification (G2.19). -const ( - schemeIDSIREN = "0002" // French SIREN (9 digits) - schemeIDEUVAT = "0223" // EU (non-French) intra-community VAT ID - schemeIDNonEU = "0227" // Outside EU: country code + first 16 chars of name - schemeIDRIDET = "0228" // New Caledonia RIDET - schemeIDTAHITI = "0229" // French Polynesia TAHITI -) - -// 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{ - schemeIDSIREN, - schemeIDEUVAT, - schemeIDNonEU, - schemeIDRIDET, - schemeIDTAHITI, -} - -// schemeIDsRequiringVAT are the scheme IDs for which party.TaxID must also -// be present (G2.33): SIREN (French) and EU non-French VAT identifiers. -var schemeIDsRequiringVAT = []string{ - schemeIDSIREN, - schemeIDEUVAT, -} - -// normalizeParty attempts to derive the Flow 10 party identity from -// information already present on the party (TaxID, SIRET) so that the -// downstream rules can succeed without the caller having to hand-craft -// the ICD 6523 identity entry. -func normalizeParty(party *org.Party) { - if party == nil || 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)), schemeIDSIREN) - case isEUNonFrance(country): - ensureIdentity(party, "", cbc.Code(country.String()+code), schemeIDEUVAT) - } -} - -// sirenFromFrenchTaxID extracts the 9-digit SIREN from a French TaxID. The -// French VAT format is FR + 2 check digits + 9 digit SIREN; if the TaxID -// has already been stripped to just the 9 digits we return it as-is. If a -// SIRET identity is already present we prefer its first 9 digits. -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; identities that already declare the scheme (via -// iso.ExtKeySchemeID) are left untouched so user-supplied data wins. -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(tax.ExtMap{ - iso.ExtKeySchemeID: cbc.Code(schemeID), - }), - Scope: org.IdentityScopeLegal, - }) -} - -// partyLegalSchemeID returns the ICD 6523 scheme ID of the identity the -// party presents as its legal identifier for Flow 10. It prefers an -// identity scoped as "legal"; failing that, the first identity that -// declares a known Flow 10 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) -} diff --git a/addons/fr/ctc/flow10/party_test.go b/addons/fr/ctc/flow10/party_test.go deleted file mode 100644 index 1522b279a..000000000 --- a/addons/fr/ctc/flow10/party_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package flow10 - -import ( - "testing" - - "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/tax" - "github.com/stretchr/testify/assert" -) - -// --- isEUNonFrance ------------------------------------------------------- - -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)) -} - -// --- normalizeParty ------------------------------------------------------ - -func TestNormalizePartyNilSafe(t *testing.T) { - assert.NotPanics(t, func() { normalizeParty(nil) }) -} - -func TestNormalizePartyNoTaxID(t *testing.T) { - p := &org.Party{Name: "Solo"} - normalizeParty(p) - assert.Empty(t, p.Identities) -} - -func TestNormalizePartyEmptyTaxIDCode(t *testing.T) { - p := &org.Party{TaxID: &tax.Identity{Country: "FR"}} - normalizeParty(p) - assert.Empty(t, p.Identities) -} - -func TestNormalizePartyNonEUNonFR(t *testing.T) { - // Non-EU / non-FR countries are left alone. - p := &org.Party{TaxID: &tax.Identity{Country: "US", Code: "12-3456789"}} - normalizeParty(p) - assert.Empty(t, p.Identities) -} - -// --- sirenFromFrenchTaxID ------------------------------------------------ - -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) -} - -// --- ensureIdentity ------------------------------------------------------ - -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(tax.ExtMap{iso.ExtKeySchemeID: "0002"}), - }, - }} - ensureIdentity(p, "", "new", "0002") - assert.Len(t, p.Identities, 1) - assert.Equal(t, cbc.Code("existing"), p.Identities[0].Code) -} - -// --- partyLegalSchemeID -------------------------------------------------- - -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(tax.ExtMap{iso.ExtKeySchemeID: "0227"})}, - {Code: "B", Scope: org.IdentityScopeLegal, Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{iso.ExtKeySchemeID: "9999"})}, - {Code: "B", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"})}, - }} - assert.Equal(t, "0002", partyLegalSchemeID(p)) -} diff --git a/addons/fr/ctc/flow2/bill_invoice.go b/addons/fr/ctc/flow2/bill_invoice.go deleted file mode 100644 index 68d116319..000000000 --- a/addons/fr/ctc/flow2/bill_invoice.go +++ /dev/null @@ -1,895 +0,0 @@ -package flow2 - -import ( - "regexp" - "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/regimes/fr" - "github.com/invopop/gobl/rules" - "github.com/invopop/gobl/rules/is" - "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" - - // 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 for - // suppliers operating under a single-VAT-group identity (scheme 0231). - stcMembreAssujettiUnique = "MEMBRE_ASSUJETTI_UNIQUE" - - // noteSubjectBAR is the UNTDID 4451 text-subject code that carries - // the transaction category for French CTC (B2B / B2BINT / B2C / etc.). - noteSubjectBAR cbc.Code = "BAR" - - // barTreatmentB2B is the BAR value Flow 2 reads when discriminating - // B2B vs other treatments inside isB2BTransaction. The default is - // not auto-applied — callers must add the BAR note explicitly with - // one of the values listed in allowedBARTreatments. - barTreatmentB2B = "B2B" -) - -// 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 - - normalizeBillingMode(inv) - normalizeRequiredNotes(inv) - normalizeSTCNote(inv) -} - -// 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(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), - }) -} - -// defaultRequiredNotes lists the three UNTDID 4451 mentions French CTC -// requires on every B2B invoice (BR-FR-05). The defaults are minimal -// regulatory placeholders — they intentionally avoid committing to -// specific payment terms (penalty amounts, interest rates, etc.), -// which are business-specific. Callers should override with their own -// terms when required; supplying any note with the matching -// untdid-text-subject suppresses the default. -var defaultRequiredNotes = []*org.Note{ - { - Key: org.NoteKeyPayment, - Text: "Conditions de paiement selon les conditions générales de vente.", - Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{untdid.ExtKeyTextSubject: "PMD"}), - }, - { - Key: org.NoteKeyPaymentTerm, - Text: "Aucun escompte n'est accordé pour paiement anticipé.", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: "AAB"}), - }, -} - -// normalizeRequiredNotes appends any of the three regulatory PMT / PMD -// / AAB notes that are missing from the invoice. A user-supplied note -// carrying the same untdid-text-subject is left untouched. -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 -} - -// normalizeBillingMode picks a sensible default for the Flow 2 -// billing-mode extension when the caller hasn't supplied one. We -// default to the Mixed (M) prefix since it is the safest without -// line-level analysis: M2 when the invoice is fully paid, M1 otherwise. -// The user can override by setting the extension explicitly. -func normalizeBillingMode(inv *bill.Invoice) { - if inv.Tax.Ext.Get(ExtKeyBillingMode) != "" { - return - } - mode := BillingModeM1 - if inv.Totals != nil && inv.Totals.Paid() { - mode = BillingModeM2 - } - inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, mode) -} - -// isB2BTransaction determines whether the invoice should be treated as a -// B2B transaction. Per BR-FR-22, the rule applies "if the invoice is -// processed B2B or carries a BAR note with text B2B". Flow 2 *is* the -// B2B addon, so the default treatment is B2B; the helper returns false -// only when a BAR note explicitly classifies the invoice as something -// else (B2BINT / B2C / OUTOFSCOPE / ARCHIVEONLY). -func isB2BTransaction(inv *bill.Invoice) bool { - if inv == nil { - return false - } - - for _, note := range inv.Notes { - if note == nil || note.Ext.IsZero() { - continue - } - if note.Ext.Get(untdid.ExtKeyTextSubject) != noteSubjectBAR { - continue - } - // An explicit BAR note overrides the default: only B2B counts as - // B2B; any other treatment opts the invoice out of the B2B rules. - return note.Text == barTreatmentB2B - } - - // No BAR note → the addon's B2B default applies. - return true -} - -// 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 "" -} - -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 reference the original invoice in preceding (BR-FR-CO-04)", - is.Present, - ), - rules.Assert("04", "corrective invoices must reference exactly one preceding invoice — multiple references are not allowed (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 (386, 500, 503) are not allowed for factoring billing modes (B4, S4, M4) (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", "ordering.contracts is required for consolidated credit notes (BR-FR-CO-03)", - is.Present, - ), - rules.Assert("25", "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("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 == 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", "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) == noteSubjectBAR { - 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/flow2/bill_invoice_test.go b/addons/fr/ctc/flow2/bill_invoice_test.go deleted file mode 100644 index 77ef09723..000000000 --- a/addons/fr/ctc/flow2/bill_invoice_test.go +++ /dev/null @@ -1,2782 +0,0 @@ -package flow2 - -import ( - "testing" - - "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(V1), - Code: "FAC-2024-001", - Currency: "EUR", - Type: bill.InvoiceTypeStandard, - Tax: &bill.Tax{ - Ext: tax.ExtensionsOf(tax.ExtMap{ - ExtKeyBillingMode: 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(tax.ExtMap{ - untdid.ExtKeyTextSubject: "PMT", - }), - }, - { - Key: org.NoteKeyPaymentMethod, - Text: "Late payment penalties apply as per our general terms of sale.", - Ext: tax.ExtensionsOf(tax.ExtMap{ - untdid.ExtKeyTextSubject: "PMD", - }), - }, - { - Key: org.NoteKeyPaymentTerm, - Text: "No discount offered for early payment.", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - Text: "B2B", - }, - &org.Note{ - Key: org.NoteKeyLegal, - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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) - inv.Supplier.Inboxes = []*org.Inbox{ - {Scheme: "0088", Code: "1234567890123"}, - } - require.NoError(t, inv.Calculate()) - // Append BAR=B2C *after* Calculate so the en16931 normalizer - // (which rewrites the subject based on note.Key) doesn't replace - // "BAR" with "ABL" on a Key=legal note. - inv.Notes = append(inv.Notes, &org.Note{ - Key: org.NoteKeyLegal, - Text: "B2C", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectBAR}), - }) - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - ExtKeyBillingMode: BillingModeS5, // Subcontractor - }), - } - require.NoError(t, inv.Calculate()) - assert.Equal(t, BillingModeS5.String(), inv.Tax.Ext.Get(ExtKeyBillingMode).String()) - }) - - t.Run("invalid billing mode rejected - B8", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax = &bill.Tax{ - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(tax.ExtMap{ - untdid.ExtKeyReference: "AFL", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(tax.ExtMap{ - untdid.ExtKeyReference: "AWW", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(tax.ExtMap{ - untdid.ExtKeyReference: "CT", - }), - }, - { - Code: "67890", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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(tax.ExtMap{ - 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, "ordering.contracts") - 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, "ordering.contracts") - 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(tax.ExtMap{ - 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: stcMembreAssujettiUnique, - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), - }) - 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(tax.ExtMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "ORDER-123", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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: stcMembreAssujettiUnique, - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), - }) - 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(tax.ExtMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = &bill.Ordering{ - Identities: []*org.Identity{ - { - Code: "ORDER-123", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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: stcMembreAssujettiUnique, - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), - }) - 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(tax.ExtMap{ - iso.ExtKeySchemeID: "0231", // STC scheme - }), - }) - inv.Ordering = nil - // Add TXD note - inv.Notes = append(inv.Notes, &org.Note{ - Text: stcMembreAssujettiUnique, - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectTXD}), - }) - 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(tax.ExtMap{ - 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 - }, - } - require.NoError(t, inv.Calculate()) - // Strip the TXD note that the normalizer auto-added to simulate a - // downstream consumer that drops it before validation. - 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.Error(t, err) - assert.ErrorContains(t, err, string(noteSubjectTXD)) - assert.ErrorContains(t, err, stcMembreAssujettiUnique) - }) - - t.Run("STC supplier auto-fills TXD note via normalizer", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Supplier.Identities = append(inv.Supplier.Identities, &org.Identity{ - Code: "12345678", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0231", - }), - }) - inv.Ordering = &bill.Ordering{ - Seller: &org.Party{ - Name: "Assujetti Unique", - TaxID: inv.Supplier.TaxID, - }, - } - require.NoError(t, inv.Calculate()) - require.NoError(t, rules.Validate(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") - }) -} - -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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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, "must reference the original invoice in preceding") - 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, "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") - 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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 (386, 500, 503) are 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(ExtKeyBillingMode, 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 (386, 500, 503) are 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(ExtKeyBillingMode, 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 (386, 500, 503) are 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(tax.ExtMap{ - 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(V1) - - 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(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(tax.ExtMap{ - 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(tax.ExtMap{ - untdid.ExtKeyTextSubject: "BAR", - }), - }) - - // Remove customer SIREN - inv.Customer.Identities = []*org.Identity{ - { - Code: "OTHER-ID", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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(V1), tax.AddonForKey(V1)) - } -} - -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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, BillingModeS7) - inv.Tax.Ext = inv.Tax.Ext.Set(untdid.ExtKeyDocumentType, "380") - - require.NoError(t, inv.Calculate()) - err := rules.Validate(inv) - assert.NoError(t, err) - }) -} - -// TestRequiredNoteCodesValidation exercises the BR-FR-05 rule by stripping -// notes AFTER Calculate so the addon's default-note normalization does -// not refill them. This simulates a downstream consumer that drops the -// regulatory mentions before validating. -func TestRequiredNoteCodesValidation(t *testing.T) { - stripSubject := func(inv *bill.Invoice, subject cbc.Code) { - kept := inv.Notes[:0] - for _, n := range inv.Notes { - if n != nil && n.Ext.Get(untdid.ExtKeyTextSubject) == subject { - continue - } - kept = append(kept, n) - } - inv.Notes = kept - } - - t.Run("missing PMT note code (BR-FR-05)", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - require.NoError(t, inv.Calculate()) - stripSubject(inv, "PMT") - 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) - require.NoError(t, inv.Calculate()) - stripSubject(inv, "PMD") - 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) - require.NoError(t, inv.Calculate()) - stripSubject(inv, "AAB") - 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) - require.NoError(t, inv.Calculate()) - stripSubject(inv, "PMD") - stripSubject(inv, "AAB") - 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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(ExtKeyBillingMode, 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("nil Tax is rebuilt by the normalizer", func(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax = nil - require.NoError(t, inv.Calculate()) - // Normalizer recreates Tax and fills the mandatory extensions. - require.NotNil(t, inv.Tax) - assert.NotEmpty(t, inv.Tax.Ext.Get(ExtKeyBillingMode)) - assert.NoError(t, rules.Validate(inv)) - }) -} - -func TestNormalizeBillingModeDefaultsM1(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Strip any billing mode the fixture provided so we exercise the - // default path. - inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) - require.NoError(t, inv.Calculate()) - assert.Equal(t, BillingModeM1, inv.Tax.Ext.Get(ExtKeyBillingMode)) -} - -func TestNormalizeBillingModeDefaultsM2WhenPaid(t *testing.T) { - inv := testInvoiceB2BStandard(t) - inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) - require.NoError(t, inv.Calculate()) - // Re-apply M-prefix default by simulating a paid invoice via Totals.Due. - due := num.MakeAmount(0, 2) - inv.Totals.Due = &due - // Re-run normalize: clear the billing mode and call Calculate again so - // the addon normalizer picks up the now-paid totals. - inv.Tax.Ext = inv.Tax.Ext.Delete(ExtKeyBillingMode) - require.NoError(t, inv.Calculate()) - assert.Equal(t, BillingModeM2, inv.Tax.Ext.Get(ExtKeyBillingMode)) -} - -func TestNormalizeBillingModePreservesUserValue(t *testing.T) { - inv := testInvoiceB2BStandard(t) - // Fixture sets S1 — re-set explicitly to make the assertion clear. - inv.Tax.Ext = inv.Tax.Ext.Set(ExtKeyBillingMode, BillingModeB7) - require.NoError(t, inv.Calculate()) - assert.Equal(t, BillingModeB7, inv.Tax.Ext.Get(ExtKeyBillingMode)) -} - -// --- Internal helper coverage (defensive nil / wrong-type branches) ----- - -func TestIsB2BTransactionNilInvoice(t *testing.T) { - assert.False(t, isB2BTransaction(nil)) -} - -func TestIsB2BTransactionNoNotesDefaultsTrue(t *testing.T) { - // Flow 2 is the B2B addon — absence of a BAR note means B2B by default. - assert.True(t, isB2BTransaction(&bill.Invoice{})) -} - -func TestIsB2BTransactionExplicitB2C(t *testing.T) { - inv := &bill.Invoice{Notes: []*org.Note{{ - Text: "B2C", - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectBAR}), - }}} - assert.False(t, isB2BTransaction(inv)) -} - -func TestIsB2BTransactionExplicitB2B(t *testing.T) { - inv := &bill.Invoice{Notes: []*org.Note{{ - Text: barTreatmentB2B, - Ext: tax.ExtensionsOf(tax.ExtMap{untdid.ExtKeyTextSubject: noteSubjectBAR}), - }}} - assert.True(t, isB2BTransaction(inv)) -} - -func TestIsSelfBilledInvoiceNilInvoice(t *testing.T) { - assert.False(t, isSelfBilledInvoice(nil)) -} - -func TestIsSelfBilledInvoiceMissingDocType(t *testing.T) { - inv := &bill.Invoice{Tax: &bill.Tax{Ext: tax.ExtensionsOf(tax.ExtMap{"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 TestIdentitiesHasSIRENWrongType(t *testing.T) { - assert.True(t, identitiesHasSIREN(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 TestNotesValidBARTextWrongType(t *testing.T) { - assert.True(t, notesValidBARText(42)) -} - -func TestInvoiceDueDatesValidWrongType(t *testing.T) { - assert.True(t, invoiceDueDatesValid(42)) -} - -func TestFinalInvoicePayableZeroWrongType(t *testing.T) { - assert.True(t, finalInvoicePayableZero(42)) -} diff --git a/addons/fr/ctc/flow2/extensions.go b/addons/fr/ctc/flow2/extensions.go deleted file mode 100644 index 22ac2f897..000000000 --- a/addons/fr/ctc/flow2/extensions.go +++ /dev/null @@ -1,184 +0,0 @@ -package flow2 - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" -) - -// French CTC extension keys for B2B e-invoicing -const ( - // ExtKeyBillingMode defines the billing framework mode (B1-B8, S1-S8, M1-M8) - ExtKeyBillingMode cbc.Key = "fr-ctc-billing-mode" -) - -// Billing mode codes (Cadre de Facturation). -// The prefix denotes the invoice nature: -// - B: Goods invoice (Biens) -// - S: Services invoice -// - M: Mixed/dual invoice (goods and services that are not accessory to each other) -// -// The numeric suffix encodes the payment context: -// - 1: standard invoice (payment outstanding) -// - 2: invoice already paid at issue -// - 4: final invoice issued after a down payment -// - 5: service invoice issued by a subcontractor -// - 6: service invoice issued by a co-contractor -// - 7: invoice subject to e-reporting (VAT already collected) -const ( - // BillingModeB1: goods invoice — payment outstanding. - BillingModeB1 cbc.Code = "B1" - // BillingModeB2: goods invoice — already paid at issue. - BillingModeB2 cbc.Code = "B2" - // BillingModeB4: final goods invoice after a down payment. - BillingModeB4 cbc.Code = "B4" - // BillingModeB7: goods invoice subject to e-reporting (VAT already collected). - BillingModeB7 cbc.Code = "B7" - // BillingModeS1: service invoice — payment outstanding. - BillingModeS1 cbc.Code = "S1" - // BillingModeS2: service invoice — already paid at issue. - BillingModeS2 cbc.Code = "S2" - // BillingModeS4: final service invoice after a down payment. - BillingModeS4 cbc.Code = "S4" - // BillingModeS5: service invoice issued by a subcontractor. - BillingModeS5 cbc.Code = "S5" - // BillingModeS6: service invoice issued by a co-contractor. - BillingModeS6 cbc.Code = "S6" - // BillingModeS7: service invoice subject to e-reporting (VAT already collected). - BillingModeS7 cbc.Code = "S7" - // BillingModeM1: mixed invoice (goods and services) — payment outstanding. - BillingModeM1 cbc.Code = "M1" - // BillingModeM2: mixed invoice — already paid at issue. - BillingModeM2 cbc.Code = "M2" - // BillingModeM4: final mixed invoice after a down payment. - BillingModeM4 cbc.Code = "M4" -) - -var extensions = []*cbc.Definition{ - { - Key: ExtKeyBillingMode, - Name: i18n.String{ - i18n.EN: "Billing Mode", - i18n.FR: "Cadre de Facturation", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - Code used to describe the billing framework of the invoice. The billing mode - indicates the nature of goods/services and the payment context. - - Code prefixes indicate the invoice nature: - - "B": Goods invoice (Biens) - - "S": Services invoice - - "M": Mixed/dual invoice (goods and services that are not accessory to each other) - - The numeric suffix indicates the payment type (1=deposit, 2=already paid, - 4=final after down payment, 5=subcontractor, 6=co-contractor, 7=e-reporting). - `), - i18n.FR: here.Doc(` - Code utilisé pour décrire le cadre de facturation de la facture. Le mode de - facturation indique la nature des biens/services et le contexte de paiement. - - Les préfixes de code indiquent la nature de la facture : - - "B" : Facture de biens - - "S" : Facture de services - - "M" : Facture mixte (biens et services qui ne sont pas accessoires l'un de l'autre) - - Le suffixe numérique indique le type de paiement (1=dépôt, 2=déjà payée, - 4=définitive après acompte, 5=sous-traitant, 6=cotraitant, 7=e-reporting). - `), - }, - 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)", - }, - }, - }, - }, -} diff --git a/addons/fr/ctc/flow2/flow2.go b/addons/fr/ctc/flow2/flow2.go deleted file mode 100644 index b81cc64db..000000000 --- a/addons/fr/ctc/flow2/flow2.go +++ /dev/null @@ -1,107 +0,0 @@ -// Package flow2 handles the extensions and validation rules for the French -// CTC (Cycle de Traitement de la Commande) Flow 2 B2B e-invoicing requirements. -package flow2 - -import ( - "github.com/invopop/gobl/addons/eu/en16931" - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/pkg/here" - "github.com/invopop/gobl/rules" - "github.com/invopop/gobl/rules/is" - "github.com/invopop/gobl/tax" -) - -const ( - // Key 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. - Key cbc.Key = "fr-ctc-flow2" - - // V1 is the key for the French CTC Flow 2 addon - V1 cbc.Key = Key + "-v1" -) - -func init() { - tax.RegisterAddonDef(newAddon()) - rules.RegisterWithGuard( - Key.String(), - rules.GOBL.Add("FR-CTC-FLOW2"), - is.InContext(tax.AddonIn(V1)), - billInvoiceRules(), - orgPartyRules(), - orgIdentityRules(), - orgInboxRules(), - orgItemRules(), - ) -} - -func newAddon() *tax.AddonDef { - return &tax.AddonDef{ - Key: V1, - Name: i18n.String{ - i18n.EN: "France CTC Flow 2", - i18n.FR: "France CTC Flux 2", - }, - Requires: []cbc.Key{ - en16931.V2017, - }, - 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. - - It requires the EN16931 addon as it extends the European standard with French-specific - requirements for the e-invoicing reform. - - 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. - `), - 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. - - 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. - - 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. - - 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. - `), - }, - Sources: []*cbc.Source{ - { - Title: i18n.String{ - i18n.EN: "External Specifications", - i18n.FR: "Spécifications Externes", - }, - URL: "https://www.impots.gouv.fr/specifications-externes-b2b", - }, - }, - Extensions: extensions, - Tags: []*tax.TagSet{ - invoiceTags, - }, - Normalizer: normalize, - } -} - -func normalize(doc any) { - switch obj := doc.(type) { - case *bill.Invoice: - normalizeInvoice(obj) - case *org.Party: - normalizeParty(obj) - case *org.Identity: - normalizeIdentity(obj) - } -} diff --git a/addons/fr/ctc/flow2/org.go b/addons/fr/ctc/flow2/org.go deleted file mode 100644 index f8da5e011..000000000 --- a/addons/fr/ctc/flow2/org.go +++ /dev/null @@ -1,135 +0,0 @@ -package flow2 - -import ( - "regexp" - - "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/tax" -) - -// Inbox validation patterns -var sirenInboxFormatRegex = regexp.MustCompile(`^[A-Za-z0-9+\-_/]+$`) - -const ( - // 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 = "0002" - - // 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 = "0224" -) - -func normalizeParty(party *org.Party) { - if party == nil { - return - } - - // Normalize identities - normalizeIdentities(party) - - // Normalize inboxes - normalizeInboxes(party) -} - -// normalizeIdentities handles all identity-related normalizations -func normalizeIdentities(party *org.Party) { - if party == nil || len(party.Identities) == 0 { - return - } - - 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 - 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, - Code: cbc.Code(sirenCode), - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: identitySchemeIDSIREN, - }), - } - party.Identities = append(party.Identities, siren) - } - } - - // 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 -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 -} - -// normalizeInboxes handles all inbox-related normalizations -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 == 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 = org.InboxKeyPeppol - } -} diff --git a/addons/fr/ctc/flow2/org_test.go b/addons/fr/ctc/flow2/org_test.go deleted file mode 100644 index a83b0a20a..000000000 --- a/addons/fr/ctc/flow2/org_test.go +++ /dev/null @@ -1,978 +0,0 @@ -package flow2 - -import ( - "strings" - "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" - "github.com/stretchr/testify/assert" -) - -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 - }, - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "732829320", // Starts with SIREN from VAT - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("valid SIREN inbox matching SIREN identity", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "123456789", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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 - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("SIREN inbox with additional routing info (cbc.Code limits apply)", func(t *testing.T) { - party := &org.Party{ - 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 - }, - }, - } - 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", - }, - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "999999999", // Format check only, not SIREN match - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - 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 - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.ErrorContains(t, err, "must be in a valid format") - }) - - 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", - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("SIREN inbox with allowed cbc.Code separators", func(t *testing.T) { - party := &org.Party{ - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: "123456789-test", // cbc.Code allows separators between alphanumeric - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - 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)) - - party := &org.Party{ - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: cbc.Code(longCode), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - 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)) - - party := &org.Party{ - Inboxes: []*org.Inbox{ - { - Scheme: cbc.Code("0225"), - Code: cbc.Code(tooLongCode), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.ErrorContains(t, err, "no longer than 64") - }) -} - -func TestPeppolKeyNormalization(t *testing.T) { - 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", - }, - Identities: []*org.Identity{ - { - 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, org.InboxKeyPeppol, party.Inboxes[0].Key) - }) - - t.Run("peppol key not set if another inbox already has it", func(t *testing.T) { - party := &org.Party{ - TaxID: &tax.Identity{ - Country: "FR", - Code: "44732829320", - }, - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "732829320", - }, - }, - Inboxes: []*org.Inbox{ - { - 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, org.InboxKeyPeppol, party.Inboxes[1].Key) - assert.Equal(t, cbc.Key(""), 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", - }, - }, - } - ad.Normalizer(party) - // Check that peppol key was set (addon usage implies French context) - 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", - }, - Identities: []*org.Identity{ - { - 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, cbc.Key(""), party.Inboxes[0].Key) - }) -} - -func TestIdentitySchemeFormatValidation(t *testing.T) { - t.Run("valid identity with scheme 0224 - alphanumeric", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123XYZ", - Ext: tax.ExtensionsOf(tax.ExtMap{ - "iso-scheme-id": "0224", - }), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("valid identity with scheme 0224 - with allowed special characters", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123-info_data/route", - Ext: tax.ExtensionsOf(tax.ExtMap{ - "iso-scheme-id": "0224", - }), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("invalid identity with scheme 0224 - special chars not allowed", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123@invalid", - Ext: tax.ExtensionsOf(tax.ExtMap{ - "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) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Code: "ABC123", // Valid cbc.Code format, different scheme - Ext: tax.ExtensionsOf(tax.ExtMap{ - "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) - }) - - 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 - }, - }, - } - 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)) - - party := &org.Party{ - Identities: []*org.Identity{ - { - Code: cbc.Code(longCode), - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0224", - }), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err) - }) - - 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)) - - party := &org.Party{ - Identities: []*org.Identity{ - { - Code: cbc.Code(tooLongCode), - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0224", - }), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.ErrorContains(t, err, "no longer than 64") - }) -} - -func TestPrivateIDNormalization(t *testing.T) { - ad := tax.AddonForKey(V1) - - t.Run("private-id key sets ISO scheme ID 0224", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - 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) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Key: cbc.Key("private-id"), - Code: "ABC123XYZ", - Ext: tax.ExtensionsOf(tax.ExtMap{ - "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) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIREN, - Code: "123456789", - }, - }, - } - ad.Normalizer(party) - // Check that no ISO scheme ID was set - assert.True(t, party.Identities[0].Ext.IsZero()) - }) - - t.Run("existing ISO scheme ID not overwritten", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Key: cbc.Key("private-id"), - Code: "ABC123XYZ", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "9999", // Pre-existing value - }), - }, - }, - } - ad.Normalizer(party) - // Check that ISO scheme ID was overwritten to 0224 - assert.Equal(t, "0224", party.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) - }) -} - -func TestSIRENGenerationFromSIRET(t *testing.T) { - ad := tax.AddonForKey(V1) - - t.Run("generated SIREN from SIRET", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - 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 - for _, id := range party.Identities { - if id.Type == fr.IdentityTypeSIREN { - sirenIdentity = id - break - } - } - 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()) - }) - - t.Run("generated SIREN gets legal scope", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - }, - }, - } - ad.Normalizer(party) - // Find the SIREN identity - var sirenIdentity *org.Identity - for _, id := range party.Identities { - if id.Type == fr.IdentityTypeSIREN { - sirenIdentity = id - break - } - } - assert.NotNil(t, sirenIdentity) - assert.Equal(t, org.IdentityScopeLegal, sirenIdentity.Scope) - }) - - t.Run("SIREN not generated if already exists", func(t *testing.T) { - party := &org.Party{ - Identities: []*org.Identity{ - { - 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 -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) { - id := &org.Identity{ - Code: cbc.Code(strings.Repeat("A", 101)), - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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) { - id := &org.Identity{ - Code: "VALID-CODE_123", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0224", - }), - } - err := rules.Validate(id, withAddonContext()) - assert.NoError(t, err) - }) -} - -func TestValidatePartyEdgeCases(t *testing.T) { - t.Run("nil party returns nil", func(t *testing.T) { - err := rules.Validate((*org.Party)(nil), withAddonContext()) - assert.NoError(t, err) - }) - - t.Run("party with SIRET but mismatching SIREN", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0009", - }), - }, - { - Type: fr.IdentityTypeSIREN, - Code: "999999999", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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) { - party := &org.Party{ - Name: "Test Party", - Inboxes: []*org.Inbox{ - { - Scheme: "0225", - Code: cbc.Code(strings.Repeat("A", 126)), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.Error(t, err) - }) -} - -func TestNormalizePartyEdgeCases(t *testing.T) { - t.Run("normalize nil party", func(_ *testing.T) { - ad := tax.AddonForKey(V1) - 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(V1) - 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 - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - id, // Nil identity should be skipped via continue - { - Type: fr.IdentityTypeSIRET, - Code: "12345678901234", - }, - }, - } - ad := tax.AddonForKey(V1) - 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 { - if id != nil { - nonNilCount++ - if id.Type == fr.IdentityTypeSIREN { - hasSIREN = true - } - if id.Type == fr.IdentityTypeSIRET { - hasSIRET = true - } - } - } - - 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(tax.ExtMap{ - iso.ExtKeySchemeID: "0009", - }), - }, - }, - } - ad := tax.AddonForKey(V1) - 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(tax.ExtMap{ - iso.ExtKeySchemeID: "0009", - }), - }, - { - Type: fr.IdentityTypeSIREN, - Code: "123456789", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0002", - }), - Scope: org.IdentityScopeLegal, - }, - }, - } - ad := tax.AddonForKey(V1) - 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(V1) - ad.Normalizer(party) - assert.Equal(t, org.InboxKeyPeppol, 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: org.InboxKeyPeppol, - Scheme: "0088", - Code: "existing", - }, - { - Scheme: "0225", - Code: "123456789:test", - }, - }, - } - ad := tax.AddonForKey(V1) - ad.Normalizer(party) - // First inbox should keep its peppol key, second should not get it - assert.Equal(t, org.InboxKeyPeppol, party.Inboxes[0].Key) - assert.NotEqual(t, org.InboxKeyPeppol, party.Inboxes[1].Key) - }) - - t.Run("normalize inbox with nil element in array", func(t *testing.T) { - var nilInbox *org.Inbox - 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 - }, - } - ad := tax.AddonForKey(V1) - 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 - for _, inbox := range party.Inboxes { - if inbox != nil { - nonNilCount++ - if 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") - }) -} - -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) - }) - - t.Run("identity without ext returns error", func(t *testing.T) { - party := &org.Party{ - 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") - }) - - t.Run("duplicate ISO scheme IDs return error", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - { - Code: "123", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0002", - }), - }, - { - Code: "456", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0002", - }), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.Error(t, err) - assert.ErrorContains(t, err, "BR-FR-CO-10") - }) - - t.Run("nil identity in array is skipped", func(t *testing.T) { - var nilID *org.Identity - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - nilID, // Nil identity should be skipped via continue - { - Code: "123", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0002", - }), - }, - }, - } - err := rules.Validate(party, withAddonContext()) - assert.NoError(t, err, "validation should skip nil identity and succeed with valid identity") - }) - - t.Run("private-id (0224) with empty code", func(t *testing.T) { - party := &org.Party{ - Name: "Test Party", - Identities: []*org.Identity{ - { - Code: "", // Empty code - base Identity rules require code - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0224", // private-id scheme - }), - }, - { - Code: "valid-id", - Ext: tax.ExtensionsOf(tax.ExtMap{ - 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") - }) -} - -func TestValidateInboxEdgeCases(t *testing.T) { - t.Run("nil inbox returns nil", func(t *testing.T) { - err := rules.Validate((*org.Inbox)(nil), withAddonContext()) - 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", - } - err := rules.Validate(inbox, withAddonContext()) - assert.NoError(t, err) - }) - - 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 - } - err := rules.Validate(inbox, withAddonContext()) - assert.NoError(t, err) - }) -} - -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/flow2/tags.go b/addons/fr/ctc/flow2/tags.go deleted file mode 100644 index 5253595ec..000000000 --- a/addons/fr/ctc/flow2/tags.go +++ /dev/null @@ -1,47 +0,0 @@ -package flow2 - -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/addons/fr/ctc/flow6/bill_status_test.go b/addons/fr/ctc/flow6/bill_status_test.go deleted file mode 100644 index 7954fd6dd..000000000 --- a/addons/fr/ctc/flow6/bill_status_test.go +++ /dev/null @@ -1,558 +0,0 @@ -package flow6 - -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 -------------------------------------------------------------- - -// addonContext activates the Flow 6 rule guard so the addon's validators -// fire even for standalone objects (bill.Reason / org.Party) that do not -// carry an addon themselves. -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) -} - -func frPartyWithSIREN() *org.Party { - return &org.Party{ - Name: "Test Platform SARL", - Identities: []*org.Identity{ - { - Code: "356000000", - Ext: tax.ExtensionsOf(tax.ExtMap{ - iso.ExtKeySchemeID: "0002", - }), - }, - }, - } -} - -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: frPartyWithSIREN(), - Customer: customerParty(), - Issuer: issuerParty(), - Recipient: recipientParty(), - Lines: []*bill.StatusLine{ - { - Key: bill.StatusEventAccepted, - Date: &issued, - Doc: &org.DocumentRef{ - Code: "INV-2026-001", - IssueDate: &issued, - }, - }, - }, - } -} - -// issuerParty returns a buyer-end-party Issuer suitable for ack 23 -// (BR-FR-CDV-CL-03 allowed list: BY/AB/DL/SE/SR/PE/PR/II/IV). It -// carries an inbox so BR-FR-CDV-08 (issuer role ≠ WK/DFH ⇒ URIID) -// is satisfied. -func issuerParty() *org.Party { - return &org.Party{ - Name: "ACHETEUR", - Identities: []*org.Identity{{ - Code: "200000008", - Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, schemeIDSIREN), - }}, - Inboxes: []*org.Inbox{{Scheme: "0225", Code: "200000008_PEP"}}, - Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleBY), - } -} - -// customerParty returns a buyer-side counterpart used as the -// (optionally derived) Issuer/Recipient party. -func customerParty() *org.Party { - return &org.Party{ - Name: "ACHETEUR", - Identities: []*org.Identity{{ - Code: "200000008", - Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, schemeIDSIREN), - }}, - Inboxes: []*org.Inbox{{Scheme: "0225", Code: "200000008_PEP"}}, - Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleBY), - } -} - -// recipientParty returns the seller-end-party counterpart with an -// inbox, satisfying BR-FR-CDV-08 (recipient role ≠ WK/DFH ⇒ URIID). -func recipientParty() *org.Party { - return &org.Party{ - Name: "VENDEUR", - Identities: []*org.Identity{{ - Code: "100000009", - Ext: tax.MakeExtensions().Set(iso.ExtKeySchemeID, schemeIDSIREN), - }}, - Inboxes: []*org.Inbox{{Scheme: "0225", Code: "100000009_PEP"}}, - Ext: tax.MakeExtensions().Set(ExtKeyRole, RoleSE), - } -} - -// --- 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 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 can't - // auto-populate Supplier from it. - st.Recipient.Identities = nil - runNormalize(t, st) - err := rules.Validate(st) - assert.ErrorContains(t, err, "SIREN") -} - -// TestStatusSupplierSIRENFilledFromSEParty exercises the normaliser: -// when Supplier is missing its SIREN identity and an SE-roled party -// (Issuer or Recipient) carries one, the SIREN propagates onto -// Supplier so the writer can populate ref.IssuerTradeParty (MDT-129) -// without the caller repeating the seller in two places. -func TestStatusSupplierSIRENFilledFromSEParty(t *testing.T) { - st := testStatus(t) - st.Supplier = nil // recipient is SE-roled with SIREN 100000009 - 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("100000009"), st.Supplier.Identities[0].Code) - assert.Equal(t, schemeIDSIREN, - st.Supplier.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) -} - -// TestStatusKeyFilledFromStatusCodeExt exercises the reverse-direction -// normalisation: when the caller pins fr-ctc-status-code but leaves -// line.Key / Status.Type blank, both fields get filled from the -// process table. -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) - // CDV-208 Suspendue maps to stock `querying + response`. - 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 // pin: paid pairs with both update and response - 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 - // `paid` maps to two CDAR codes (211/update, 212/response) so the - // normaliser cannot default Type from the key alone — pin it. - 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(tax.ExtMap{ExtKeyReasonCode: "TX_TVA_ERR"}), - }} - obj, err := schema.NewObject(&Characteristic{ - ReasonCode: "QTE_ERR", // not matching any sibling reason - 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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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 TestPartyHasSIRENIdentityWrongType(t *testing.T) { - assert.False(t, partyHasSIRENIdentity("not a party")) -} - -func TestPartyHasSIRENIdentityNilParty(t *testing.T) { - assert.False(t, partyHasSIRENIdentity((*org.Party)(nil))) -} - -func TestPartyHasSIRENIdentityWithoutExt(t *testing.T) { - p := &org.Party{Identities: []*org.Identity{{Code: "X"}}} - assert.False(t, partyHasSIRENIdentity(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) { - // `paid + update` (CDV-211) does NOT require MEN. - 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")) -} diff --git a/addons/fr/ctc/flow6/codes_test.go b/addons/fr/ctc/flow6/codes_test.go deleted file mode 100644 index 3ceb8e9a0..000000000 --- a/addons/fr/ctc/flow6/codes_test.go +++ /dev/null @@ -1,373 +0,0 @@ -package flow6 - -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) { - // `accepted` is response-only (CDV-205); querying with Type=update - // must miss. - _, ok := CDARProcessCodeFor(bill.StatusEventAccepted, bill.StatusTypeUpdate) - assert.False(t, ok) -} - -// `paid` is the one key that pairs with both Status.Type values: -// update→211 (Paiement transmis), response→212 (Encaissée). -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) -} - -// Business-rejection reasons --------------------------------------------- - -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) -} - -// Technical / platform rejection reasons (code 213) --------------------- - -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) -} - -// Default-for-key: one per bucket with codes. - -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) -} - -// --- Internal helper coverage ------------------------------------------- - -func TestStatusTypeForKeyUnknown(t *testing.T) { - _, ok := statusTypeForKey("unknown") - assert.False(t, ok) -} diff --git a/addons/fr/ctc/flow6/extensions.go b/addons/fr/ctc/flow6/extensions.go deleted file mode 100644 index 24954a215..000000000 --- a/addons/fr/ctc/flow6/extensions.go +++ /dev/null @@ -1,173 +0,0 @@ -package flow6 - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" - "github.com/invopop/gobl/tax" -) - -// Flow 6 extension keys. -const ( - // ExtKeyRole carries the CDAR RoleCode for a party (UNCL 3035 subset). - // Applied per populated party (Supplier / Customer / Issuer / Recipient) - // on a bill.Status message. For example, an issuer party with RoleCode=SE - // is the seller. - ExtKeyRole cbc.Key = "fr-ctc-role" - - // ExtKeyReasonCode pins the exact CDAR ReasonCode for a bill.Reason. - // When set, takes precedence over the default_for_key lookup that the - // converter would otherwise perform from Reason.Key. - ExtKeyReasonCode cbc.Key = "fr-ctc-reason-code" - - // ExtKeyStatusCode surfaces the CDAR ProcessConditionCode (MDT-9) - // on a bill.Status. Normalised from the (line.Key, Status.Type) - // pair via the Flow 6 process table; carried on the GOBL document - // so the wire-level event identifier is visible without consulting - // the converter. - ExtKeyStatusCode cbc.Key = "fr-ctc-status-code" -) - -// Flow 6 party role codes (UNCL 3035 subset accepted by CDAR). -const ( - RoleSE cbc.Code = "SE" // Seller - RoleBY cbc.Code = "BY" // Buyer - RoleWK cbc.Code = "WK" // Work/Service receiver - RoleDFH cbc.Code = "DFH" // Delivery from - RoleAB cbc.Code = "AB" // Bank - RoleSR cbc.Code = "SR" // Sender / issuer on behalf of - RoleDL cbc.Code = "DL" // Dealer / intermediary - RolePE cbc.Code = "PE" // Payee - RolePR cbc.Code = "PR" // Payer - RoleII cbc.Code = "II" // Issuer of invoice - RoleIV cbc.Code = "IV" // Invoicee -) - -var extensions = []*cbc.Definition{ - { - 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 for each - populated party on a Flow 6 lifecycle message. The normalizer - fills the obvious defaults (Supplier → SE, Customer → BY) - and leaves the rest for the caller to set explicitly. - `), - }, - Values: []*cbc.Definition{ - {Code: RoleSE, Name: i18n.String{i18n.EN: "Seller"}}, - {Code: RoleBY, Name: i18n.String{i18n.EN: "Buyer"}}, - {Code: RoleWK, Name: i18n.String{i18n.EN: "Work / Service Receiver"}}, - {Code: RoleDFH, Name: i18n.String{i18n.EN: "Delivery From"}}, - {Code: RoleAB, Name: i18n.String{i18n.EN: "Bank"}}, - {Code: RoleSR, Name: i18n.String{i18n.EN: "Sender / Issuer on behalf of"}}, - {Code: RoleDL, Name: i18n.String{i18n.EN: "Dealer"}}, - {Code: RolePE, Name: i18n.String{i18n.EN: "Payee"}}, - {Code: RolePR, Name: i18n.String{i18n.EN: "Payer"}}, - {Code: RoleII, Name: i18n.String{i18n.EN: "Issuer of Invoice"}}, - {Code: RoleIV, Name: i18n.String{i18n.EN: "Invoicee"}}, - }, - }, - { - 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. Kept next to the extension definition so the -// extension catalogue stays self-documenting. -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 — single source of truth for the -// codes the addon accepts. -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/flow6/extensions_test.go b/addons/fr/ctc/flow6/extensions_test.go deleted file mode 100644 index b55ac3753..000000000 --- a/addons/fr/ctc/flow6/extensions_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package flow6 - -import ( - "testing" - - "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(tax.ExtMap{"k": "v"}) - assert.False(t, extValue(e).IsZero()) -} - -func TestExtValueFromPointer(t *testing.T) { - e := tax.ExtensionsOf(tax.ExtMap{"k": "v"}) - assert.False(t, extValue(&e).IsZero()) -} diff --git a/addons/fr/ctc/flow6/flow6.go b/addons/fr/ctc/flow6/flow6.go deleted file mode 100644 index 89c7ae319..000000000 --- a/addons/fr/ctc/flow6/flow6.go +++ /dev/null @@ -1,99 +0,0 @@ -// Package flow6 handles the extensions, validations and normalization -// for the French CTC Flow 6 — CDV (cycle de vie) lifecycle statuses -// exchanged between PAs (plateformes agréées) for B2B invoices. -// -// The addon is standalone: it does not require fr-ctc-flow2-v1. It -// operates on bill.Status documents, carries the codebooks needed for -// the gobl.cii CDAR round-trip, and validates the subset of (key, type) -// / reason / action / role combinations that Flow 6 accepts. -package flow6 - -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/org" - "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 ( - // Key identifies the French CTC Flow 6 addon family. - Key cbc.Key = "fr-ctc-flow6" - - // V1 is the key for the French CTC Flow 6 addon. - V1 cbc.Key = Key + "-v1" -) - -func init() { - tax.RegisterAddonDef(newAddon()) - schema.Register(schema.GOBL.Add("addons/fr/ctc/flow6"), - Characteristic{}, - ) - rules.RegisterWithGuard( - Key.String(), - rules.GOBL.Add("FR-CTC-FLOW6"), - is.InContext(tax.AddonIn(V1)), - billStatusRules(), - billReasonRules(), - billActionRules(), - orgPartyRules(), - ) -} - -func newAddon() *tax.AddonDef { - return &tax.AddonDef{ - Key: V1, - Name: i18n.String{ - i18n.EN: "France CTC Flow 6", - i18n.FR: "France CTC Flux 6", - }, - Description: i18n.String{ - i18n.EN: here.Doc(` - Support for the French CTC (Continuous Transaction Control) - Flow 6 lifecycle messages (Cycle de Vie) exchanged between - registered platforms (plateformes agréées) for B2B invoices. - - This addon operates on bill.Status documents. It carries the - code tables (ProcessConditionCode, ReasonCode, RequestedAction, - RoleCode) that the gobl.cii CDAR converter reads to round-trip - to and from the French PPF XML, and validates the subset of - (key, type) / reason / action / role combinations that Flow 6 - accepts. - - It does not depend on Flow 2: a platform may report lifecycle - events for any compliant invoice, whether or not the invoice - itself went through the Flow 2 B2B clearance path. - `), - }, - Sources: []*cbc.Source{ - { - Title: i18n.String{ - i18n.EN: "External Specifications", - i18n.FR: "Spécifications Externes", - }, - URL: "https://www.impots.gouv.fr/specifications-externes-b2b", - }, - }, - Extensions: extensions, - Normalizer: normalize, - } -} - -func normalize(doc any) { - switch obj := doc.(type) { - case *bill.Status: - normalizeStatus(obj) - case *bill.Reason: - normalizeReason(obj) - case *org.Party: - // Party-level normalization handles the case where a party is - // processed in isolation (e.g. through a direct tax.Normalize call); - // the status-level normalizer applies the contextual role defaults - // because those depend on which slot the party occupies. - _ = obj - } -} diff --git a/addons/fr/ctc/flow6/org_party.go b/addons/fr/ctc/flow6/org_party.go deleted file mode 100644 index 5bbd14403..000000000 --- a/addons/fr/ctc/flow6/org_party.go +++ /dev/null @@ -1,74 +0,0 @@ -package flow6 - -import ( - "slices" - - "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" -) - -// allowedRoleCodes is the UNCL 3035 subset that the fr-ctc-role -// extension accepts, kept in sync with the extension definition. -var allowedRoleCodes = []cbc.Code{ - RoleSE, RoleBY, RoleWK, RoleDFH, RoleAB, RoleSR, - RoleDL, RolePE, RolePR, RoleII, RoleIV, -} - -// allowedIdentitySchemes is the ICD 6523 subset CDAR accepts on the -// Flow 6 party identities — SIREN plus the commonly used foreign -// identifier schemes. Parties with identities outside this set should -// not be reported in a Flow 6 CDV. -var allowedIdentitySchemes = []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 -} - -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.Each( - rules.Field("ext", - rules.Assert("02", "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", - is.Func("scheme in allowed set", partyIdentitySchemeAllowed), - ), - ), - ), - ), - ) -} - -func partyRoleKnown(v any) bool { - ext := extValue(v) - role := ext.Get(ExtKeyRole) - if role == "" { - return true - } - return slices.Contains(allowedRoleCodes, role) -} - -func partyIdentitySchemeAllowed(v any) bool { - ext := extValue(v) - if ext.IsZero() { - return true - } - scheme := ext.Get(iso.ExtKeySchemeID).String() - if scheme == "" { - return true - } - return slices.Contains(allowedIdentitySchemes, scheme) -} diff --git a/addons/fr/ctc/flow6/org_party_test.go b/addons/fr/ctc/flow6/org_party_test.go deleted file mode 100644 index 6dff9473c..000000000 --- a/addons/fr/ctc/flow6/org_party_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package flow6 - -import ( - "testing" - - "github.com/invopop/gobl/catalogues/iso" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/rules" - "github.com/invopop/gobl/tax" - "github.com/stretchr/testify/assert" -) - -func TestPartyUnknownRoleRejected(t *testing.T) { - p := &org.Party{ - Name: "Agent", - Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{ExtKeyRole: RoleWK}), - } - assert.NoError(t, rules.Validate(p, addonContext())) -} - -func TestPartyUnknownIdentitySchemeRejected(t *testing.T) { - p := &org.Party{ - Name: "Agent", - Identities: []*org.Identity{{ - Code: "X", - Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "9999"}), - }}, - } - err := rules.Validate(p, addonContext()) - assert.ErrorContains(t, err, "ICD 6523") -} - -func TestPartyIdentityWithoutSchemeAccepted(t *testing.T) { - p := &org.Party{ - Name: "Agent", - Identities: []*org.Identity{{Code: "X"}}, - } - assert.NoError(t, rules.Validate(p, addonContext())) -} - -// --- Internal helpers --------------------------------------------------- - -func TestPartyIdentitySchemeAllowedEmptyScheme(t *testing.T) { - // An Ext without the scheme ID key falls through the scheme check. - e := tax.ExtensionsOf(tax.ExtMap{"some-other": "x"}) - assert.True(t, partyIdentitySchemeAllowed(e)) -} diff --git a/addons/fr/ctc/org.go b/addons/fr/ctc/org.go new file mode 100644 index 000000000..dd8eb0420 --- /dev/null +++ b/addons/fr/ctc/org.go @@ -0,0 +1,332 @@ +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 / identity scheme constants used across the addon. +const ( + // 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 = "0002" + // 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 cbc.Key = "private-id" + // 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 party identities — SIREN plus the commonly used foreign +// identifier schemes. Parties with identities outside this set should +// not be reported in a Flow 6 CDV. +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 + } + + // 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 (peppol key on SIREN inbox). + normalizeInboxes(party) +} + +// 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(tax.ExtMap{ + 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 + } + + var siret, siren *org.Identity + hasLegalScope := false + + for _, id := range party.Identities { + if id == nil { + continue + } + normalizeIdentity(id) + + if id.Type == fr.IdentityTypeSIRET { + siret = id + } + if id.Type == fr.IdentityTypeSIREN { + siren = id + } + if id.Scope == org.IdentityScopeLegal { + hasLegalScope = true + } + } + + // BR-FR-09/10: Generate SIREN from SIRET if needed. + if siret != nil && siren == nil { + siretCode := string(siret.Code) + if len(siretCode) == 14 { + sirenCode := siretCode[:9] + siren = &org.Identity{ + Type: fr.IdentityTypeSIREN, + Code: cbc.Code(sirenCode), + Ext: tax.ExtensionsOf(tax.ExtMap{ + iso.ExtKeySchemeID: identitySchemeIDSIREN, + }), + } + party.Identities = append(party.Identities, siren) + } + } + + // Set SIREN scope to legal if no other identity has legal scope. + if siren != nil && !hasLegalScope { + siren.Scope = org.IdentityScopeLegal + } +} + +// normalizeIdentity handles per-identity normalization (private-id key). +func normalizeIdentity(id *org.Identity) { + if id == nil { + return + } + if id.Key == identityKeyPrivateID { + id.Ext = id.Ext.Set(iso.ExtKeySchemeID, identitySchemeIDPrivate) + } + // Note: Type ↔ ISO scheme ID mapping for SIREN/SIRET is handled by + // the EN16931 addon. +} + +// 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 + } + + hasPeppol := false + var sirenInbox *org.Inbox + for _, inbox := range party.Inboxes { + if inbox == nil { + continue + } + if inbox.Key == org.InboxKeyPeppol { + hasPeppol = true + } + if inbox.Scheme == inboxSchemeSIREN { + sirenInbox = inbox + } + } + if !hasPeppol && sirenInbox != nil { + 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/flow2/org_party.go b/addons/fr/ctc/org_party.go similarity index 79% rename from addons/fr/ctc/flow2/org_party.go rename to addons/fr/ctc/org_party.go index 92b319357..7158a572b 100644 --- a/addons/fr/ctc/flow2/org_party.go +++ b/addons/fr/ctc/org_party.go @@ -1,8 +1,9 @@ -package flow2 +package ctc import ( "errors" "fmt" + "slices" "strings" "github.com/invopop/gobl/catalogues/iso" @@ -15,17 +16,29 @@ 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.Each( + rules.Field("ext", + rules.Assert("04", "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", + is.Func("scheme in Flow 6 allowed set", partyIdentitySchemeAllowed), + ), + ), + ), ), 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 +90,27 @@ 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 partyIdentitySchemeAllowed(v any) bool { + ext := extValue(v) + if ext.IsZero() { + return true + } + scheme := ext.Get(iso.ExtKeySchemeID).String() + if scheme == "" { + return true + } + return slices.Contains(allowedFlow6IdentitySchemes, scheme) +} + func identitiesSIRETSIRENCoherent(val any) bool { identities, ok := val.([]*org.Identity) if !ok || len(identities) == 0 { diff --git a/addons/fr/ctc/flow10/scenarios.go b/addons/fr/ctc/scenarios.go similarity index 81% rename from addons/fr/ctc/flow10/scenarios.go rename to addons/fr/ctc/scenarios.go index a71d8c163..beeda33b7 100644 --- a/addons/fr/ctc/flow10/scenarios.go +++ b/addons/fr/ctc/scenarios.go @@ -1,4 +1,4 @@ -package flow10 +package ctc import ( "github.com/invopop/gobl/bill" @@ -7,11 +7,13 @@ import ( "github.com/invopop/gobl/tax" ) -// Flow 10 accepts a curated subset of UNTDID 1001 document type codes. -// The scenarios below map GOBL invoice types (+ tag combinations) to the -// corresponding UNTDID code via untdid.ExtKeyDocumentType. The list is -// intentionally self-contained so Flow 10 can operate without requiring -// the en16931 addon. +// 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, @@ -144,11 +146,16 @@ var scenarios = []*tax.ScenarioSet{ }, } -// allowedDocumentTypes is the whitelist of UNTDID 1001 codes permitted on a -// Flow 10 invoice (B2B scope). Kept in sync with the scenarios above. -var allowedDocumentTypes = []cbc.Code{ +// 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/data/addons/fr-ctc-flow10-v1.json b/data/addons/fr-ctc-flow10-v1.json deleted file mode 100644 index f069730ae..000000000 --- a/data/addons/fr-ctc-flow10-v1.json +++ /dev/null @@ -1,362 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/tax/addon-def", - "key": "fr-ctc-flow10-v1", - "name": { - "en": "France CTC Flow 10", - "fr": "France CTC Flux 10" - }, - "description": { - "en": "Support for the French CTC (Continuous Transaction Control) Flow 10\ne-reporting requirements from the French electronic invoicing reform.\n\nFlow 10 covers transactions that must be reported to the tax authority\nbut are not subject to the domestic B2B clearance flow (Flow 2). This\nincludes B2C, cross-border, and out-of-scope transactions where VAT\ndata and payment data must still be transmitted to the PPF.", - "fr": "Support pour le CTC (Contrôle Continu des Transactions) français Flux 10\npour les exigences de e-reporting de la réforme française de la\nfacturation électronique.\n\nLe Flux 10 couvre les transactions qui doivent être déclarées à\nl'administration fiscale mais qui ne sont pas soumises au flux B2B\ndomestique (Flux 2). Cela inclut les transactions B2C, transfrontalières\net hors champ pour lesquelles les données de TVA et de paiement doivent\ntout de même être transmises au PPF." - }, - "sources": [ - { - "title": { - "en": "External Specifications", - "fr": "Spécifications Externes" - }, - "url": "https://www.impots.gouv.fr/specifications-externes-b2b" - } - ], - "extensions": [ - { - "key": "fr-ctc-b2c-category", - "name": { - "en": "B2C Transaction Category", - "fr": "Catégorie de transaction B2C" - }, - "desc": { - "en": "Classifies a B2C transaction for French Flow 10 reporting to the PPF\n(G1.68). Required on B2C invoices and B2C payments.\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 reporting Flux 10 au PPF (G1.68).\nObligatoire sur les factures et paiements B2C.\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-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": "b2c", - "name": { - "en": "B2C", - "fr": "B2C" - } - } - ] - }, - { - "schema": "bill/payment", - "list": [ - { - "key": "b2c", - "name": { - "en": "B2C", - "fr": "B2C" - } - } - ] - } - ], - "scenarios": [ - { - "schema": "bill/invoice", - "list": [ - { - "type": [ - "standard" - ], - "ext": { - "untdid-document-type": "380" - } - }, - { - "type": [ - "standard" - ], - "tags": [ - "self-billed" - ], - "ext": { - "untdid-document-type": "389" - } - }, - { - "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" - } - } - ] - } - ], - "corrections": null -} \ No newline at end of file diff --git a/data/addons/fr-ctc-flow2-v1.json b/data/addons/fr-ctc-flow2-v1.json deleted file mode 100644 index 71d016205..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.", - "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." - }, - "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-flow6-v1.json b/data/addons/fr-ctc-flow6-v1.json deleted file mode 100644 index d686c3b54..000000000 --- a/data/addons/fr-ctc-flow6-v1.json +++ /dev/null @@ -1,384 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/tax/addon-def", - "key": "fr-ctc-flow6-v1", - "name": { - "en": "France CTC Flow 6", - "fr": "France CTC Flux 6" - }, - "description": { - "en": "Support for the French CTC (Continuous Transaction Control)\nFlow 6 lifecycle messages (Cycle de Vie) exchanged between\nregistered platforms (plateformes agréées) for B2B invoices.\n\nThis addon operates on bill.Status documents. It carries the\ncode tables (ProcessConditionCode, ReasonCode, RequestedAction,\nRoleCode) that the gobl.cii CDAR converter reads to round-trip\nto and from the French PPF XML, and validates the subset of\n(key, type) / reason / action / role combinations that Flow 6\naccepts.\n\nIt does not depend on Flow 2: a platform may report lifecycle\nevents for any compliant invoice, whether or not the invoice\nitself went through the Flow 2 B2B clearance path." - }, - "sources": [ - { - "title": { - "en": "External Specifications", - "fr": "Spécifications Externes" - }, - "url": "https://www.impots.gouv.fr/specifications-externes-b2b" - } - ], - "extensions": [ - { - "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 for each\npopulated party on a Flow 6 lifecycle message. The normalizer\nfills the obvious defaults (Supplier → SE, Customer → BY)\nand leaves the rest for the caller to set explicitly." - }, - "values": [ - { - "code": "SE", - "name": { - "en": "Seller" - } - }, - { - "code": "BY", - "name": { - "en": "Buyer" - } - }, - { - "code": "WK", - "name": { - "en": "Work / Service Receiver" - } - }, - { - "code": "DFH", - "name": { - "en": "Delivery From" - } - }, - { - "code": "AB", - "name": { - "en": "Bank" - } - }, - { - "code": "SR", - "name": { - "en": "Sender" - } - }, - { - "code": "DL", - "name": { - "en": "Dealer" - } - }, - { - "code": "PE", - "name": { - "en": "Payee" - } - }, - { - "code": "PR", - "name": { - "en": "Payer" - } - }, - { - "code": "II", - "name": { - "en": "Issuer of Invoice" - } - }, - { - "code": "IV", - "name": { - "en": "Invoicee" - } - } - ] - }, - { - "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" - } - } - ] - } - ], - "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..335b61c41 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,671 @@ } } ] + }, + { + "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 for each\npopulated party on a Flow 6 lifecycle message. The normalizer\nfills the obvious defaults (Supplier → SE, Customer → BY)\nand leaves the rest for the caller to set explicitly." + }, + "values": [ + { + "code": "SE", + "name": { + "en": "Seller" + } + }, + { + "code": "BY", + "name": { + "en": "Buyer" + } + }, + { + "code": "WK", + "name": { + "en": "Work / Service Receiver" + } + }, + { + "code": "DFH", + "name": { + "en": "Delivery From" + } + }, + { + "code": "AB", + "name": { + "en": "Bank" + } + }, + { + "code": "SR", + "name": { + "en": "Sender / Issuer on behalf of" + } + }, + { + "code": "DL", + "name": { + "en": "Dealer" + } + }, + { + "code": "PE", + "name": { + "en": "Payee" + } + }, + { + "code": "PR", + "name": { + "en": "Payer" + } + }, + { + "code": "II", + "name": { + "en": "Issuer of Invoice" + } + }, + { + "code": "IV", + "name": { + "en": "Invoicee" + } + } + ] + }, + { + "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-flow10.json b/data/rules/fr-ctc-flow10.json deleted file mode 100644 index db0e867ff..000000000 --- a/data/rules/fr-ctc-flow10.json +++ /dev/null @@ -1,307 +0,0 @@ -{ - "id": "GOBL-FR-CTC-FLOW10", - "package": "fr-ctc-flow10", - "guard": "context: addon in [fr-ctc-flow10-v1]", - "subsets": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE", - "object": "bill.Invoice", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-10", - "desc": "invoice must be in EUR or provide an exchange rate to EUR", - "tests": "can convert to [EUR]" - } - ], - "subsets": [ - { - "guard": "B2C invoice", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-19", - "desc": "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates" - } - ], - "subsets": [ - { - "field": "tax", - "subsets": [ - { - "field": "ext", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-16", - "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-FLOW10-BILL-INVOICE-17", - "desc": "supplier is required on B2C invoice", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-18", - "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-FLOW10-BILL-INVOICE-13", - "desc": "supplier address must include country", - "tests": "present" - } - ] - } - ] - } - ] - } - ] - }, - { - "field": "customer", - "subsets": [ - { - "field": "addresses", - "subsets": [ - { - "each": true, - "subsets": [ - { - "field": "country", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-14", - "desc": "customer address must include country", - "tests": "present" - } - ] - } - ] - } - ] - } - ] - }, - { - "guard": "B2B invoice", - "subsets": [ - { - "field": "tax", - "subsets": [ - { - "field": "ext", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-09", - "desc": "invoice document type must be one of the Flow 10 permitted UNTDID 1001 codes (380, 389, 393, 501, 386, 500, 384, 471, 472, 473, 381, 261, 396, 502, 503)", - "tests": "allowed Flow 10 document type" - }, - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-11", - "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-FLOW10-BILL-INVOICE-12", - "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-FLOW10-BILL-INVOICE-01", - "desc": "supplier is required for Flow 10 B2B invoice (G2.19)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-02", - "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-FLOW10-BILL-INVOICE-03", - "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-FLOW10-BILL-INVOICE-07", - "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-FLOW10-BILL-INVOICE-15", - "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-FLOW10-BILL-INVOICE-04", - "desc": "customer is required for Flow 10 B2B invoice (G2.19)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW10-BILL-INVOICE-05", - "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-FLOW10-BILL-INVOICE-06", - "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-FLOW10-BILL-PAYMENT", - "object": "bill.Payment", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-07", - "desc": "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates" - } - ], - "subsets": [ - { - "field": "type", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-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-FLOW10-BILL-PAYMENT-02", - "desc": "payment value_date (settlement date) is required", - "tests": "present" - } - ] - }, - { - "field": "supplier", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-08", - "desc": "supplier is required", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-09", - "desc": "supplier must have a SIREN identity (ISO/IEC 6523 scheme 0002)", - "tests": "party has SIREN" - } - ] - }, - { - "guard": "B2B payment", - "subsets": [ - { - "field": "lines", - "subsets": [ - { - "each": true, - "subsets": [ - { - "field": "document", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-04", - "desc": "each payment line must reference a document (invoice) on B2B payments", - "tests": "present" - } - ], - "subsets": [ - { - "field": "code", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-05", - "desc": "payment line document code (invoice ID) is required on B2B payments", - "tests": "present" - } - ] - }, - { - "field": "issue_date", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW10-BILL-PAYMENT-06", - "desc": "payment line document issue_date (invoice issue date) is required on B2B payments", - "tests": "present" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] -} diff --git a/data/rules/fr-ctc-flow2.json b/data/rules/fr-ctc-flow2.json deleted file mode 100644 index 60395411f..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 reference the original invoice in preceding (BR-FR-CO-04)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-04", - "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-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 (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-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": "ordering.contracts is required for consolidated credit notes (BR-FR-CO-03)", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW2-BILL-INVOICE-25", - "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-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-flow6.json b/data/rules/fr-ctc-flow6.json deleted file mode 100644 index 88ea3c5fa..000000000 --- a/data/rules/fr-ctc-flow6.json +++ /dev/null @@ -1,206 +0,0 @@ -{ - "id": "GOBL-FR-CTC-FLOW6", - "package": "fr-ctc-flow6", - "guard": "context: addon in [fr-ctc-flow6-v1]", - "subsets": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS", - "object": "bill.Status", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-08", - "desc": "Status.Type must match the Type implied by each StatusLine.Key", - "tests": "status type consistent with line keys" - } - ], - "subsets": [ - { - "field": "type", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-01", - "desc": "status type must be one of: response, update", - "tests": "one of [response, update]" - } - ] - }, - { - "field": "supplier", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-02", - "desc": "supplier is required on Flow 6 status messages", - "tests": "present" - }, - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-03", - "desc": "supplier must have an identity with ISO/IEC 6523 scheme 0002 (SIREN)", - "tests": "supplier has SIREN" - } - ] - }, - { - "field": "lines", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-04", - "desc": "exactly one status line is required (CDAR carries a single status per CDV message)", - "tests": "exactly one line" - } - ], - "subsets": [ - { - "each": true, - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-06", - "desc": "status line key must be a recognised Flow 6 event", - "tests": "known Flow 6 status event" - }, - { - "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-BILL-STATUS-07", - "desc": "status line with key 'paid' (CDAR 212) must carry a Characteristic complement with Amount (value + currency) set — this is the MEN", - "tests": "amount received set when paid" - }, - { - "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-BILL-STATUS-10", - "desc": "Characteristic.TypeCode must be one of the MDT-207 values: MEN, MPA, RAP, ESC, RAB, REM, MAP, MAPTTC, MNA, MNATTC, CBB, DIV, DVA, MAJ", - "tests": "characteristic type code known" - } - ], - "subsets": [ - { - "field": "doc", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-05", - "desc": "status line must reference a document (BR-FR-CDV-10)", - "tests": "present" - } - ], - "subsets": [ - { - "field": "code", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-11", - "desc": "referenced invoice code is required (BR-FR-CDV-10)", - "tests": "present" - } - ] - }, - { - "field": "issue_date", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-BILL-STATUS-12", - "desc": "referenced invoice issue date is required (BR-FR-CDV-11)", - "tests": "present" - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "GOBL-FR-CTC-FLOW6-BILL-REASON", - "object": "bill.Reason", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-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-FLOW6-BILL-ACTION", - "object": "bill.Action", - "subsets": [ - { - "field": "key", - "subsets": [ - { - "guard": "present", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-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-FLOW6-ORG-PARTY", - "object": "org.Party", - "subsets": [ - { - "field": "ext", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-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", - "subsets": [ - { - "each": true, - "subsets": [ - { - "field": "ext", - "assert": [ - { - "id": "GOBL-FR-CTC-FLOW6-ORG-PARTY-02", - "desc": "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", - "tests": "scheme in allowed set" - } - ] - } - ] - } - ] - } - ] - } - ] -} diff --git a/data/rules/fr-ctc.json b/data/rules/fr-ctc.json new file mode 100644 index 000000000..7296af9ee --- /dev/null +++ b/data/rules/fr-ctc.json @@ -0,0 +1,1113 @@ +{ + "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 eu-en16931-v2017 addon" + }, + { + "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 rate must be one of the Flow 10 permitted percentages (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 rates" + } + ], + "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 rate must be one of the Flow 10 permitted percentages (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 rates" + } + ], + "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" + } + ], + "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" + } + ], + "subsets": [ + { + "each": true, + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-FR-CTC-ORG-PARTY-04", + "desc": "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", + "tests": "scheme in Flow 6 allowed set" + } + ] + } + ] + } + ] + }, + { + "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/flow6/characteristic.json b/data/schemas/addons/fr/ctc/characteristic.json similarity index 86% rename from data/schemas/addons/fr/ctc/flow6/characteristic.json rename to data/schemas/addons/fr/ctc/characteristic.json index e14af23e7..be3089e4f 100644 --- a/data/schemas/addons/fr/ctc/flow6/characteristic.json +++ b/data/schemas/addons/fr/ctc/characteristic.json @@ -1,24 +1,24 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://gobl.org/draft-0/addons/fr/ctc/flow6/characteristic", - "$ref": "#/$defs/flow6.Characteristic", + "$id": "https://gobl.org/draft-0/addons/fr/ctc/characteristic", + "$ref": "#/$defs/ctc.Characteristic", "$defs": { - "flow6.Characteristic": { + "ctc.Characteristic": { "properties": { "id": { "type": "string", "title": "ID", - "description": "ID optionally identifies the characteristic. Used by CDAR to\ncorrelate a correction with a previously reported field." + "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. See the TypeCode*\nconstants for reserved values Flow 6 interprets directly." + "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. Only meaningful on\nrejection / dispute / partial-acceptance lines." + "description": "ReasonCode links this characteristic to a sibling bill.Reason\nvia its fr-ctc-reason-code extension value." }, "description": { "type": "string", @@ -33,7 +33,7 @@ "direction": { "$ref": "https://gobl.org/draft-0/cbc/code", "title": "Direction", - "description": "Direction carries the CDAR AdjustmentDirectionCode — typically\n\"+\" or \"-\" when Changed is true." + "description": "Direction carries the CDAR AdjustmentDirectionCode." }, "name": { "type": "string", @@ -63,7 +63,7 @@ "amount": { "$ref": "https://gobl.org/draft-0/currency/amount", "title": "Amount", - "description": "Amount holds a monetary value paired with its currency. Used\nfor the MEN on paid lines and for any price/total correction." + "description": "Amount holds a monetary value paired with its currency." }, "numeric": { "$ref": "https://gobl.org/draft-0/num/amount", diff --git a/data/schemas/tax/addon-list.json b/data/schemas/tax/addon-list.json index 42c0b1a7b..bb3b414f4 100644 --- a/data/schemas/tax/addon-list.json +++ b/data/schemas/tax/addon-list.json @@ -56,16 +56,8 @@ "title": "Chorus Pro" }, { - "const": "fr-ctc-flow10-v1", - "title": "France CTC Flow 10" - }, - { - "const": "fr-ctc-flow2-v1", - "title": "France CTC Flow 2" - }, - { - "const": "fr-ctc-flow6-v1", - "title": "France CTC Flow 6" + "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 4f3fe862b..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: @@ -77,9 +80,6 @@ notes: text: "No discount offered for early payment." ext: untdid-text-subject: "AAB" - - text: "B2BINT" - ext: - untdid-text-subject: "BAR" payment: instructions: 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-b2b.yaml b/examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml deleted file mode 100644 index 4d6f7497f..000000000 --- a/examples/fr/invoice-fr-fr-ctc-flow10-b2b.yaml +++ /dev/null @@ -1,52 +0,0 @@ -$schema: "https://gobl.org/draft-0/bill/invoice" -$addons: - - "fr-ctc-flow10-v1" -uuid: "5d3aebcf-8b7e-4f5a-9b7e-0d4c83f9e1a1" -currency: "EUR" -issue_date: "2026-04-15" -series: "FAC" -code: "2026-00042" - -supplier: - tax_id: - country: "FR" - code: "732829320" - identities: - - type: "SIREN" - code: "732829320" - ext: - iso-scheme-id: "0002" - name: "Fournisseur Reporting SARL" - emails: - - addr: "facturation@fournisseur.fr" - addresses: - - street: "12 Rue de la Reforme" - locality: "Paris" - code: "75001" - country: "FR" - -customer: - tax_id: - country: "FR" - code: "356000000" - identities: - - type: "SIREN" - code: "356000000" - ext: - iso-scheme-id: "0002" - name: "Client Reporting SAS" - addresses: - - street: "8 Avenue de l'Opera" - locality: "Paris" - code: "75001" - country: "FR" - -lines: - - quantity: 5 - item: - name: "Prestation de conseil" - price: "200.00" - unit: "h" - taxes: - - cat: VAT - rate: standard diff --git a/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml b/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml index 17bd90300..6e796ed9d 100644 --- a/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml +++ b/examples/fr/invoice-fr-fr-ctc-flow10-b2c.yaml @@ -1,6 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/invoice" $addons: - - "fr-ctc-flow10-v1" + - "fr-ctc-v1" uuid: "8c2b4a1d-d7e2-4d6e-9c1f-3a8b9d4f2e10" currency: "EUR" issue_date: "2026-04-15" @@ -35,3 +35,11 @@ lines: 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 cf8ad87e0..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": "9c3cf2a4f68045fb039c62df2498ed0eaf1cf02b5860fb4b339a49a36a8d6372" + "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" @@ -179,12 +189,6 @@ "ext": { "untdid-text-subject": "AAB" } - }, - { - "text": "B2BINT", - "ext": { - "untdid-text-subject": "BAR" - } } ] } 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-b2b.json b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2b.json deleted file mode 100644 index 91b57a2d4..000000000 --- a/examples/fr/out/invoice-fr-fr-ctc-flow10-b2b.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "fbe1f4fb4308c1b02d5f559246131f0a9658a7cd96584c45d26f5299fe28c5ba" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "FR", - "$addons": [ - "fr-ctc-flow10-v1" - ], - "uuid": "5d3aebcf-8b7e-4f5a-9b7e-0d4c83f9e1a1", - "type": "standard", - "series": "FAC", - "code": "2026-00042", - "issue_date": "2026-04-15", - "currency": "EUR", - "tax": { - "ext": { - "fr-ctc-billing-mode": "M1", - "untdid-document-type": "380" - } - }, - "supplier": { - "name": "Fournisseur Reporting SARL", - "tax_id": { - "country": "FR", - "code": "44732829320" - }, - "identities": [ - { - "type": "SIREN", - "code": "732829320", - "ext": { - "iso-scheme-id": "0002" - } - } - ], - "addresses": [ - { - "street": "12 Rue de la Reforme", - "locality": "Paris", - "code": "75001", - "country": "FR" - } - ], - "emails": [ - { - "addr": "facturation@fournisseur.fr" - } - ] - }, - "customer": { - "name": "Client Reporting SAS", - "tax_id": { - "country": "FR", - "code": "39356000000" - }, - "identities": [ - { - "type": "SIREN", - "code": "356000000", - "ext": { - "iso-scheme-id": "0002" - } - } - ], - "addresses": [ - { - "street": "8 Avenue de l'Opera", - "locality": "Paris", - "code": "75001", - "country": "FR" - } - ] - }, - "lines": [ - { - "i": 1, - "quantity": "5", - "item": { - "name": "Prestation de conseil", - "price": "200.00", - "unit": "h" - }, - "sum": "1000.00", - "taxes": [ - { - "cat": "VAT", - "key": "standard", - "rate": "general", - "percent": "20%" - } - ], - "total": "1000.00" - } - ], - "totals": { - "sum": "1000.00", - "total": "1000.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "1000.00", - "percent": "20%", - "amount": "200.00" - } - ], - "amount": "200.00" - } - ], - "sum": "200.00" - }, - "tax": "200.00", - "total_with_tax": "1200.00", - "payable": "1200.00" - } - } -} \ No newline at end of file diff --git a/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json index 45e0695ed..052fbda10 100644 --- a/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json +++ b/examples/fr/out/invoice-fr-fr-ctc-flow10-b2c.json @@ -4,14 +4,14 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "0fb627224d6cf49dc1cb7b5c06bf0ec0fdb20c5f6a11f0e5842d76599f5897dd" + "val": "f6d97cf5dff94cd50a9d59808e4634a2e8450e5e4d2ab434a3f751d93b34f66e" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "FR", "$addons": [ - "fr-ctc-flow10-v1" + "fr-ctc-v1" ], "uuid": "8c2b4a1d-d7e2-4d6e-9c1f-3a8b9d4f2e10", "type": "standard", @@ -33,6 +33,7 @@ }, "identities": [ { + "scope": "legal", "type": "SIREN", "code": "732829320", "ext": { @@ -69,6 +70,17 @@ "total": "59.00" } ], + "payment": { + "terms": { + "notes": "Paiement comptant" + }, + "instructions": { + "key": "credit-transfer", + "ext": { + "untdid-payment-means": "30" + } + } + }, "totals": { "sum": "59.00", "total": "59.00", diff --git a/examples/fr/out/payment-fr-fr-ctc-flow10-b2b.json b/examples/fr/out/payment-fr-de-ctc-b2bint.json similarity index 82% rename from examples/fr/out/payment-fr-fr-ctc-flow10-b2b.json rename to examples/fr/out/payment-fr-de-ctc-b2bint.json index 09bae145e..82c58dad4 100644 --- a/examples/fr/out/payment-fr-fr-ctc-flow10-b2b.json +++ b/examples/fr/out/payment-fr-de-ctc-b2bint.json @@ -4,14 +4,14 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "eab4d3ed16ca4ea595ca43f716eec2d7fff6e72cf7c3b675c1bb3ecd2791b251" + "val": "b73d2745b02510c4af2430013c7e9107d9212d284e10934fe07ad667f19abdd7" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/payment", "$regime": "FR", "$addons": [ - "fr-ctc-flow10-v1" + "fr-ctc-v1" ], "uuid": "1f4d3e8a-9b6c-4d2e-8e7f-7a3c4d5e6f01", "type": "receipt", @@ -37,6 +37,7 @@ }, "identities": [ { + "scope": "legal", "type": "SIREN", "code": "732829320", "ext": { @@ -46,17 +47,17 @@ ] }, "customer": { - "name": "Client Reporting SAS", + "name": "Kunde Deutschland GmbH", "tax_id": { - "country": "FR", - "code": "39356000000" + "country": "DE", + "code": "111111125" }, "identities": [ { - "type": "SIREN", - "code": "356000000", + "scope": "legal", + "code": "DE111111125", "ext": { - "iso-scheme-id": "0002" + "iso-scheme-id": "0223" } } ] @@ -66,7 +67,7 @@ "i": 1, "document": { "issue_date": "2026-04-15", - "code": "2026-00042" + "code": "2026-INT-001" }, "amount": "1200.00", "tax": { diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json index 5b3ec3323..1b334ed81 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-accepted.json @@ -4,13 +4,13 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "6a106b9fc25e7a10e3e0e048753e1161c171c6a4c69848a58ed85fb43ff0b7e7" + "val": "50202b9d3b460812e4e10a4eaa3d502d931f8887de1108f8e44db0630f9c097c" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/status", "$addons": [ - "fr-ctc-flow6-v1" + "fr-ctc-v1" ], "uuid": "2a5b6c7d-8e9f-4a1b-2c3d-4e5f6a7b8c01", "type": "response", @@ -22,6 +22,7 @@ "supplier": { "identities": [ { + "scope": "legal", "type": "SIREN", "code": "732829320", "ext": { @@ -34,6 +35,7 @@ "name": "ACHETEUR SARL", "identities": [ { + "scope": "legal", "type": "SIREN", "code": "200000008", "ext": { @@ -43,6 +45,7 @@ ], "inboxes": [ { + "key": "peppol", "scheme": "0225", "code": "200000008_PEP" } @@ -55,6 +58,7 @@ "name": "VENDEUR SARL", "identities": [ { + "scope": "legal", "type": "SIREN", "code": "732829320", "ext": { @@ -64,6 +68,7 @@ ], "inboxes": [ { + "key": "peppol", "scheme": "0225", "code": "732829320_PEP" } diff --git a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json index 8b17a0009..d6cdbda7b 100644 --- a/examples/fr/out/status-fr-fr-ctc-flow6-paid.json +++ b/examples/fr/out/status-fr-fr-ctc-flow6-paid.json @@ -4,13 +4,13 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "f0f6363de7f3234d2e5f0f47b8659eddaa3531c276f837bdc423440068f3edc9" + "val": "926e915228135293354bb22ccb3fdf00fdd7483158275a7952c9c727253b0325" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/status", "$addons": [ - "fr-ctc-flow6-v1" + "fr-ctc-v1" ], "uuid": "3b6c7d8e-9f0a-4b2c-3d4e-5f6a7b8c9d01", "type": "response", @@ -23,6 +23,7 @@ "name": "VENDEUR SARL", "identities": [ { + "scope": "legal", "type": "SIREN", "code": "732829320", "ext": { @@ -35,6 +36,7 @@ "name": "VENDEUR SARL", "identities": [ { + "scope": "legal", "type": "SIREN", "code": "732829320", "ext": { @@ -44,6 +46,7 @@ ], "inboxes": [ { + "key": "peppol", "scheme": "0225", "code": "732829320_PEP" } @@ -56,6 +59,7 @@ "name": "ACHETEUR SARL", "identities": [ { + "scope": "legal", "type": "SIREN", "code": "200000008", "ext": { @@ -65,6 +69,7 @@ ], "inboxes": [ { + "key": "peppol", "scheme": "0225", "code": "200000008_PEP" } @@ -84,7 +89,7 @@ }, "complements": [ { - "$schema": "https://gobl.org/draft-0/addons/fr/ctc/flow6/characteristic", + "$schema": "https://gobl.org/draft-0/addons/fr/ctc/characteristic", "type_code": "MEN", "amount": { "currency": "EUR", diff --git a/examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml b/examples/fr/payment-fr-de-ctc-b2bint.yaml similarity index 78% rename from examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml rename to examples/fr/payment-fr-de-ctc-b2bint.yaml index 9b9f33385..8baefff96 100644 --- a/examples/fr/payment-fr-fr-ctc-flow10-b2b.yaml +++ b/examples/fr/payment-fr-de-ctc-b2bint.yaml @@ -1,6 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/payment" $addons: - - "fr-ctc-flow10-v1" + - "fr-ctc-v1" uuid: "1f4d3e8a-9b6c-4d2e-8e7f-7a3c4d5e6f01" type: "receipt" currency: "EUR" @@ -28,18 +28,13 @@ supplier: customer: tax_id: - country: "FR" - code: "356000000" - identities: - - type: "SIREN" - code: "356000000" - ext: - iso-scheme-id: "0002" - name: "Client Reporting SAS" + country: "DE" + code: "111111125" + name: "Kunde Deutschland GmbH" lines: - document: - code: "2026-00042" + code: "2026-INT-001" issue_date: "2026-04-15" amount: "1200.00" tax: diff --git a/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml index bb7db3df6..f541c1ff7 100644 --- a/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml +++ b/examples/fr/status-fr-fr-ctc-flow6-accepted.yaml @@ -1,6 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/status" $addons: - - "fr-ctc-flow6-v1" + - "fr-ctc-v1" uuid: "2a5b6c7d-8e9f-4a1b-2c3d-4e5f6a7b8c01" issue_date: "2026-04-16" code: "STA-2026-0001" diff --git a/examples/fr/status-fr-fr-ctc-flow6-paid.yaml b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml index 87f8008e1..d4793c70e 100644 --- a/examples/fr/status-fr-fr-ctc-flow6-paid.yaml +++ b/examples/fr/status-fr-fr-ctc-flow6-paid.yaml @@ -1,6 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/status" $addons: - - "fr-ctc-flow6-v1" + - "fr-ctc-v1" uuid: "3b6c7d8e-9f0a-4b2c-3d4e-5f6a7b8c9d01" issue_date: "2026-05-02" code: "STA-2026-0002" @@ -49,7 +49,7 @@ lines: code: "2026-00042" issue_date: "2026-04-15" complements: - - $schema: "https://gobl.org/draft-0/addons/fr/ctc/flow6/characteristic" + - $schema: "https://gobl.org/draft-0/addons/fr/ctc/characteristic" type_code: "MEN" amount: currency: "EUR" diff --git a/internal/ops/bulk_test.go b/internal/ops/bulk_test.go index bce05e4d7..171870195 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/addons/fr/ctc/flow6/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/advance", "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/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/advance", "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/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, From abdbbf2d7d5f647a372a521244594273632a90b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 15:27:40 +0000 Subject: [PATCH 20/26] fr-ctc: restore test coverage for codes / extensions / org / payment / status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the deleted flow2/flow6/flow10 test files into the consolidated addons/fr/ctc package: - codes_test.go: CDAR ProcessConditionCode / ActionCode / ReasonCode round-trip + lookup misses - extensions_test.go: extValue defensive branches - org_test.go: SIREN-inbox validation, peppol-key normalisation, identity scheme format (BR-FR-CO-10), private-id (0224) normalisation, SIREN-from-SIRET derivation, party / inbox / item meta edge cases (flow2 + flow10 + flow6 merged) - bill_payment_test.go: payment receipt rules — VAT rate whitelist, supplier SIREN, per-line invoice refs for B2B - bill_status_test.go: Flow 6 lifecycle rules — exactly-one-line, SIREN propagation, MEN amount on paid response, reason-code link, MDT-207 type codes - helpers_test.go: shared addonContext / runNormalize / party builders Coverage rises from 0% to ~57%. bill_invoice tests still to port. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/bill_payment_test.go | 161 +++++++ addons/fr/ctc/bill_status_test.go | 532 +++++++++++++++++++++ addons/fr/ctc/codes_test.go | 361 ++++++++++++++ addons/fr/ctc/extensions_test.go | 26 + addons/fr/ctc/helpers_test.go | 97 ++++ addons/fr/ctc/org_test.go | 731 +++++++++++++++++++++++++++++ 6 files changed, 1908 insertions(+) create mode 100644 addons/fr/ctc/bill_payment_test.go create mode 100644 addons/fr/ctc/bill_status_test.go create mode 100644 addons/fr/ctc/codes_test.go create mode 100644 addons/fr/ctc/extensions_test.go create mode 100644 addons/fr/ctc/helpers_test.go create mode 100644 addons/fr/ctc/org_test.go diff --git a/addons/fr/ctc/bill_payment_test.go b/addons/fr/ctc/bill_payment_test.go new file mode 100644 index 000000000..d088da212 --- /dev/null +++ b/addons/fr/ctc/bill_payment_test.go @@ -0,0 +1,161 @@ +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, + Method: &pay.Instructions{Key: pay.MeansKeyCreditTransfer}, + 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, + Method: &pay.Instructions{Key: pay.MeansKeyCreditTransfer}, + 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, paymentVATRatesAllowed("x")) +} + +func TestPaymentVATRatesAllowedNilLine(t *testing.T) { + p := &bill.Payment{Lines: []*bill.PaymentLine{nil}} + assert.True(t, paymentVATRatesAllowed(p)) +} diff --git a/addons/fr/ctc/bill_status_test.go b/addons/fr/ctc/bill_status_test.go new file mode 100644 index 000000000..4c80d498e --- /dev/null +++ b/addons/fr/ctc/bill_status_test.go @@ -0,0 +1,532 @@ +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(tax.ExtMap{ + 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 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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{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")) +} diff --git a/addons/fr/ctc/codes_test.go b/addons/fr/ctc/codes_test.go new file mode 100644 index 000000000..657dc2d05 --- /dev/null +++ b/addons/fr/ctc/codes_test.go @@ -0,0 +1,361 @@ +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) +} diff --git a/addons/fr/ctc/extensions_test.go b/addons/fr/ctc/extensions_test.go new file mode 100644 index 000000000..d265ed16d --- /dev/null +++ b/addons/fr/ctc/extensions_test.go @@ -0,0 +1,26 @@ +package ctc + +import ( + "testing" + + "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(tax.ExtMap{"k": "v"}) + assert.False(t, extValue(e).IsZero()) +} + +func TestExtValueFromPointer(t *testing.T) { + e := tax.ExtensionsOf(tax.ExtMap{"k": "v"}) + assert.False(t, extValue(&e).IsZero()) +} diff --git a/addons/fr/ctc/helpers_test.go b/addons/fr/ctc/helpers_test.go new file mode 100644 index 000000000..f49163677 --- /dev/null +++ b/addons/fr/ctc/helpers_test.go @@ -0,0 +1,97 @@ +package ctc + +import ( + "testing" + + "github.com/invopop/gobl/catalogues/iso" + "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(tax.ExtMap{ + 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(tax.ExtMap{ + 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(tax.ExtMap{ + iso.ExtKeySchemeID: identitySchemeIDEUVAT, + }), + }, + }, + Addresses: []*org.Address{{Country: "DE"}}, + } +} diff --git a/addons/fr/ctc/org_test.go b/addons/fr/ctc/org_test.go new file mode 100644 index 000000000..ad68bb9d1 --- /dev/null +++ b/addons/fr/ctc/org_test.go @@ -0,0 +1,731 @@ +package ctc + +import ( + "strings" + "testing" + + "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" + "github.com/invopop/gobl/tax" + "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"}, + Inboxes: []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: "732829320"}, + }, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + t.Run("valid SIREN inbox matching SIREN identity", func(t *testing.T) { + party := &org.Party{ + Identities: []*org.Identity{ + { + Type: fr.IdentityTypeSIREN, + Code: "123456789", + Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"}), + }, + }, + Inboxes: []*org.Inbox{{Scheme: cbc.Code("0225"), Code: "123456789"}}, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + 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"}, + Inboxes: []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: "732829320+routing"}, + }, + } + err := rules.Validate(party, withAddonContext()) + 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"}, + Inboxes: []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: "999999999"}, + }, + } + 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"}, + }, + } + err := rules.Validate(party, withAddonContext()) + assert.ErrorContains(t, err, "must be in a valid format") + }) + + t.Run("SIREN inbox without party context is valid", func(t *testing.T) { + party := &org.Party{ + Inboxes: []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: "123456789"}, + }, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + t.Run("SIREN inbox with allowed separators", func(t *testing.T) { + party := &org.Party{ + Inboxes: []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: "123456789-test"}, + }, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + t.Run("SIREN inbox at cbc.Code max length (64 characters)", func(t *testing.T) { + longCode := strings.Repeat("1", 64) + party := &org.Party{ + Inboxes: []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: cbc.Code(longCode)}, + }, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + t.Run("SIREN inbox exceeds cbc.Code max length (65 characters)", func(t *testing.T) { + tooLong := strings.Repeat("1", 65) + party := &org.Party{ + Inboxes: []*org.Inbox{ + {Scheme: cbc.Code("0225"), Code: cbc.Code(tooLong)}, + }, + } + err := rules.Validate(party, withAddonContext()) + assert.ErrorContains(t, err, "no longer than 64") + }) +} + +// --- Peppol key normalisation (normalizeInboxes) ------------------------ + +func TestPeppolKeyNormalization(t *testing.T) { + 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"}, + Identities: []*org.Identity{ + {Type: fr.IdentityTypeSIREN, Code: "732829320"}, + }, + Inboxes: []*org.Inbox{{Scheme: cbc.Code("0225"), Code: "732829320"}}, + } + ad.Normalizer(party) + assert.Equal(t, org.InboxKeyPeppol, party.Inboxes[0].Key) + }) + + 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"}, + Identities: []*org.Identity{ + {Type: fr.IdentityTypeSIREN, Code: "732829320"}, + }, + Inboxes: []*org.Inbox{ + {Key: org.InboxKeyPeppol, Scheme: "0088", Code: "1234567890123"}, + {Scheme: cbc.Code("0225"), Code: "732829320"}, + }, + } + ad.Normalizer(party) + 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"}}, + } + ad.Normalizer(party) + 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"}, + Identities: []*org.Identity{ + {Type: fr.IdentityTypeSIREN, Code: "732829320"}, + }, + Inboxes: []*org.Inbox{{Scheme: "0088", Code: "1234567890123"}}, + } + ad.Normalizer(party) + assert.Equal(t, cbc.Key(""), party.Inboxes[0].Key) + }) +} + +// --- Identity scheme format (BR-FR-CO-10) ------------------------------- + +func TestIdentitySchemeFormatValidation(t *testing.T) { + t.Run("valid 0224 alphanumeric", func(t *testing.T) { + party := &org.Party{ + Identities: []*org.Identity{{ + Code: "ABC123XYZ", + Ext: tax.ExtensionsOf(tax.ExtMap{"iso-scheme-id": "0224"}), + }}, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + 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(tax.ExtMap{"iso-scheme-id": "0224"}), + }}, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + t.Run("invalid 0224 special chars rejected", func(t *testing.T) { + party := &org.Party{ + Identities: []*org.Identity{{ + Code: "ABC123@invalid", + Ext: tax.ExtensionsOf(tax.ExtMap{"iso-scheme-id": "0224"}), + }}, + } + err := rules.Validate(party, withAddonContext()) + assert.ErrorContains(t, err, "must be in a valid format") + }) + + t.Run("scheme 0002 not subject to 0224 alphanumeric rules", func(t *testing.T) { + party := &org.Party{ + Identities: []*org.Identity{{ + Code: "ABC123", + Ext: tax.ExtensionsOf(tax.ExtMap{"iso-scheme-id": "0002"}), + }}, + } + 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", + }}, + } + err := rules.Validate(party, withAddonContext()) + assert.ErrorContains(t, err, "BR-FR-CO-10") + }) + + 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(tax.ExtMap{iso.ExtKeySchemeID: "0224"}), + }}, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + 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(tooLong), + Ext: tax.ExtensionsOf(tax.ExtMap{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(V1) + + 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"}, + }, + } + ad.Normalizer(party) + assert.Equal(t, "0224", party.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) + }) + + 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(tax.ExtMap{"other-key": "other-value"}), + }}, + } + ad.Normalizer(party) + 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("non-private-id identity not modified", func(t *testing.T) { + party := &org.Party{ + Identities: []*org.Identity{ + {Type: fr.IdentityTypeSIREN, Code: "123456789"}, + }, + } + ad.Normalizer(party) + assert.True(t, party.Identities[0].Ext.IsZero()) + }) + + 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(tax.ExtMap{iso.ExtKeySchemeID: "9999"}), + }}, + } + ad.Normalizer(party) + assert.Equal(t, "0224", party.Identities[0].Ext.Get(iso.ExtKeySchemeID).String()) + }) +} + +// --- SIREN-from-SIRET normalization ------------------------------------- + +func TestSIRENGenerationFromSIRET(t *testing.T) { + ad := tax.AddonForKey(V1) + + t.Run("SIREN generated from SIRET", func(t *testing.T) { + party := &org.Party{ + Identities: []*org.Identity{ + {Type: fr.IdentityTypeSIRET, Code: "12345678901234"}, + }, + } + ad.Normalizer(party) + assert.Len(t, party.Identities, 2) + var siren *org.Identity + for _, id := range party.Identities { + if id.Type == fr.IdentityTypeSIREN { + siren = id + } + } + 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"}, + }, + } + ad.Normalizer(party) + var siren *org.Identity + for _, id := range party.Identities { + if id.Type == fr.IdentityTypeSIREN { + siren = id + } + } + assert.NotNil(t, siren) + assert.Equal(t, org.IdentityScopeLegal, siren.Scope) + }) + + 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"}, + }, + } + ad.Normalizer(party) + assert.Len(t, party.Identities, 2) + }) +} + +// --- 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("0224 code over 100 chars rejected", func(t *testing.T) { + id := &org.Identity{ + Code: cbc.Code(strings.Repeat("A", 101)), + Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0224"}), + } + err := rules.Validate(id, withAddonContext()) + assert.ErrorContains(t, err, "must be no more than 100") + }) + + t.Run("0224 valid code", func(t *testing.T) { + id := &org.Identity{ + Code: "VALID-CODE_123", + Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0224"}), + } + assert.NoError(t, rules.Validate(id, withAddonContext())) + }) +} + +func TestValidatePartyEdgeCases(t *testing.T) { + t.Run("nil party returns nil", func(t *testing.T) { + err := rules.Validate((*org.Party)(nil), withAddonContext()) + assert.NoError(t, err) + }) + + 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(tax.ExtMap{iso.ExtKeySchemeID: "0009"}), + }, + { + Type: fr.IdentityTypeSIREN, + Code: "999999999", + Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"}), + }, + }, + } + err := rules.Validate(party, withAddonContext()) + assert.ErrorContains(t, err, "BR-FR-09/10") + }) + + 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))}, + }, + } + err := rules.Validate(party, withAddonContext()) + assert.Error(t, err) + }) +} + +func TestNormalizePartyEdgeCases(t *testing.T) { + ad := tax.AddonForKey(V1) + + t.Run("nil party is safe", func(_ *testing.T) { + ad.Normalizer((*org.Party)(nil)) + }) + + 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("party with nil identity in array", func(t *testing.T) { + var nilID *org.Identity + party := &org.Party{ + Name: "Test Party", + Identities: []*org.Identity{ + nilID, + {Type: fr.IdentityTypeSIRET, Code: "12345678901234"}, + }, + } + ad.Normalizer(party) + assert.Len(t, party.Identities, 3) + nonNilCount := 0 + var hasSIREN, hasSIRET bool + for _, id := range party.Identities { + if id != nil { + nonNilCount++ + if id.Type == fr.IdentityTypeSIREN { + hasSIREN = true + } + if id.Type == fr.IdentityTypeSIRET { + hasSIRET = true + } + } + } + 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) { + var nilInbox *org.Inbox + party := &org.Party{ + Name: "Test Party", + Inboxes: []*org.Inbox{ + nilInbox, + {Scheme: "0225", Code: "123456789-test"}, + nilInbox, + }, + } + ad.Normalizer(party) + assert.Len(t, party.Inboxes, 3) + var hasPeppol bool + for _, inbox := range party.Inboxes { + if inbox != nil && inbox.Key == org.InboxKeyPeppol { + hasPeppol = true + } + } + 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{}} + 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"}}, + } + err := rules.Validate(party, withAddonContext()) + assert.ErrorContains(t, err, "BR-FR-CO-10") + }) + + t.Run("duplicate ISO scheme IDs return error", func(t *testing.T) { + party := &org.Party{ + Name: "Test Party", + Identities: []*org.Identity{ + {Code: "123", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"})}, + {Code: "456", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"})}, + }, + } + err := rules.Validate(party, withAddonContext()) + assert.ErrorContains(t, err, "BR-FR-CO-10") + }) + + t.Run("nil identity in array is skipped", func(t *testing.T) { + var nilID *org.Identity + party := &org.Party{ + Name: "Test Party", + Identities: []*org.Identity{ + nilID, + {Code: "123", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"})}, + }, + } + assert.NoError(t, rules.Validate(party, withAddonContext())) + }) + + 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: "", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0224"})}, + {Code: "valid-id", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "0002"})}, + }, + } + err := rules.Validate(party, withAddonContext()) + assert.Error(t, err) + }) +} + +func TestValidateInboxEdgeCases(t *testing.T) { + t.Run("nil inbox returns nil", func(t *testing.T) { + err := rules.Validate((*org.Inbox)(nil), withAddonContext()) + assert.NoError(t, err) + }) + + 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"}, + } + assert.NoError(t, rules.Validate(item, withAddonContext())) + }) + + 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(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(tax.ExtMap{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(tax.ExtMap{iso.ExtKeySchemeID: "0227"})}, + {Code: "B", Scope: org.IdentityScopeLegal, Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{iso.ExtKeySchemeID: "9999"})}, + {Code: "B", Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{ExtKeyRole: RoleWK}), + } + assert.NoError(t, rules.Validate(p, addonContext())) +} + +func TestPartyUnknownIdentitySchemeRejected(t *testing.T) { + p := &org.Party{ + Name: "Agent", + Identities: []*org.Identity{{ + Code: "X", + Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "9999"}), + }}, + } + err := rules.Validate(p, addonContext()) + assert.ErrorContains(t, err, "ICD 6523") +} + +func TestPartyIdentitySchemeAllowedEmptyScheme(t *testing.T) { + e := tax.ExtensionsOf(tax.ExtMap{"some-other": "x"}) + assert.True(t, partyIdentitySchemeAllowed(e)) +} + +func TestPartyRoleKnownEmptyExtPasses(t *testing.T) { + assert.True(t, partyRoleKnown(tax.Extensions{})) +} From 1ef0d7c6fa7b985c95c154c065c6bdea2b25914d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 15:49:31 +0000 Subject: [PATCH 21/26] fr-ctc: port bill_invoice tests to consolidated addon Combines the deleted flow2/bill_invoice_test.go and flow10/bill_invoice_test.go into a single test file against the merged addon. Adaptations: - declare eu-en16931-v2017 alongside fr-ctc-v1 on Flow 2 fixtures (the addon no longer hard-Requires it; rule 02 enforces it softly) - carry iso-scheme-id ext on every identity so BR-FR-CO-10 accepts them - swap renamed helpers (extensionsValue -> extValue, invoiceIsB2BAny -> invoiceIsCrossBorderB2BAny, normalizeInvoiceBillingMode -> normalizeBillingMode) - drop BAR-note dispatcher tests (the merged code routes on party residency, not on the BAR note); Flow 10 cross-border cases now use deCustomerWithVATID() - match on rule messages instead of the renumbered fault codes Coverage rises from ~57% to 92% on the fr-ctc package. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- addons/fr/ctc/bill_invoice_test.go | 1531 ++++++++++++++++++++++++++++ 2 files changed, 1532 insertions(+), 1 deletion(-) create mode 100644 addons/fr/ctc/bill_invoice_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dae170da..dc30d8c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- `addons/fr/ctc`: consolidated French CTC support. The previous `fr-ctc-flow2`, `fr-ctc-flow6` and `fr-ctc-flow10` addons are folded into a single `fr-ctc-v1` addon: invoice rule sets are dispatched at validation time (both parties French → Flow 2 clearance, otherwise Flow 10 e-reporting), and Flow 6 lifecycle messages (`bill.Status`) run unconditionally. `eu-en16931-v2017` is no longer a hard `Requires` — the Flow 2 branch enforces it as a soft assertion so Flow 10 / Flow 6 callers don't have to pull it in. +- `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_invoice_test.go b/addons/fr/ctc/bill_invoice_test.go new file mode 100644 index 000000000..01256671b --- /dev/null +++ b/addons/fr/ctc/bill_invoice_test.go @@ -0,0 +1,1531 @@ +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(tax.ExtMap{ + 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(tax.ExtMap{ + 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(tax.ExtMap{ + 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(tax.ExtMap{ + untdid.ExtKeyTextSubject: "PMT", + }), + }, + { + Key: org.NoteKeyPaymentMethod, + Text: "Penalites de retard applicables.", + Ext: tax.ExtensionsOf(tax.ExtMap{ + untdid.ExtKeyTextSubject: "PMD", + }), + }, + { + Key: org.NoteKeyPaymentTerm, + Text: "Aucun escompte pour paiement anticipe.", + Ext: tax.ExtensionsOf(tax.ExtMap{ + 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(tax.ExtMap{ + 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)) +} + +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(tax.ExtMap{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, invoiceVATRatesAllowed("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(tax.ExtMap{ + 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(tax.ExtMap{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(tax.ExtMap{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(tax.ExtMap{untdid.ExtKeyReference: "AFL"})}, + {Code: "67890", Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{untdid.ExtKeyReference: "AFL"})}, + {Code: "67890", Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{untdid.ExtKeyReference: "AWW"})}, + {Code: "67890", Ext: tax.ExtensionsOf(tax.ExtMap{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) { + // NOTE: A happy-path STC test cannot pass because identity scheme 0231 + // is not in allowedFlow6IdentitySchemes (org_party rule 04). The + // failure-path STC tests below still exercise the bill_invoice STC + // rules because they expect errors regardless of which rule fires. + + 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(tax.ExtMap{ + 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(tax.ExtMap{ + 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(tax.ExtMap{ + 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(tax.ExtMap{ + 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(tax.ExtMap{ + 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(tax.ExtMap{ + iso.ExtKeySchemeID: "0231", + }), + }) + inv.Notes = []*org.Note{{ + Key: org.NoteKeyLegal, + Text: stcMembreAssujettiUnique, + Ext: tax.ExtensionsOf(tax.ExtMap{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(tax.ExtMap{"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 TestInvoiceHasEN16931AddonNonInvoice(t *testing.T) { + // Non-invoice input returns true (rule is invoice-specific). + assert.True(t, invoiceHasEN16931Addon("x")) +} + +func TestInvoiceHasEN16931AddonNilInvoice(t *testing.T) { + assert.True(t, invoiceHasEN16931Addon((*bill.Invoice)(nil))) +} + +func TestInvoiceHasEN16931AddonPresent(t *testing.T) { + inv := &bill.Invoice{Addons: tax.WithAddons(V1, "eu-en16931-v2017")} + assert.True(t, invoiceHasEN16931Addon(inv)) +} + +func TestInvoiceHasEN16931AddonMissing(t *testing.T) { + inv := &bill.Invoice{Addons: tax.WithAddons(V1)} + assert.False(t, invoiceHasEN16931Addon(inv)) +} + +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") +} From 08b100c95536dc51fb22e17b662662c50f95d26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 16:24:37 +0000 Subject: [PATCH 22/26] fr-ctc: scope identity-scheme allow-list to Flow 6 only Removes the addon-wide org.Party identity scheme allow-list. It was a Flow 6 (CDV) constraint that had crept into the addon-wide ruleset, rejecting legitimate Flow 10 cross-border B2B invoices whose foreign counterparty carries a secondary identifier in a scheme outside the French / CDAR list. - org.go: keep only allowedFlow6IdentitySchemes; STC (0231) stays out - org_party.go: drop rule 04 (partyIdentitySchemeAllowed) and the helper; SIRET/SIREN coherence + scheme-format checks remain - bill_status.go: new rule 22 enforces allowedFlow6IdentitySchemes across the four party slots on a bill.Status (Supplier, Customer, Issuer, Recipient), so STC and other non-CDV schemes are still rejected where they actually matter - Tests: add the STC invoice happy-path (now reachable because rule 04 no longer blocks 0231), the tax-rep exempt path via a non-EU supplier, and the CDV-rejects-STC case; replace the now-obsolete "unknown scheme rejected addon-wide" test with its inverse Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/bill_invoice_test.go | 78 ++++++++++++++++++++++++++++-- addons/fr/ctc/bill_status.go | 28 +++++++++++ addons/fr/ctc/bill_status_test.go | 15 ++++++ addons/fr/ctc/org.go | 8 +-- addons/fr/ctc/org_party.go | 19 -------- addons/fr/ctc/org_test.go | 19 ++++---- data/rules/fr-ctc.json | 22 ++------- 7 files changed, 137 insertions(+), 52 deletions(-) diff --git a/addons/fr/ctc/bill_invoice_test.go b/addons/fr/ctc/bill_invoice_test.go index 01256671b..57174e256 100644 --- a/addons/fr/ctc/bill_invoice_test.go +++ b/addons/fr/ctc/bill_invoice_test.go @@ -357,6 +357,62 @@ func TestInvoiceB2BFlow10ExemptHappyWithSellerVATAndNote(t *testing.T) { 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(tax.ExtMap{ + 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) @@ -943,10 +999,24 @@ func TestConsolidatedCreditNoteValidation(t *testing.T) { } func TestSTCSupplierValidation(t *testing.T) { - // NOTE: A happy-path STC test cannot pass because identity scheme 0231 - // is not in allowedFlow6IdentitySchemes (org_party rule 04). The - // failure-path STC tests below still exercise the bill_invoice STC - // rules because they expect errors regardless of which rule fires. + 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(tax.ExtMap{ + 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) diff --git a/addons/fr/ctc/bill_status.go b/addons/fr/ctc/bill_status.go index ebc66509f..88ac82b1d 100644 --- a/addons/fr/ctc/bill_status.go +++ b/addons/fr/ctc/bill_status.go @@ -247,9 +247,37 @@ func billStatusRules() *rules.Set { 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 { diff --git a/addons/fr/ctc/bill_status_test.go b/addons/fr/ctc/bill_status_test.go index 4c80d498e..518967d31 100644 --- a/addons/fr/ctc/bill_status_test.go +++ b/addons/fr/ctc/bill_status_test.go @@ -114,6 +114,21 @@ func TestStatusHappyPath(t *testing.T) { 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(tax.ExtMap{ + 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) diff --git a/addons/fr/ctc/org.go b/addons/fr/ctc/org.go index dd8eb0420..4e08365ac 100644 --- a/addons/fr/ctc/org.go +++ b/addons/fr/ctc/org.go @@ -58,9 +58,11 @@ var schemeIDsRequiringVAT = []string{ } // allowedFlow6IdentitySchemes is the ICD 6523 subset CDAR accepts on -// Flow 6 party identities — SIREN plus the commonly used foreign -// identifier schemes. Parties with identities outside this set should -// not be reported in a Flow 6 CDV. +// 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 diff --git a/addons/fr/ctc/org_party.go b/addons/fr/ctc/org_party.go index 7158a572b..ad0eed385 100644 --- a/addons/fr/ctc/org_party.go +++ b/addons/fr/ctc/org_party.go @@ -28,13 +28,6 @@ func orgPartyRules() *rules.Set { rules.Assert("03", "identity scheme format invalid (BR-FR-CO-10)", is.FuncError("valid scheme format", identitiesSchemeFormatValid), ), - rules.Each( - rules.Field("ext", - rules.Assert("04", "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", - is.Func("scheme in Flow 6 allowed set", partyIdentitySchemeAllowed), - ), - ), - ), ), rules.Field("inboxes", rules.Each( @@ -99,18 +92,6 @@ func partyRoleKnown(v any) bool { return slices.Contains(allowedRoleCodes, role) } -func partyIdentitySchemeAllowed(v any) bool { - ext := extValue(v) - if ext.IsZero() { - return true - } - scheme := ext.Get(iso.ExtKeySchemeID).String() - if scheme == "" { - return true - } - return slices.Contains(allowedFlow6IdentitySchemes, scheme) -} - 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 ad68bb9d1..76d36e684 100644 --- a/addons/fr/ctc/org_test.go +++ b/addons/fr/ctc/org_test.go @@ -709,21 +709,22 @@ func TestPartyKnownRoleAccepted(t *testing.T) { assert.NoError(t, rules.Validate(p, addonContext())) } -func TestPartyUnknownIdentitySchemeRejected(t *testing.T) { +// 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: "Agent", + Name: "Foreign Counterparty", Identities: []*org.Identity{{ Code: "X", Ext: tax.ExtensionsOf(tax.ExtMap{iso.ExtKeySchemeID: "9999"}), }}, } - err := rules.Validate(p, addonContext()) - assert.ErrorContains(t, err, "ICD 6523") -} - -func TestPartyIdentitySchemeAllowedEmptyScheme(t *testing.T) { - e := tax.ExtensionsOf(tax.ExtMap{"some-other": "x"}) - assert.True(t, partyIdentitySchemeAllowed(e)) + assert.NoError(t, rules.Validate(p, addonContext())) } func TestPartyRoleKnownEmptyExtPasses(t *testing.T) { diff --git a/data/rules/fr-ctc.json b/data/rules/fr-ctc.json index 7296af9ee..5c7604eee 100644 --- a/data/rules/fr-ctc.json +++ b/data/rules/fr-ctc.json @@ -788,6 +788,11 @@ "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": [ @@ -1005,23 +1010,6 @@ "desc": "identity scheme format invalid (BR-FR-CO-10)", "tests": "valid scheme format" } - ], - "subsets": [ - { - "each": true, - "subsets": [ - { - "field": "ext", - "assert": [ - { - "id": "GOBL-FR-CTC-ORG-PARTY-04", - "desc": "identity scheme (iso-scheme-id) must be one of the ICD 6523 codes accepted by Flow 6", - "tests": "scheme in Flow 6 allowed set" - } - ] - } - ] - } ] }, { From a33cbf67518fc69c54381514353954c59fb9da50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 16:43:40 +0000 Subject: [PATCH 23/26] fr-ctc: fix staticcheck S1008 + lift coverage to 94.6% - bill_invoice.go:898: collapse the if-then-return / return-true pair into a single `return strings.HasPrefix(...)` so staticcheck S1008 stops flagging it - defensive_test.go: add nil / wrong-type / empty-slice tests for the predicate helpers and normalisers whose defensive guards were previously unreached (setPartyRoleDefault, partyHasRole, partyHasInboxWhenRequired, ensureSIRENOnSupplier, isPartyIdentitySTC, the is*Invoice family, statusReasonCodesAllowed, etc.). Coverage: 91.7% -> 94.6% Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/bill_invoice.go | 5 +- addons/fr/ctc/defensive_test.go | 229 ++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 addons/fr/ctc/defensive_test.go diff --git a/addons/fr/ctc/bill_invoice.go b/addons/fr/ctc/bill_invoice.go index 9aed8c704..3d6a0912b 100644 --- a/addons/fr/ctc/bill_invoice.go +++ b/addons/fr/ctc/bill_invoice.go @@ -895,10 +895,7 @@ func partyHasSIRENInbox(val any) bool { } for _, inbox := range party.Inboxes { if inbox != nil && inbox.Scheme == inboxSchemeSIREN { - if !strings.HasPrefix(string(inbox.Code), siren) { - return false - } - return true + return strings.HasPrefix(string(inbox.Code), siren) } } return false diff --git a/addons/fr/ctc/defensive_test.go b/addons/fr/ctc/defensive_test.go new file mode 100644 index 000000000..7a0fdf30d --- /dev/null +++ b/addons/fr/ctc/defensive_test.go @@ -0,0 +1,229 @@ +package ctc + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +// Defensive-branch coverage: nil / zero / wrong-type / empty-slice +// inputs to the predicate helpers, so the "ok / nil" guards report as +// exercised rather than dead. + +// --- bill_invoice predicates -------------------------------------------- + +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) }) +} + +// --- bill_status predicates --------------------------------------------- + +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)) +} + +// --- ensureSIRENOnSupplier covers the "supplier already carries the +// SIREN" early-return path that the happy-path tests don't reach +// (since the test fixture already aligns 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) +} + +// --- ReasonCodeAllowedForProcessCode ----------------------------------- + +func TestReasonCodeAllowedForProcessCodeUnknownProcess(t *testing.T) { + // An unknown process code means we have no allow-list — should pass + // (rule defers to the bucket consistency check). + assert.True(t, ReasonCodeAllowedForProcessCode("DEST_INC", "999")) +} + +// --- org.go sirenFromFrenchTaxID + partyCarriesSIREN ------------------ + +func TestSirenFromFrenchTaxIDNilSIRETEntry(t *testing.T) { + p := &org.Party{Identities: []*org.Identity{nil}} + // Falls back to TaxID digits. + 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)) +} From 528129c9e5b0f60f645a3ba183d21497b6300c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Mon, 11 May 2026 17:02:46 +0000 Subject: [PATCH 24/26] fr-ctc: address Copilot review - bill_status.go: rename siRENFromSEParty -> sirenFromSEParty (S/N1) - extensions.go: rewrite fr-ctc-role values + descriptions to match the French CTC specification of MDT-158 (CDAR RoleCode). Labels for WK and DFH were generic UNCL 3035 ("Work/Service receiver", "Delivery From") but CDAR assigns them CDAR-specific meanings: WK = dematerialisation platform / operator, DFH = Portail Public de Facturation. Updated all role names (BY Acheteur, DL Affactureur, AB Agent d'acheteur, SR Agent de vendeur, etc.) and added French translations. - bill_status.go: align BR-FR-CDV-08 comment with the corrected WK / DFH meanings. - org.go: normalizeIdentity now maps SIREN Type -> iso-scheme-id 0002 and SIRET Type -> 0009 directly, so downstream validators can rely on the scheme-id ext being present even when eu-en16931 is not declared (Flow 6 / standalone Flow 10). - Updated TestPrivateIDNormalization to reflect the new contract. Copilot comments on the deleted flow10 files (early-return party normalisation; inverted billing-mode comments) are already resolved by the consolidation merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/bill_status.go | 16 +++++---- addons/fr/ctc/defensive_test.go | 27 +++++++++++++++ addons/fr/ctc/extensions.go | 59 ++++++++++++++++++--------------- addons/fr/ctc/org.go | 16 +++++++-- addons/fr/ctc/org_test.go | 10 +++++- data/addons/fr-ctc-v1.json | 49 ++++++++++++++++----------- 6 files changed, 122 insertions(+), 55 deletions(-) diff --git a/addons/fr/ctc/bill_status.go b/addons/fr/ctc/bill_status.go index 88ac82b1d..bded29b87 100644 --- a/addons/fr/ctc/bill_status.go +++ b/addons/fr/ctc/bill_status.go @@ -55,7 +55,7 @@ func normalizeStatus(st *bill.Status) { } // Propagate the SE-roled party's SIREN onto Supplier when missing. - if siren := siRENFromSEParty(st.Issuer, st.Recipient); siren != nil { + if siren := sirenFromSEParty(st.Issuer, st.Recipient); siren != nil { st.Supplier = ensureSIRENOnSupplier(st.Supplier, siren) } @@ -67,9 +67,11 @@ func normalizeStatus(st *bill.Status) { } } -// siRENFromSEParty returns the first SIREN identity carried by an -// SE-roled party among the given candidates, or nil. -func siRENFromSEParty(candidates ...*org.Party) *org.Identity { +// 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 @@ -145,8 +147,10 @@ func partyHasRole(v any) bool { } // partyHasInboxWhenRequired enforces BR-FR-CDV-08: a party whose role -// is not WK (legal representative) or DFH (declarant for VAT grouping) -// must carry a URIID (electronic inbox). +// 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 { diff --git a/addons/fr/ctc/defensive_test.go b/addons/fr/ctc/defensive_test.go index 7a0fdf30d..6727b6fad 100644 --- a/addons/fr/ctc/defensive_test.go +++ b/addons/fr/ctc/defensive_test.go @@ -227,3 +227,30 @@ 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 (e.g. +// statusPartyHasSIRENIdentity) can therefore rely on the scheme-id +// extension 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-scheme-id").String()) +} + +func TestNormalizeIdentityMapsSIRETTypeToScheme(t *testing.T) { + id := &org.Identity{Type: "SIRET", Code: "35600000000011"} + normalizeIdentity(id) + assert.Equal(t, identitySchemeIDSIRET, id.Ext.Get("iso-scheme-id").String()) +} + +func TestNormalizeIdentityPreservesExistingScheme(t *testing.T) { + id := &org.Identity{ + Type: "SIREN", + Code: "356000000", + Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "9999"}), + } + normalizeIdentity(id) + assert.Equal(t, "9999", id.Ext.Get("iso-scheme-id").String()) +} diff --git a/addons/fr/ctc/extensions.go b/addons/fr/ctc/extensions.go index 3dfce6023..da89aecd2 100644 --- a/addons/fr/ctc/extensions.go +++ b/addons/fr/ctc/extensions.go @@ -62,18 +62,21 @@ const ( BillingModeM4 cbc.Code = "M4" ) -// Flow 6 party role codes (UNCL 3035 subset accepted by CDAR). +// 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 ( - RoleSE cbc.Code = "SE" // Seller - RoleBY cbc.Code = "BY" // Buyer - RoleWK cbc.Code = "WK" // Work/Service receiver - RoleDFH cbc.Code = "DFH" // Delivery from - RoleAB cbc.Code = "AB" // Bank - RoleSR cbc.Code = "SR" // Sender / issuer on behalf of - RoleDL cbc.Code = "DL" // Dealer / intermediary - RolePE cbc.Code = "PE" // Payee - RolePR cbc.Code = "PR" // Payer - RoleII cbc.Code = "II" // Issuer of invoice + 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 ) @@ -172,24 +175,28 @@ var extensions = []*cbc.Definition{ }, Desc: i18n.String{ i18n.EN: here.Doc(` - UNCL 3035 role code carried as the CDAR RoleCode for each - populated party on a Flow 6 lifecycle message. The normalizer - fills the obvious defaults (Supplier → SE, Customer → BY) - and leaves the rest for the caller to set explicitly. + 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: RoleSE, Name: i18n.String{i18n.EN: "Seller"}}, - {Code: RoleBY, Name: i18n.String{i18n.EN: "Buyer"}}, - {Code: RoleWK, Name: i18n.String{i18n.EN: "Work / Service Receiver"}}, - {Code: RoleDFH, Name: i18n.String{i18n.EN: "Delivery From"}}, - {Code: RoleAB, Name: i18n.String{i18n.EN: "Bank"}}, - {Code: RoleSR, Name: i18n.String{i18n.EN: "Sender / Issuer on behalf of"}}, - {Code: RoleDL, Name: i18n.String{i18n.EN: "Dealer"}}, - {Code: RolePE, Name: i18n.String{i18n.EN: "Payee"}}, - {Code: RolePR, Name: i18n.String{i18n.EN: "Payer"}}, - {Code: RoleII, Name: i18n.String{i18n.EN: "Issuer of Invoice"}}, - {Code: RoleIV, Name: i18n.String{i18n.EN: "Invoicee"}}, + {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"}}, }, }, { diff --git a/addons/fr/ctc/org.go b/addons/fr/ctc/org.go index b8d817bf2..109871041 100644 --- a/addons/fr/ctc/org.go +++ b/addons/fr/ctc/org.go @@ -20,6 +20,8 @@ const ( // 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. @@ -214,7 +216,11 @@ func normalizeIdentities(party *org.Party) { } } -// normalizeIdentity handles per-identity normalization (private-id key). +// 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 @@ -222,8 +228,12 @@ func normalizeIdentity(id *org.Identity) { if id.Key == identityKeyPrivateID { id.Ext = id.Ext.Set(iso.ExtKeySchemeID, identitySchemeIDPrivate) } - // Note: Type ↔ ISO scheme ID mapping for SIREN/SIRET is handled by - // the 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 flags the SIREN-scope inbox with the peppol key diff --git a/addons/fr/ctc/org_test.go b/addons/fr/ctc/org_test.go index 356969435..626bd466d 100644 --- a/addons/fr/ctc/org_test.go +++ b/addons/fr/ctc/org_test.go @@ -278,13 +278,21 @@ func TestPrivateIDNormalization(t *testing.T) { assert.Equal(t, "other-value", party.Identities[0].Ext.Get("other-key").String()) }) - t.Run("non-private-id identity 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"}, }, } ad.Normalizer(party) + 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()) }) diff --git a/data/addons/fr-ctc-v1.json b/data/addons/fr-ctc-v1.json index 335b61c41..ca941ec11 100644 --- a/data/addons/fr-ctc-v1.json +++ b/data/addons/fr-ctc-v1.json @@ -171,73 +171,84 @@ "fr": "Code rôle partie" }, "desc": { - "en": "UNCL 3035 role code carried as the CDAR RoleCode for each\npopulated party on a Flow 6 lifecycle message. The normalizer\nfills the obvious defaults (Supplier → SE, Customer → BY)\nand leaves the rest for the caller to set explicitly." + "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": "SE", + "code": "BY", "name": { - "en": "Seller" + "en": "Buyer", + "fr": "Acheteur" } }, { - "code": "BY", + "code": "DL", "name": { - "en": "Buyer" + "en": "Factor", + "fr": "Affactureur" } }, { - "code": "WK", + "code": "SE", "name": { - "en": "Work / Service Receiver" + "en": "Seller", + "fr": "Vendeur" } }, { - "code": "DFH", + "code": "AB", "name": { - "en": "Delivery From" + "en": "Buyer's agent", + "fr": "Agent d'acheteur" } }, { - "code": "AB", + "code": "SR", "name": { - "en": "Bank" + "en": "Seller's agent", + "fr": "Agent de vendeur" } }, { - "code": "SR", + "code": "WK", "name": { - "en": "Sender / Issuer on behalf of" + "en": "Dematerialisation platform or operator", + "fr": "Plateforme ou opérateur de dématérialisation" } }, { - "code": "DL", + "code": "DFH", "name": { - "en": "Dealer" + "en": "Portail Public de Facturation (PPF)", + "fr": "Portail Public de Facturation" } }, { "code": "PE", "name": { - "en": "Payee" + "en": "Payee", + "fr": "Bénéficiaire" } }, { "code": "PR", "name": { - "en": "Payer" + "en": "Payer", + "fr": "Payeur" } }, { "code": "II", "name": { - "en": "Issuer of Invoice" + "en": "Invoicer", + "fr": "Émetteur de la facture" } }, { "code": "IV", "name": { - "en": "Invoicee" + "en": "Invoicee", + "fr": "Destinataire de la facture" } } ] From 229fe9bd2e6389ebc43d5c915c1d725eedc9dbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Tue, 12 May 2026 11:21:26 +0000 Subject: [PATCH 25/26] tax: make HasAddon variadic; reorganise fr-ctc test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tax/addons.go: HasAddon now takes ...cbc.Key. Returns true when the object declares at least one of the listed addons (semantics symmetric with AddonIn). Backwards-compatible with single-key callers. - addons/fr/ctc/bill_invoice.go: drop the local invoiceHasEN16931Addon helper; rule 02 now reads `tax.HasAddon(en16931.V2017)` directly. - addons/fr/ctc: rename helpers_test.go -> ctc_test.go (paired with ctc.go) and dissolve defensive_test.go — its nil / wrong-type / empty-slice tests are redistributed to bill_invoice_test.go, bill_status_test.go, codes_test.go and org_test.go alongside the source they exercise. Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/bill_invoice.go | 21 +- addons/fr/ctc/bill_invoice_test.go | 139 ++++++++-- addons/fr/ctc/bill_status_test.go | 64 +++++ addons/fr/ctc/codes_test.go | 6 + .../fr/ctc/{helpers_test.go => ctc_test.go} | 0 addons/fr/ctc/defensive_test.go | 256 ------------------ addons/fr/ctc/org_test.go | 43 +++ data/rules/fr-ctc.json | 2 +- tax/addons.go | 23 +- 9 files changed, 254 insertions(+), 300 deletions(-) rename addons/fr/ctc/{helpers_test.go => ctc_test.go} (100%) delete mode 100644 addons/fr/ctc/defensive_test.go diff --git a/addons/fr/ctc/bill_invoice.go b/addons/fr/ctc/bill_invoice.go index 3d6a0912b..9c7bd70ab 100644 --- a/addons/fr/ctc/bill_invoice.go +++ b/addons/fr/ctc/bill_invoice.go @@ -5,6 +5,7 @@ import ( "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" @@ -157,24 +158,6 @@ func invoiceIsNotDomesticFrenchAny(v any) bool { return ok && !invoiceIsDomesticFrench(inv) } -// en16931V2017Key is the addon key the Flow 2 ruleset requires to be -// declared on the invoice. Hard-coded to avoid importing the en16931 -// package — that import would make en16931 a static dependency of -// fr-ctc, which is exactly what the Flow 2 en16931-addon rule below -// was added to avoid. -const en16931V2017Key cbc.Key = "eu-en16931-v2017" - -// invoiceHasEN16931Addon reports whether the invoice carries the -// eu-en16931-v2017 addon. Used by the Flow 2 dispatcher to make -// en16931 a soft requirement only for domestic French B2B. -func invoiceHasEN16931Addon(v any) bool { - inv, ok := v.(*bill.Invoice) - if !ok || inv == nil { - return true - } - return slices.Contains(inv.Addons.List, en16931V2017Key) -} - // -- Normalisation -------------------------------------------------------- func normalizeInvoice(inv *bill.Invoice) { @@ -374,7 +357,7 @@ func flow2InvoiceDefs() []rules.Def { // 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", - is.Func("has eu-en16931-v2017 addon", invoiceHasEN16931Addon), + 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", diff --git a/addons/fr/ctc/bill_invoice_test.go b/addons/fr/ctc/bill_invoice_test.go index 9e6a79b5d..0b4502507 100644 --- a/addons/fr/ctc/bill_invoice_test.go +++ b/addons/fr/ctc/bill_invoice_test.go @@ -1572,30 +1572,133 @@ func TestInvoiceIsNotDomesticFrenchAnyWrongType(t *testing.T) { assert.False(t, invoiceIsNotDomesticFrenchAny("x")) } -func TestInvoiceHasEN16931AddonNonInvoice(t *testing.T) { - // Non-invoice input returns true (rule is invoice-specific). - assert.True(t, invoiceHasEN16931Addon("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") } -func TestInvoiceHasEN16931AddonNilInvoice(t *testing.T) { - assert.True(t, invoiceHasEN16931Addon((*bill.Invoice)(nil))) +// --- defensive coverage: nil / wrong-type / empty-slice guards -------- + +func TestInvoiceCodeValidNonInvoice(t *testing.T) { + assert.True(t, invoiceCodeValid(42)) } -func TestInvoiceHasEN16931AddonPresent(t *testing.T) { - inv := &bill.Invoice{Addons: tax.WithAddons(V1, "eu-en16931-v2017")} - assert.True(t, invoiceHasEN16931Addon(inv)) +func TestInvoiceCodeValidEmptyCode(t *testing.T) { + assert.True(t, invoiceCodeValid(&bill.Invoice{})) } -func TestInvoiceHasEN16931AddonMissing(t *testing.T) { - inv := &bill.Invoice{Addons: tax.WithAddons(V1)} - assert.False(t, invoiceHasEN16931Addon(inv)) +func TestPrecedingDocCodeValidNonDocumentRef(t *testing.T) { + assert.True(t, precedingDocCodeValid(42)) } -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") +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_status_test.go b/addons/fr/ctc/bill_status_test.go index 4770a1ebf..370ea9d0d 100644 --- a/addons/fr/ctc/bill_status_test.go +++ b/addons/fr/ctc/bill_status_test.go @@ -545,3 +545,67 @@ func TestLineHasReasonCodeNilReason(t *testing.T) { 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/codes_test.go b/addons/fr/ctc/codes_test.go index 657dc2d05..5a6cf2e62 100644 --- a/addons/fr/ctc/codes_test.go +++ b/addons/fr/ctc/codes_test.go @@ -359,3 +359,9 @@ 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/helpers_test.go b/addons/fr/ctc/ctc_test.go similarity index 100% rename from addons/fr/ctc/helpers_test.go rename to addons/fr/ctc/ctc_test.go diff --git a/addons/fr/ctc/defensive_test.go b/addons/fr/ctc/defensive_test.go deleted file mode 100644 index 6727b6fad..000000000 --- a/addons/fr/ctc/defensive_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package ctc - -import ( - "testing" - - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/catalogues/untdid" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/tax" - "github.com/stretchr/testify/assert" -) - -// Defensive-branch coverage: nil / zero / wrong-type / empty-slice -// inputs to the predicate helpers, so the "ok / nil" guards report as -// exercised rather than dead. - -// --- bill_invoice predicates -------------------------------------------- - -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) }) -} - -// --- bill_status predicates --------------------------------------------- - -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)) -} - -// --- ensureSIRENOnSupplier covers the "supplier already carries the -// SIREN" early-return path that the happy-path tests don't reach -// (since the test fixture already aligns 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) -} - -// --- ReasonCodeAllowedForProcessCode ----------------------------------- - -func TestReasonCodeAllowedForProcessCodeUnknownProcess(t *testing.T) { - // An unknown process code means we have no allow-list — should pass - // (rule defers to the bucket consistency check). - assert.True(t, ReasonCodeAllowedForProcessCode("DEST_INC", "999")) -} - -// --- org.go sirenFromFrenchTaxID + partyCarriesSIREN ------------------ - -func TestSirenFromFrenchTaxIDNilSIRETEntry(t *testing.T) { - p := &org.Party{Identities: []*org.Identity{nil}} - // Falls back to TaxID digits. - 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 (e.g. -// statusPartyHasSIRENIdentity) can therefore rely on the scheme-id -// extension 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-scheme-id").String()) -} - -func TestNormalizeIdentityMapsSIRETTypeToScheme(t *testing.T) { - id := &org.Identity{Type: "SIRET", Code: "35600000000011"} - normalizeIdentity(id) - assert.Equal(t, identitySchemeIDSIRET, id.Ext.Get("iso-scheme-id").String()) -} - -func TestNormalizeIdentityPreservesExistingScheme(t *testing.T) { - id := &org.Identity{ - Type: "SIREN", - Code: "356000000", - Ext: tax.ExtensionsOf(cbc.CodeMap{"iso-scheme-id": "9999"}), - } - normalizeIdentity(id) - assert.Equal(t, "9999", id.Ext.Get("iso-scheme-id").String()) -} diff --git a/addons/fr/ctc/org_test.go b/addons/fr/ctc/org_test.go index 626bd466d..424997bf5 100644 --- a/addons/fr/ctc/org_test.go +++ b/addons/fr/ctc/org_test.go @@ -738,3 +738,46 @@ func TestPartyForeignIdentitySchemeAccepted(t *testing.T) { 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/data/rules/fr-ctc.json b/data/rules/fr-ctc.json index 5c7604eee..964ccefd3 100644 --- a/data/rules/fr-ctc.json +++ b/data/rules/fr-ctc.json @@ -20,7 +20,7 @@ { "id": "GOBL-FR-CTC-BILL-INVOICE-02", "desc": "domestic French B2B invoices must also declare the eu-en16931-v2017 addon", - "tests": "has eu-en16931-v2017 addon" + "tests": "has addon eu-en16931-v2017" }, { "id": "GOBL-FR-CTC-BILL-INVOICE-03", diff --git a/tax/addons.go b/tax/addons.go index 8d4eb7c76..7e77ec2cf 100644 --- a/tax/addons.go +++ b/tax/addons.go @@ -1,7 +1,6 @@ package tax import ( - "fmt" "sort" "strings" @@ -180,15 +179,27 @@ type implementsAddon interface { GetAddons() []cbc.Key } -// HasAddon provides a test to check that an object provided to the test responds to the -// GetAddons method and that the addon key provided is supported. -func HasAddon(key cbc.Key) rules.Test { - return is.Func(fmt.Sprintf("has addon %v", key), func(value any) bool { +// HasAddon provides a test to check that an object provided to the test +// responds to the GetAddons method and declares at least one of the +// given addon keys. Symmetric with AddonIn. +func HasAddon(keys ...cbc.Key) rules.Test { + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = k.String() + } + desc := "has addon " + strings.Join(parts, " or ") + return is.Func(desc, func(value any) bool { obj, ok := value.(implementsAddon) if !ok { return false // do not continue } - return key.In(obj.GetAddons()...) + declared := obj.GetAddons() + for _, k := range keys { + if k.In(declared...) { + return true + } + } + return false }) } From bdce435f6a51a685dca27df4a3b216abcb516536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Tue, 12 May 2026 14:57:03 +0000 Subject: [PATCH 26/26] fr-ctc: rename allowedVATRates -> allowedVATPercents Aligns naming with the underlying value (G1.24 is a whitelist of VAT percentages, not a tax-rate object). Renames cover the variable, the predicate helpers, is.Func labels, rule messages and tests: - allowedVATRates -> allowedVATPercents - invoiceVATRatesAllowed -> invoiceVATPercentsAllowed - paymentVATRatesAllowed -> paymentVATPercentsAllowed - TestPaymentVATRate* -> TestPaymentVATPercent* - TestInvoiceB2CVATRateNot* -> TestInvoiceB2CVATPercentNot* Co-Authored-By: Claude Opus 4.7 (1M context) --- addons/fr/ctc/bill_invoice.go | 14 +++++++------- addons/fr/ctc/bill_invoice_test.go | 2 +- addons/fr/ctc/bill_payment.go | 13 +++++++------ addons/fr/ctc/bill_payment_test.go | 4 ++-- data/rules/fr-ctc.json | 8 ++++---- tax/addons.go | 23 ++++++----------------- 6 files changed, 27 insertions(+), 37 deletions(-) diff --git a/addons/fr/ctc/bill_invoice.go b/addons/fr/ctc/bill_invoice.go index 9c7bd70ab..77fb67246 100644 --- a/addons/fr/ctc/bill_invoice.go +++ b/addons/fr/ctc/bill_invoice.go @@ -84,9 +84,9 @@ var allowedAttachmentDescriptions = []string{ "RECAPITULATIF_COTRAITANCE", } -// allowedVATRates is the whitelist of VAT percentages authorised on a +// allowedVATPercents is the whitelist of VAT percentages authorised on a // Flow 10 invoice / payment (G1.24). -var allowedVATRates = mustParsePercentages( +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%", ) @@ -583,7 +583,7 @@ func flow2InvoiceDefs() []rules.Def { // (Flow 10 e-reporting) invoice — B2C or cross-border B2B. func flow10InvoiceDefs() []rules.Def { return []rules.Def{ - // B2C rules: category, supplier SIREN, VAT rate whitelist. + // B2C rules: category, supplier SIREN, VAT percent whitelist. rules.When( is.Func("B2C invoice", invoiceIsB2CAny), rules.Field("tax", @@ -601,8 +601,8 @@ func flow10InvoiceDefs() []rules.Def { is.Func("party has SIREN", partyHasSIREN), ), ), - rules.Assert("46", "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates", invoiceVATRatesAllowed), + 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", @@ -1048,7 +1048,7 @@ func invoiceIsCrossBorderB2BAny(v any) bool { return ok && !invoiceIsB2C(inv) } -func invoiceVATRatesAllowed(v any) bool { +func invoiceVATPercentsAllowed(v any) bool { inv, ok := v.(*bill.Invoice) if !ok || inv == nil { return true @@ -1061,7 +1061,7 @@ func invoiceVATRatesAllowed(v any) bool { if combo == nil || combo.Category != tax.CategoryVAT || combo.Percent == nil { continue } - if !percentageInList(*combo.Percent, allowedVATRates) { + if !percentageInList(*combo.Percent, allowedVATPercents) { return false } } diff --git a/addons/fr/ctc/bill_invoice_test.go b/addons/fr/ctc/bill_invoice_test.go index 0b4502507..001b2e3f1 100644 --- a/addons/fr/ctc/bill_invoice_test.go +++ b/addons/fr/ctc/bill_invoice_test.go @@ -551,7 +551,7 @@ func TestInvoiceHasExemptTaxNoteWrongType(t *testing.T) { } func TestInvoiceVATRatesAllowedWrongType(t *testing.T) { - assert.True(t, invoiceVATRatesAllowed("x")) + assert.True(t, invoiceVATPercentsAllowed("x")) } func TestMustParsePercentagesPanicsOnBadInput(t *testing.T) { diff --git a/addons/fr/ctc/bill_payment.go b/addons/fr/ctc/bill_payment.go index b6e2f92fa..9ca493f23 100644 --- a/addons/fr/ctc/bill_payment.go +++ b/addons/fr/ctc/bill_payment.go @@ -38,8 +38,8 @@ func billPaymentRules() *rules.Set { is.Present, ), ), - rules.Assert("03", "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates", paymentVATRatesAllowed), + 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", @@ -76,9 +76,10 @@ func billPaymentRules() *rules.Set { ) } -// paymentVATRatesAllowed reports whether every VAT rate total on the -// payment's lines matches one of the G1.24 whitelist percentages. -func paymentVATRatesAllowed(v any) bool { +// 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 @@ -95,7 +96,7 @@ func paymentVATRatesAllowed(v any) bool { if rate == nil || rate.Percent == nil { continue } - if !percentageInList(*rate.Percent, allowedVATRates) { + if !percentageInList(*rate.Percent, allowedVATPercents) { return false } } diff --git a/addons/fr/ctc/bill_payment_test.go b/addons/fr/ctc/bill_payment_test.go index 137ba9055..678a9d4c8 100644 --- a/addons/fr/ctc/bill_payment_test.go +++ b/addons/fr/ctc/bill_payment_test.go @@ -156,10 +156,10 @@ func TestPaymentHasCustomerAnyWrongType(t *testing.T) { } func TestPaymentVATRatesAllowedWrongType(t *testing.T) { - assert.True(t, paymentVATRatesAllowed("x")) + assert.True(t, paymentVATPercentsAllowed("x")) } func TestPaymentVATRatesAllowedNilLine(t *testing.T) { p := &bill.Payment{Lines: []*bill.PaymentLine{nil}} - assert.True(t, paymentVATRatesAllowed(p)) + assert.True(t, paymentVATPercentsAllowed(p)) } diff --git a/data/rules/fr-ctc.json b/data/rules/fr-ctc.json index 964ccefd3..0c40fef35 100644 --- a/data/rules/fr-ctc.json +++ b/data/rules/fr-ctc.json @@ -479,8 +479,8 @@ "assert": [ { "id": "GOBL-FR-CTC-BILL-INVOICE-46", - "desc": "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates" + "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": [ @@ -676,8 +676,8 @@ "assert": [ { "id": "GOBL-FR-CTC-BILL-PAYMENT-03", - "desc": "every VAT line rate must be one of the Flow 10 permitted percentages (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 rates" + "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": [ diff --git a/tax/addons.go b/tax/addons.go index 7e77ec2cf..8d4eb7c76 100644 --- a/tax/addons.go +++ b/tax/addons.go @@ -1,6 +1,7 @@ package tax import ( + "fmt" "sort" "strings" @@ -179,27 +180,15 @@ type implementsAddon interface { GetAddons() []cbc.Key } -// HasAddon provides a test to check that an object provided to the test -// responds to the GetAddons method and declares at least one of the -// given addon keys. Symmetric with AddonIn. -func HasAddon(keys ...cbc.Key) rules.Test { - parts := make([]string, len(keys)) - for i, k := range keys { - parts[i] = k.String() - } - desc := "has addon " + strings.Join(parts, " or ") - return is.Func(desc, func(value any) bool { +// HasAddon provides a test to check that an object provided to the test responds to the +// GetAddons method and that the addon key provided is supported. +func HasAddon(key cbc.Key) rules.Test { + return is.Func(fmt.Sprintf("has addon %v", key), func(value any) bool { obj, ok := value.(implementsAddon) if !ok { return false // do not continue } - declared := obj.GetAddons() - for _, k := range keys { - if k.In(declared...) { - return true - } - } - return false + return key.In(obj.GetAddons()...) }) }