From 6619f58970fb1f89d805601552a927d4c7fc6964 Mon Sep 17 00:00:00 2001 From: Enrique Date: Tue, 4 Aug 2026 13:36:20 +0200 Subject: [PATCH 1/2] Add Luxembourg (LU) tax regime Introduce VAT rates, TVA identity validation with checksum, and a sample invoice for Luxembourg. Co-authored-by: Cursor --- data/regimes/lu.json | 223 +++++++++++++++++++++++++++++ examples/lu/invoice-lu-lu.yaml | 46 ++++++ examples/lu/out/invoice-lu-lu.json | 114 +++++++++++++++ regimes/lu/lu.go | 79 ++++++++++ regimes/lu/tax_categories.go | 124 ++++++++++++++++ regimes/lu/tax_identity.go | 65 +++++++++ regimes/lu/tax_identity_test.go | 62 ++++++++ regimes/regimes.go | 1 + 8 files changed, 714 insertions(+) create mode 100644 data/regimes/lu.json create mode 100644 examples/lu/invoice-lu-lu.yaml create mode 100644 examples/lu/out/invoice-lu-lu.json create mode 100644 regimes/lu/lu.go create mode 100644 regimes/lu/tax_categories.go create mode 100644 regimes/lu/tax_identity.go create mode 100644 regimes/lu/tax_identity_test.go diff --git a/data/regimes/lu.json b/data/regimes/lu.json new file mode 100644 index 000000000..0d74a5c99 --- /dev/null +++ b/data/regimes/lu.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "de": "Luxemburg", + "en": "Luxembourg", + "fr": "Luxembourg" + }, + "description": { + "en": "Luxembourg's tax system is administered by the Administration de\nl'enregistrement, des domaines et de la TVA (AED). As an EU member\nstate, Luxembourg follows the EU VAT Directive and applies the\nlowest standard VAT rate in the Union.\n\nVAT (taxe sur la valeur ajoutée, TVA) applies at a standard rate of\n17%, an intermediate rate of 14%, a reduced rate of 8%, and a\nsuper-reduced rate of 3%. A temporary one-point reduction applied\nto the standard, intermediate, and reduced rates during 2023.\n\nBusinesses are identified by their n° TVA (VAT identification\nnumber) in the format LU followed by 8 digits, of which the last\ntwo are check digits. Luxembourg supports credit notes for invoice\ncorrections. B2G e-invoicing via PEPPOL is supported." + }, + "sources": [ + { + "title": { + "en": "AED - Value Added Tax (TVA)" + }, + "url": "https://pfi.public.lu/en/professionnels/tva.html" + }, + { + "title": { + "en": "Guichet.lu - VAT rates" + }, + "url": "https://guichet.public.lu/en/entreprises/fiscalite/tva/assujettissement/taux-tva.html" + } + ], + "time_zone": "Europe/Luxembourg", + "country": "LU", + "currency": "EUR", + "tax_scheme": "VAT", + "scenarios": [ + { + "schema": "bill/invoice", + "list": [ + { + "tags": [ + "reverse-charge" + ], + "cat": [ + "VAT" + ], + "note": { + "cat": "VAT", + "key": "reverse-charge", + "text": "Reverse charge: Customer to account for VAT to the relevant tax authority." + } + } + ] + } + ], + "corrections": [ + { + "schema": "bill/invoice", + "types": [ + "credit-note" + ] + } + ], + "categories": [ + { + "code": "VAT", + "name": { + "de": "MwSt.", + "en": "VAT", + "fr": "TVA" + }, + "title": { + "de": "Mehrwertsteuer", + "en": "Value Added Tax", + "fr": "Taxe sur la valeur ajoutée" + }, + "keys": [ + { + "key": "standard", + "name": { + "en": "Standard" + } + }, + { + "key": "zero", + "name": { + "en": "Zero" + } + }, + { + "key": "reverse-charge", + "name": { + "en": "Reverse charge" + }, + "no_percent": true + }, + { + "key": "exempt", + "name": { + "en": "Exempt" + }, + "no_percent": true + }, + { + "key": "export", + "name": { + "en": "Export" + }, + "no_percent": true + }, + { + "key": "intra-community", + "name": { + "en": "Intra-community" + }, + "no_percent": true + }, + { + "key": "outside-scope", + "name": { + "en": "Outside scope" + }, + "no_percent": true + } + ], + "rates": [ + { + "rate": "general", + "keys": [ + "standard" + ], + "name": { + "de": "Normalsatz", + "en": "General Rate", + "fr": "Taux normal" + }, + "values": [ + { + "since": "2024-01-01", + "percent": "17.0%" + }, + { + "since": "2023-01-01", + "percent": "16.0%" + }, + { + "since": "2015-01-01", + "percent": "17.0%" + } + ] + }, + { + "rate": "intermediate", + "keys": [ + "standard" + ], + "name": { + "de": "Mittlerer Satz", + "en": "Intermediate Rate", + "fr": "Taux intermédiaire" + }, + "values": [ + { + "since": "2024-01-01", + "percent": "14.0%" + }, + { + "since": "2023-01-01", + "percent": "13.0%" + }, + { + "since": "2015-01-01", + "percent": "14.0%" + } + ] + }, + { + "rate": "reduced", + "keys": [ + "standard" + ], + "name": { + "de": "Ermäßigter Satz", + "en": "Reduced Rate", + "fr": "Taux réduit" + }, + "values": [ + { + "since": "2024-01-01", + "percent": "8.0%" + }, + { + "since": "2023-01-01", + "percent": "7.0%" + }, + { + "since": "2015-01-01", + "percent": "8.0%" + } + ] + }, + { + "rate": "super-reduced", + "keys": [ + "standard" + ], + "name": { + "de": "Stark ermäßigter Satz", + "en": "Super-Reduced Rate", + "fr": "Taux super-réduit" + }, + "values": [ + { + "since": "2015-01-01", + "percent": "3.0%" + } + ] + } + ], + "sources": [ + { + "title": { + "en": "Guichet.lu - VAT rates" + }, + "url": "https://guichet.public.lu/en/entreprises/fiscalite/tva/assujettissement/taux-tva.html" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/lu/invoice-lu-lu.yaml b/examples/lu/invoice-lu-lu.yaml new file mode 100644 index 000000000..dc1d4b494 --- /dev/null +++ b/examples/lu/invoice-lu-lu.yaml @@ -0,0 +1,46 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +uuid: "019035bd-4524-73ab-bf44-6037841ce5d9" +issue_date: "2025-10-29" +series: "SAMPLE" +code: "001" + +supplier: + tax_id: + country: "LU" + code: "15027442" + name: "Provide One S.à r.l." + emails: + - addr: "billing@example.com" + addresses: + - num: "1" + street: "Boulevard Royal" + locality: "Luxembourg" + code: "2449" + country: "LU" + +customer: + tax_id: + country: "LU" + code: "13669580" + name: "Sample Consumer S.A." + emails: + - addr: "customer@example.com" + addresses: + - num: "12" + street: "Rue de la Poste" + locality: "Esch-sur-Alzette" + code: "4001" + country: "LU" + +lines: + - quantity: 20 + item: + name: "Development services" + price: "90.00" + unit: "h" + discounts: + - percent: "10%" + reason: "Special discount" + taxes: + - cat: VAT + rate: "standard" diff --git a/examples/lu/out/invoice-lu-lu.json b/examples/lu/out/invoice-lu-lu.json new file mode 100644 index 000000000..5adf50e1a --- /dev/null +++ b/examples/lu/out/invoice-lu-lu.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "e1b9b58fbd201839252c8f6c71d54f5796ffdbb17ca02aadd6915a8c4477a44d" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "LU", + "uuid": "019035bd-4524-73ab-bf44-6037841ce5d9", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2025-10-29", + "currency": "EUR", + "supplier": { + "name": "Provide One S.à r.l.", + "tax_id": { + "country": "LU", + "code": "15027442" + }, + "addresses": [ + { + "num": "1", + "street": "Boulevard Royal", + "locality": "Luxembourg", + "code": "2449", + "country": "LU" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer S.A.", + "tax_id": { + "country": "LU", + "code": "13669580" + }, + "addresses": [ + { + "num": "12", + "street": "Rue de la Poste", + "locality": "Esch-sur-Alzette", + "code": "4001", + "country": "LU" + } + ], + "emails": [ + { + "addr": "customer@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "17.0%" + } + ], + "total": "1620.00" + } + ], + "totals": { + "sum": "1620.00", + "total": "1620.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1620.00", + "percent": "17.0%", + "amount": "275.40" + } + ], + "amount": "275.40" + } + ], + "sum": "275.40" + }, + "tax": "275.40", + "total_with_tax": "1895.40", + "payable": "1895.40" + } + } +} \ No newline at end of file diff --git a/regimes/lu/lu.go b/regimes/lu/lu.go new file mode 100644 index 000000000..6f00753e9 --- /dev/null +++ b/regimes/lu/lu.go @@ -0,0 +1,79 @@ +// Package lu provides the Luxembourg tax regime. +package lu + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/norm" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" +) + +// CountryCode is the tax country code for Luxembourg. +const CountryCode = "LU" + +func init() { + tax.RegisterRegimeDef(New()) + rules.Register("lu", rules.GOBL.Add(CountryCode), taxIdentityRules()) + norm.Register( + norm.When(tax.IdentityIn(CountryCode), norm.For(func(id *tax.Identity) { tax.NormalizeIdentity(id) })), + ) +} + +// New provides the tax region definition +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: CountryCode, + Currency: currency.EUR, + TaxScheme: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "Luxembourg", + i18n.FR: "Luxembourg", + i18n.DE: "Luxemburg", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Luxembourg's tax system is administered by the Administration de + l'enregistrement, des domaines et de la TVA (AED). As an EU member + state, Luxembourg follows the EU VAT Directive and applies the + lowest standard VAT rate in the Union. + + VAT (taxe sur la valeur ajoutée, TVA) applies at a standard rate of + 17%, an intermediate rate of 14%, a reduced rate of 8%, and a + super-reduced rate of 3%. A temporary one-point reduction applied + to the standard, intermediate, and reduced rates during 2023. + + Businesses are identified by their n° TVA (VAT identification + number) in the format LU followed by 8 digits, of which the last + two are check digits. Luxembourg supports credit notes for invoice + corrections. B2G e-invoicing via PEPPOL is supported. + `), + }, + Sources: []*cbc.Source{ + { + Title: i18n.NewString("AED - Value Added Tax (TVA)"), + URL: "https://pfi.public.lu/fr/professionnel/tva.html", + }, + { + Title: i18n.NewString("Guichet.lu - VAT rates"), + URL: "https://guichet.public.lu/en/entreprises/fiscalite/tva/assujettissement/taux-tva.html", + }, + }, + TimeZone: "Europe/Luxembourg", + Scenarios: []*tax.ScenarioSet{ + bill.InvoiceScenarios(), + }, + Categories: taxCategories, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + }, + }, + }, + } +} diff --git a/regimes/lu/tax_categories.go b/regimes/lu/tax_categories.go new file mode 100644 index 000000000..8a6bed0bc --- /dev/null +++ b/regimes/lu/tax_categories.go @@ -0,0 +1,124 @@ +package lu + +import ( + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" +) + +var taxCategories = []*tax.CategoryDef{ + // + // VAT + // + { + Code: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "VAT", + i18n.FR: "TVA", + i18n.DE: "MwSt.", + }, + Title: i18n.String{ + i18n.EN: "Value Added Tax", + i18n.FR: "Taxe sur la valeur ajoutée", + i18n.DE: "Mehrwertsteuer", + }, + Sources: []*cbc.Source{ + { + Title: i18n.String{ + i18n.EN: "Guichet.lu - VAT rates", + }, + URL: "https://guichet.public.lu/en/entreprises/fiscalite/tva/assujettissement/taux-tva.html", + }, + }, + Retained: false, + Keys: tax.GlobalVATKeys(), + Rates: []*tax.RateDef{ + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateGeneral, + Name: i18n.String{ + i18n.EN: "General Rate", + i18n.FR: "Taux normal", + i18n.DE: "Normalsatz", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2024, 1, 1), + Percent: num.MakePercentage(170, 3), + }, + { + Since: cal.NewDate(2023, 1, 1), + Percent: num.MakePercentage(160, 3), + }, + { + Since: cal.NewDate(2015, 1, 1), + Percent: num.MakePercentage(170, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateIntermediate, + Name: i18n.String{ + i18n.EN: "Intermediate Rate", + i18n.FR: "Taux intermédiaire", + i18n.DE: "Mittlerer Satz", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2024, 1, 1), + Percent: num.MakePercentage(140, 3), + }, + { + Since: cal.NewDate(2023, 1, 1), + Percent: num.MakePercentage(130, 3), + }, + { + Since: cal.NewDate(2015, 1, 1), + Percent: num.MakePercentage(140, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateReduced, + Name: i18n.String{ + i18n.EN: "Reduced Rate", + i18n.FR: "Taux réduit", + i18n.DE: "Ermäßigter Satz", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2024, 1, 1), + Percent: num.MakePercentage(80, 3), + }, + { + Since: cal.NewDate(2023, 1, 1), + Percent: num.MakePercentage(70, 3), + }, + { + Since: cal.NewDate(2015, 1, 1), + Percent: num.MakePercentage(80, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateSuperReduced, + Name: i18n.String{ + i18n.EN: "Super-Reduced Rate", + i18n.FR: "Taux super-réduit", + i18n.DE: "Stark ermäßigter Satz", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2015, 1, 1), + Percent: num.MakePercentage(30, 3), + }, + }, + }, + }, + }, +} diff --git a/regimes/lu/tax_identity.go b/regimes/lu/tax_identity.go new file mode 100644 index 000000000..c24d52c31 --- /dev/null +++ b/regimes/lu/tax_identity.go @@ -0,0 +1,65 @@ +package lu + +import ( + "errors" + "regexp" + "strconv" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// Source: https://arthurdejong.org/python-stdnum/doc/1.20/stdnum.lu.tva +// Check digits: first 6 digits modulo 89 must equal the last 2 digits. + +var ( + taxCodeRegexp = regexp.MustCompile(`^\d{8}$`) +) + +func taxIdentityRules() *rules.Set { + return rules.For(new(tax.Identity), + rules.When(tax.IdentityIn("LU"), + rules.Field("code", + rules.AssertIfPresent("01", "invalid Luxembourgish VAT identity code", + is.Func("valid", isValidTaxIdentityCode), + ), + ), + ), + ) +} + +func isValidTaxIdentityCode(value any) bool { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return false + } + return validateTaxCode(code) == nil +} + +func validateTaxCode(code cbc.Code) error { + if code == "" { + return nil + } + val := code.String() + if !taxCodeRegexp.MatchString(val) { + return errors.New("invalid format") + } + return checksum(val) +} + +func checksum(val string) error { + base, err := strconv.Atoi(val[:6]) + if err != nil { + return errors.New("invalid format") + } + check, err := strconv.Atoi(val[6:]) + if err != nil { + return errors.New("invalid format") + } + if base%89 != check { + return errors.New("checksum mismatch") + } + return nil +} diff --git a/regimes/lu/tax_identity_test.go b/regimes/lu/tax_identity_test.go new file mode 100644 index 000000000..f07675de4 --- /dev/null +++ b/regimes/lu/tax_identity_test.go @@ -0,0 +1,62 @@ +package lu_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + _ "github.com/invopop/gobl/regimes/lu" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestTaxIdentityRules(t *testing.T) { + tests := []struct { + name string + code cbc.Code + err string + }{ + {name: "good 1", code: "15027442"}, + {name: "good 2", code: "13669580"}, + {name: "good 3", code: "10000053"}, + { + name: "bad checksum", + code: "15027443", + err: "IDENTITY-01", + }, + { + name: "too short", + code: "1502744", + err: "IDENTITY-01", + }, + { + name: "too long", + code: "150274421", + err: "IDENTITY-01", + }, + { + name: "not normalized", + code: "150-274.42", + err: "IDENTITY-01", + }, + { + name: "letters", + code: "1502744A", + err: "IDENTITY-01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "LU", Code: tt.code} + err := rules.Validate(tID) + if tt.err == "" { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.err) + } + } + }) + } +} diff --git a/regimes/regimes.go b/regimes/regimes.go index a6b6d7203..bc0b50677 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -23,6 +23,7 @@ import ( _ "github.com/invopop/gobl/regimes/ie" _ "github.com/invopop/gobl/regimes/in" _ "github.com/invopop/gobl/regimes/it" + _ "github.com/invopop/gobl/regimes/lu" _ "github.com/invopop/gobl/regimes/mx" _ "github.com/invopop/gobl/regimes/nl" _ "github.com/invopop/gobl/regimes/no" From 2ead7a74aa76c00de6cbb86a5fb76a645432cab8 Mon Sep 17 00:00:00 2001 From: Enrique Date: Tue, 4 Aug 2026 13:53:37 +0200 Subject: [PATCH 2/2] Sync generated LU regime data after go generate. Include the missing rules file, regime-code schema entry, and corrected AED source URL so generate output stays committed. Co-authored-by: Cursor --- data/regimes/lu.json | 2 +- data/rules/lu.json | 32 +++++++++++++++++++++++++++++++ data/schemas/tax/regime-code.json | 4 ++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 data/rules/lu.json diff --git a/data/regimes/lu.json b/data/regimes/lu.json index 0d74a5c99..4eced00a1 100644 --- a/data/regimes/lu.json +++ b/data/regimes/lu.json @@ -13,7 +13,7 @@ "title": { "en": "AED - Value Added Tax (TVA)" }, - "url": "https://pfi.public.lu/en/professionnels/tva.html" + "url": "https://pfi.public.lu/fr/professionnel/tva.html" }, { "title": { diff --git a/data/rules/lu.json b/data/rules/lu.json new file mode 100644 index 000000000..53a79ca52 --- /dev/null +++ b/data/rules/lu.json @@ -0,0 +1,32 @@ +{ + "id": "GOBL-LU", + "package": "lu", + "subsets": [ + { + "id": "GOBL-LU-TAX-IDENTITY", + "object": "tax.Identity", + "subsets": [ + { + "guard": "code in [LU]", + "subsets": [ + { + "field": "code", + "subsets": [ + { + "guard": "present", + "assert": [ + { + "id": "GOBL-LU-TAX-IDENTITY-01", + "desc": "invalid Luxembourgish VAT identity code", + "tests": "valid" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/data/schemas/tax/regime-code.json b/data/schemas/tax/regime-code.json index 585a84591..c46e867a4 100644 --- a/data/schemas/tax/regime-code.json +++ b/data/schemas/tax/regime-code.json @@ -77,6 +77,10 @@ "const": "IT", "title": "Italy" }, + { + "const": "LU", + "title": "Luxembourg" + }, { "const": "MX", "title": "Mexico"