diff --git a/CHANGELOG.md b/CHANGELOG.md index 347674131..469d49785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- `uy`: New tax regime for Uruguay with IVA (22% standard, 10% reduced) and RUT validation. + ## [v0.401.0] - 2026-04-17 ### Changed diff --git a/data/regimes/uy.json b/data/regimes/uy.json new file mode 100644 index 000000000..b8448b91d --- /dev/null +++ b/data/regimes/uy.json @@ -0,0 +1,150 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "en": "Uruguay", + "es": "Uruguay" + }, + "description": { + "en": "Uruguay's tax system is administered by the DGI (Dirección General\nImpositiva). The primary indirect tax is the IVA (Impuesto al Valor\nAgregado), which applies at a standard rate and a reduced rate known\nas the \"tasa mínima\".\n\nTaxpayers are identified by their RUT (Registro Único Tributario),\na 12-digit number that includes a check digit calculated using a\nmodulo 11 algorithm. The RUT serves as both the general taxpayer\nidentification and the IVA registration number.\n\nExports are zero-rated. Certain goods and services are exempt from\nIVA, including some financial services and agricultural products.\n\nElectronic invoicing (Comprobantes Fiscales Electrónicos, CFE) is\nmandatory for all IVA taxpayers, administered through the DGI.\nBoth credit notes and debit notes are supported for invoice\ncorrections." + }, + "sources": [ + { + "title": { + "en": "DGI - RUT numbering" + }, + "url": "https://www.gub.uy/direccion-general-impositiva/comunicacion/noticias/nueva-numeracion-del-rut" + }, + { + "title": { + "en": "OECD - Tax Identification Numbers: Uruguay" + }, + "url": "https://www.oecd.org/content/dam/oecd/en/topics/policy-issue-focus/aeoi/uruguay-tin.pdf" + } + ], + "time_zone": "America/Montevideo", + "country": "UY", + "currency": "UYU", + "tax_scheme": "VAT", + "corrections": [ + { + "schema": "bill/invoice", + "types": [ + "credit-note", + "debit-note" + ] + } + ], + "categories": [ + { + "code": "VAT", + "name": { + "en": "VAT", + "es": "IVA" + }, + "title": { + "en": "Value Added Tax", + "es": "Impuesto al Valor Agregado" + }, + "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": { + "en": "Standard Rate", + "es": "Tasa Básica" + }, + "desc": { + "en": "Applies to the majority of goods and services.", + "es": "Se aplica a la mayoría de bienes y servicios." + }, + "values": [ + { + "since": "2007-07-01", + "percent": "22.0%" + } + ] + }, + { + "rate": "reduced", + "keys": [ + "standard" + ], + "name": { + "en": "Reduced Rate", + "es": "Tasa Mínima" + }, + "desc": { + "en": "Applies to basic necessities including food, medicine, hotel services, passenger transport, and health services.", + "es": "Se aplica a artículos de primera necesidad como alimentos, medicamentos, servicios de hotelería, transporte de pasajeros y servicios de salud." + }, + "values": [ + { + "since": "2007-07-01", + "percent": "10.0%" + } + ] + } + ], + "sources": [ + { + "title": { + "en": "IVA - Título 10, Texto Ordenado 2023 (Art. 18: rates)", + "es": "IVA - Título 10, Texto Ordenado 2023 (Art. 18: tasas)" + }, + "url": "https://www.impo.com.uy/bases/todgi-2023/10-2024/10" + } + ] + } + ] +} \ No newline at end of file diff --git a/data/rules/uy.json b/data/rules/uy.json new file mode 100644 index 000000000..4c0ae6663 --- /dev/null +++ b/data/rules/uy.json @@ -0,0 +1,32 @@ +{ + "id": "GOBL-UY", + "package": "uy", + "subsets": [ + { + "id": "GOBL-UY-TAX-IDENTITY", + "object": "tax.Identity", + "subsets": [ + { + "guard": "code in [UY]", + "subsets": [ + { + "field": "code", + "subsets": [ + { + "guard": "present", + "assert": [ + { + "id": "GOBL-UY-TAX-IDENTITY-01", + "desc": "invalid Uruguay RUT identity code", + "tests": "valid" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/data/schemas/tax/regime-code.json b/data/schemas/tax/regime-code.json index a8fdd8b8d..1846ebc82 100644 --- a/data/schemas/tax/regime-code.json +++ b/data/schemas/tax/regime-code.json @@ -100,6 +100,10 @@ { "const": "US", "title": "United States of America" + }, + { + "const": "UY", + "title": "Uruguay" } ], "type": "string", diff --git a/examples/uy/invoice-standard.json b/examples/uy/invoice-standard.json new file mode 100644 index 000000000..67654ec3e --- /dev/null +++ b/examples/uy/invoice-standard.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "UY", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "code": "A-00001234", + "issue_date": "2025-03-15", + "currency": "UYU", + "supplier": { + "name": "Proveedor Ejemplo S.A.", + "tax_id": { + "country": "UY", + "code": "211003420017" + }, + "addresses": [ + { + "street": "Av. 18 de Julio 1234", + "locality": "Montevideo", + "region": "Montevideo", + "code": "11200", + "country": "UY" + } + ] + }, + "customer": { + "name": "Cliente Comercial S.R.L.", + "tax_id": { + "country": "UY", + "code": "216893210012" + }, + "addresses": [ + { + "street": "Rambla República del Perú 500", + "locality": "Montevideo", + "region": "Montevideo", + "code": "11300", + "country": "UY" + } + ] + }, + "lines": [ + { + "quantity": "10", + "item": { + "name": "Servicios de consultoría", + "price": "5000.00" + }, + "taxes": [ + { + "cat": "VAT", + "rate": "general" + } + ] + }, + { + "quantity": "20", + "item": { + "name": "Productos alimenticios", + "price": "200.00" + }, + "taxes": [ + { + "cat": "VAT", + "rate": "reduced" + } + ] + } + ], + "payment": { + "terms": { + "due_dates": [ + { + "date": "2025-04-15", + "percent": "100%" + } + ] + } + } +} diff --git a/examples/uy/out/invoice-standard.json b/examples/uy/out/invoice-standard.json new file mode 100644 index 000000000..aa35fc69b --- /dev/null +++ b/examples/uy/out/invoice-standard.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "950c6fa8f95dd7713bb5a7c8cda33eeaeea81c4bc69be44886cbecc03416e3ba" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "UY", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "A-00001234", + "issue_date": "2025-03-15", + "currency": "UYU", + "supplier": { + "name": "Proveedor Ejemplo S.A.", + "tax_id": { + "country": "UY", + "code": "211003420017" + }, + "addresses": [ + { + "street": "Av. 18 de Julio 1234", + "locality": "Montevideo", + "region": "Montevideo", + "code": "11200", + "country": "UY" + } + ] + }, + "customer": { + "name": "Cliente Comercial S.R.L.", + "tax_id": { + "country": "UY", + "code": "216893210012" + }, + "addresses": [ + { + "street": "Rambla República del Perú 500", + "locality": "Montevideo", + "region": "Montevideo", + "code": "11300", + "country": "UY" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Servicios de consultoría", + "price": "5000.00" + }, + "sum": "50000.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "22.0%" + } + ], + "total": "50000.00" + }, + { + "i": 2, + "quantity": "20", + "item": { + "name": "Productos alimenticios", + "price": "200.00" + }, + "sum": "4000.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "reduced", + "percent": "10.0%" + } + ], + "total": "4000.00" + } + ], + "payment": { + "terms": { + "due_dates": [ + { + "date": "2025-04-15", + "amount": "65400.00", + "percent": "100%" + } + ] + } + }, + "totals": { + "sum": "54000.00", + "total": "54000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "50000.00", + "percent": "22.0%", + "amount": "11000.00" + }, + { + "key": "standard", + "base": "4000.00", + "percent": "10.0%", + "amount": "400.00" + } + ], + "amount": "11400.00" + } + ], + "sum": "11400.00" + }, + "tax": "11400.00", + "total_with_tax": "65400.00", + "payable": "65400.00" + } + } +} \ No newline at end of file diff --git a/regimes/regimes.go b/regimes/regimes.go index ff3cb73a8..c006e5da5 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -29,4 +29,5 @@ import ( _ "github.com/invopop/gobl/regimes/se" _ "github.com/invopop/gobl/regimes/sg" _ "github.com/invopop/gobl/regimes/us" + _ "github.com/invopop/gobl/regimes/uy" ) diff --git a/regimes/uy/tax_categories.go b/regimes/uy/tax_categories.go new file mode 100644 index 000000000..7b9dd303c --- /dev/null +++ b/regimes/uy/tax_categories.go @@ -0,0 +1,82 @@ +package uy + +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" +) + +// IVA rates and tax reform references: +// - Law 18.083 (Tax Reform, effective 2007-07-01): established the current 22% standard +// and 10% reduced ("tasa mínima") rates by modifying Article 18 of Título 10 +// (Texto Ordenado 1996). +// - Law text: https://www.impo.com.uy/bases/leyes/18083-2006 +// - Consolidated rates (Art. 18, Título 10): https://www.impo.com.uy/bases/todgi-2023/10-2024/10 + +var taxCategories = []*tax.CategoryDef{ + // + // VAT (IVA) + // + { + Code: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "VAT", + i18n.ES: "IVA", + }, + Title: i18n.String{ + i18n.EN: "Value Added Tax", + i18n.ES: "Impuesto al Valor Agregado", + }, + Sources: []*cbc.Source{ + { + Title: i18n.String{ + i18n.EN: "IVA - Título 10, Texto Ordenado 2023 (Art. 18: rates)", + i18n.ES: "IVA - Título 10, Texto Ordenado 2023 (Art. 18: tasas)", + }, + URL: "https://www.impo.com.uy/bases/todgi-2023/10-2024/10", + }, + }, + Retained: false, + Keys: tax.GlobalVATKeys(), + Rates: []*tax.RateDef{ + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateGeneral, + Name: i18n.String{ + i18n.EN: "Standard Rate", + i18n.ES: "Tasa Básica", + }, + Description: i18n.String{ + i18n.EN: "Applies to the majority of goods and services.", + i18n.ES: "Se aplica a la mayoría de bienes y servicios.", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2007, 7, 1), + Percent: num.MakePercentage(220, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateReduced, + Name: i18n.String{ + i18n.EN: "Reduced Rate", + i18n.ES: "Tasa Mínima", + }, + Description: i18n.String{ + i18n.EN: "Applies to basic necessities including food, medicine, hotel services, passenger transport, and health services.", + i18n.ES: "Se aplica a artículos de primera necesidad como alimentos, medicamentos, servicios de hotelería, transporte de pasajeros y servicios de salud.", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2007, 7, 1), + Percent: num.MakePercentage(100, 3), + }, + }, + }, + }, + }, +} diff --git a/regimes/uy/tax_identity.go b/regimes/uy/tax_identity.go new file mode 100644 index 000000000..f3b7043e5 --- /dev/null +++ b/regimes/uy/tax_identity.go @@ -0,0 +1,127 @@ +package uy + +import ( + "errors" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// RUT (Registro Único Tributario) is a 12-digit tax identification number +// used in Uruguay for all taxpayers. +// +// Format: XX-XXXXXX-001-X (typically displayed with hyphens, but stored without) +// +// - Positions 1-2: Registration type (01-22) +// - Positions 3-8: Sequence number (cannot be all zeros) +// - Positions 9-11: Fixed value "001" (always represents the main taxpayer; +// branch/sucursal identification is handled separately via the CdgDGISucur +// field in the CFE XML, not within the RUT itself) +// - Position 12: Check digit +// +// The check digit is calculated using a modulo 11 algorithm with the +// following weights applied to the first 11 digits: +// +// Weights: [4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] +// +// Calculation: +// 1. Multiply each of the first 11 digits by its corresponding weight +// 2. Sum the products +// 3. Check digit = (-sum) mod 11 +// 4. If the check digit is 10 or 11, the RUT is invalid +// +// References: +// - https://arthurdejong.org/python-stdnum/doc/1.20/stdnum.uy.rut +// - https://github.com/alfius/uy-rut +// - https://www.oecd.org/content/dam/oecd/en/topics/policy-issue-focus/aeoi/uruguay-tin.pdf +// - https://www.gub.uy/direccion-general-impositiva/comunicacion/noticias/nueva-numeracion-del-rut + +var rutWeights = []int{4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + +// normalizeTaxIdentity removes whitespace, hyphens, and the "UY" prefix +// from the tax identity code. +func normalizeTaxIdentity(tID *tax.Identity) { + if tID == nil { + return + } + tax.NormalizeIdentity(tID) +} + +func taxIdentityRules() *rules.Set { + return rules.For(new(tax.Identity), + rules.When(tax.IdentityIn("UY"), + rules.Field("code", + rules.AssertIfPresent("01", "invalid Uruguay RUT 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 { + val := code.String() + + // RUT must be exactly 12 digits + if len(val) != 12 { + return errors.New("must have 12 digits") + } + + // Verify all characters are digits + for _, c := range val { + if c < '0' || c > '9' { + return errors.New("must contain only digits") + } + } + + // Validate registration type (first two digits must be 01-22) + prefix := (int(val[0]-'0') * 10) + int(val[1]-'0') + if prefix < 1 || prefix > 22 { + return errors.New("invalid registration type") + } + + // Sequence number (positions 3-8) cannot be all zeros + if val[2:8] == "000000" { + return errors.New("invalid sequence number") + } + + // Positions 9-11 must be "001" + if val[8:11] != "001" { + return errors.New("invalid fixed field") + } + + // Validate check digit using modulo 11 algorithm + return validateCheckDigit(val) +} + +func validateCheckDigit(val string) error { + sum := 0 + for i := 0; i < 11; i++ { + sum += int(val[i]-'0') * rutWeights[i] + } + + // The check digit is (-sum) mod 11. + // In Go, the modulo of a negative number can be negative, so we + // normalize it: ((11 - (sum % 11)) % 11). + check := (11 - (sum % 11)) % 11 + if check >= 10 { + return errors.New("invalid check digit") + } + + actual := int(val[11] - '0') + if actual != check { + return errors.New("checksum mismatch") + } + + return nil +} diff --git a/regimes/uy/tax_identity_test.go b/regimes/uy/tax_identity_test.go new file mode 100644 index 000000000..c711c4ca6 --- /dev/null +++ b/regimes/uy/tax_identity_test.go @@ -0,0 +1,116 @@ +package uy_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/uy" + "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 + }{ + // Valid RUT numbers (from python-stdnum test suite) + {name: "valid 1", code: "211003420017"}, + {name: "valid 2", code: "020164180014"}, + {name: "valid 3", code: "020334150013"}, + {name: "valid 4", code: "040003080012"}, + {name: "valid 5", code: "040005970015"}, + {name: "valid 6", code: "211004160019"}, + {name: "valid 7", code: "211049510019"}, + {name: "valid 8", code: "211073320011"}, + {name: "valid 9", code: "216893210012"}, + {name: "valid 10", code: "217055850011"}, + {name: "valid 11", code: "220018800014"}, + + // Empty code passes (not required by the rule set). + {name: "empty code", code: ""}, + + // Invalid - wrong length + {name: "too short", code: "21100342001", err: "IDENTITY-01"}, + {name: "too long", code: "2142184200106", err: "IDENTITY-01"}, + + // Invalid - non-numeric characters + {name: "contains letters", code: "FF1599340019", err: "IDENTITY-01"}, + + // Invalid - registration type out of range + {name: "prefix 00", code: "001599340019", err: "IDENTITY-01"}, + {name: "prefix too high", code: "991599340011", err: "IDENTITY-01"}, + + // Invalid - sequence number all zeros + {name: "zero sequence number", code: "210000000019", err: "IDENTITY-01"}, + + // Invalid - fixed field not 001 + {name: "invalid fixed field", code: "211599345519", err: "IDENTITY-01"}, + + // Invalid - wrong check digit + {name: "checksum mismatch", code: "211599340010", err: "IDENTITY-01"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "UY", 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) + } + } + }) + } +} + +func TestNormalizeTaxIdentity(t *testing.T) { + var tID *tax.Identity + assert.NotPanics(t, func() { + uy.Normalize(tID) + }) + + tests := []struct { + name string + code cbc.Code + want cbc.Code + }{ + { + name: "already normalized", + code: "211003420017", + want: "211003420017", + }, + { + name: "with hyphens", + code: "21-100342-001-7", + want: "211003420017", + }, + { + name: "with spaces", + code: "21 100342 001 7", + want: "211003420017", + }, + { + name: "with UY prefix", + code: "UY211003420017", + want: "211003420017", + }, + { + name: "with UY prefix and spaces", + code: "UY 21 140634 001 1", + want: "211406340011", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "UY", Code: tt.code} + uy.Normalize(tID) + assert.Equal(t, tt.want, tID.Code) + }) + } +} diff --git a/regimes/uy/uy.go b/regimes/uy/uy.go new file mode 100644 index 000000000..f4006697d --- /dev/null +++ b/regimes/uy/uy.go @@ -0,0 +1,81 @@ +// Package uy provides the tax regime definition for Uruguay. +package uy + +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/pkg/here" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterRegimeDef(New()) + rules.Register("uy", rules.GOBL.Add("UY"), taxIdentityRules()) +} + +// New provides the tax region definition for Uruguay. +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: "UY", + Currency: currency.UYU, + TaxScheme: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "Uruguay", + i18n.ES: "Uruguay", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Uruguay's tax system is administered by the DGI (Dirección General + Impositiva). The primary indirect tax is the IVA (Impuesto al Valor + Agregado), which applies at a standard rate and a reduced rate known + as the "tasa mínima". + + Taxpayers are identified by their RUT (Registro Único Tributario), + a 12-digit number that includes a check digit calculated using a + modulo 11 algorithm. The RUT serves as both the general taxpayer + identification and the IVA registration number. + + Exports are zero-rated. Certain goods and services are exempt from + IVA, including some financial services and agricultural products. + + Electronic invoicing (Comprobantes Fiscales Electrónicos, CFE) is + mandatory for all IVA taxpayers, administered through the DGI. + Both credit notes and debit notes are supported for invoice + corrections. + `), + }, + Sources: []*cbc.Source{ + { + Title: i18n.NewString("DGI - RUT numbering"), + URL: "https://www.gub.uy/direccion-general-impositiva/comunicacion/noticias/nueva-numeracion-del-rut", + }, + { + Title: i18n.NewString("OECD - Tax Identification Numbers: Uruguay"), + URL: "https://www.oecd.org/content/dam/oecd/en/topics/policy-issue-focus/aeoi/uruguay-tin.pdf", + }, + }, + TimeZone: "America/Montevideo", + Normalizer: Normalize, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + bill.InvoiceTypeDebitNote, + }, + }, + }, + Categories: taxCategories, + } +} + +// Normalize will attempt to clean the object passed to it. +func Normalize(doc any) { + switch obj := doc.(type) { + case *tax.Identity: + normalizeTaxIdentity(obj) + } +}