diff --git a/data/regimes/lu.json b/data/regimes/lu.json new file mode 100644 index 000000000..4eced00a1 --- /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/fr/professionnel/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/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" 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"