diff --git a/CHANGELOG.md b/CHANGELOG.md index b8bb68328..973dbdcac 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 + +- `cz`: Added the Czech Republic DPH (VAT) regime with 21% standard, 12% reduced (2024 consolidation), and historical rates back to 2004. DIČ validation with three format types and IČO business registration identity. + ## [v0.400.0] - 2026-14-15 Final release of the rules based changes. diff --git a/data/regimes/cz.json b/data/regimes/cz.json new file mode 100644 index 000000000..53cc6ae49 --- /dev/null +++ b/data/regimes/cz.json @@ -0,0 +1,186 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "cs": "Česká republika", + "en": "Czech Republic" + }, + "description": { + "en": "The Czech Republic's tax system is administered by the Financial\nAdministration of the Czech Republic (Finanční správa ČR). As an EU member\nstate, the Czech Republic follows the EU VAT Directive.\n\nVAT (DPH — Daň z přidané hodnoty) applies at a standard rate of 21% and a\nsingle reduced rate of 12% (since January 2024, when the previous first\nreduced rate of 15% and second reduced rate of 10% were merged). Certain\nsupplies are zero-rated (e.g. exports) or exempt (e.g. healthcare,\neducation, financial services).\n\nBusinesses are identified by their DIČ (Daňové identifikační číslo), which\nconsists of the prefix CZ followed by 8 to 10 digits. For legal entities\nthe DIČ is 8 digits with a modulo-11 checksum; for individuals it is\nderived from the birth number (Rodné číslo) and is 9 or 10 digits." + }, + "time_zone": "Europe/Prague", + "country": "CZ", + "currency": "CZK", + "tax_scheme": "VAT", + "identities": [ + { + "key": "cz-ico", + "name": { + "cs": "Identifikační číslo osoby", + "en": "Business Registration Number" + } + } + ], + "corrections": [ + { + "schema": "bill/invoice", + "types": [ + "credit-note", + "debit-note" + ] + } + ], + "categories": [ + { + "code": "VAT", + "name": { + "cs": "DPH", + "en": "VAT" + }, + "title": { + "cs": "Daň z přidané hodnoty", + "en": "Value Added Tax" + }, + "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": { + "cs": "Základní sazba", + "en": "Standard Rate" + }, + "values": [ + { + "since": "2013-01-01", + "percent": "21.0%" + }, + { + "since": "2010-01-01", + "percent": "20.0%" + }, + { + "since": "2004-05-01", + "percent": "19.0%" + } + ] + }, + { + "rate": "reduced", + "keys": [ + "standard" + ], + "name": { + "cs": "Snížená sazba", + "en": "Reduced Rate" + }, + "values": [ + { + "since": "2024-01-01", + "percent": "12.0%" + }, + { + "since": "2013-01-01", + "percent": "15.0%" + }, + { + "since": "2012-01-01", + "percent": "14.0%" + }, + { + "since": "2010-01-01", + "percent": "10.0%" + }, + { + "since": "2008-01-01", + "percent": "9.0%" + }, + { + "since": "2004-05-01", + "percent": "5.0%" + } + ] + }, + { + "rate": "super-reduced", + "keys": [ + "standard" + ], + "name": { + "cs": "Druhá snížená sazba", + "en": "Second Reduced Rate" + }, + "values": [ + { + "since": "2015-01-01", + "percent": "10.0%" + } + ] + } + ], + "sources": [ + { + "title": { + "en": "Czech Republic - General rules and VAT rates" + }, + "url": "https://portal.gov.cz/en/informace/general-rules-and-vat-rates-INF-205" + }, + { + "title": { + "en": "Registering for VAT in the Czech Republic" + }, + "url": "https://portal.gov.cz/en/informace/registering-for-vat-INF-204" + } + ] + } + ] +} \ No newline at end of file diff --git a/data/schemas/tax/regime-code.json b/data/schemas/tax/regime-code.json index a8fdd8b8d..c5852494d 100644 --- a/data/schemas/tax/regime-code.json +++ b/data/schemas/tax/regime-code.json @@ -37,6 +37,10 @@ "const": "CO", "title": "Colombia" }, + { + "const": "CZ", + "title": "Czech Republic" + }, { "const": "DE", "title": "Germany" diff --git a/examples/cz/invoice-cz-cz.yaml b/examples/cz/invoice-cz-cz.yaml new file mode 100644 index 000000000..c671b2a72 --- /dev/null +++ b/examples/cz/invoice-cz-cz.yaml @@ -0,0 +1,45 @@ +$schema: https://gobl.org/draft-0/bill/invoice +$regime: CZ +uuid: 8c042838-29e4-4f96-b202-15f0e1c3e224 +currency: CZK +series: "2025" +code: "001" +issue_date: "2025-06-15" + +supplier: + name: "Příklad s.r.o." + tax_id: + country: CZ + code: "00177041" + addresses: + - street: "Vodičkova 791/41" + locality: "Praha" + code: "110 00" + country: CZ + +customer: + name: "Zákazník a.s." + tax_id: + country: CZ + code: "45274649" + addresses: + - street: "Duhová 1444/2" + locality: "Praha" + code: "140 53" + country: CZ + +lines: + - quantity: "10" + item: + name: "Softwarové poradenství" + price: "1500.00" + taxes: + - cat: VAT + rate: standard + - quantity: "5" + item: + name: "Knihy" + price: "250.00" + taxes: + - cat: VAT + rate: reduced diff --git a/examples/cz/out/invoice-cz-cz.json b/examples/cz/out/invoice-cz-cz.json new file mode 100644 index 000000000..da9ba8c6b --- /dev/null +++ b/examples/cz/out/invoice-cz-cz.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "67b484fdbb62747f84f1dd1d252428939a2cf0b401aa497a07180a42221e9436" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "CZ", + "uuid": "8c042838-29e4-4f96-b202-15f0e1c3e224", + "type": "standard", + "series": "2025", + "code": "001", + "issue_date": "2025-06-15", + "currency": "CZK", + "supplier": { + "name": "Příklad s.r.o.", + "tax_id": { + "country": "CZ", + "code": "00177041" + }, + "addresses": [ + { + "street": "Vodičkova 791/41", + "locality": "Praha", + "code": "110 00", + "country": "CZ" + } + ] + }, + "customer": { + "name": "Zákazník a.s.", + "tax_id": { + "country": "CZ", + "code": "45274649" + }, + "addresses": [ + { + "street": "Duhová 1444/2", + "locality": "Praha", + "code": "140 53", + "country": "CZ" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Softwarové poradenství", + "price": "1500.00" + }, + "sum": "15000.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "21.0%" + } + ], + "total": "15000.00" + }, + { + "i": 2, + "quantity": "5", + "item": { + "name": "Knihy", + "price": "250.00" + }, + "sum": "1250.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "reduced", + "percent": "12.0%" + } + ], + "total": "1250.00" + } + ], + "totals": { + "sum": "16250.00", + "total": "16250.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "15000.00", + "percent": "21.0%", + "amount": "3150.00" + }, + { + "key": "standard", + "base": "1250.00", + "percent": "12.0%", + "amount": "150.00" + } + ], + "amount": "3300.00" + } + ], + "sum": "3300.00" + }, + "tax": "3300.00", + "total_with_tax": "19550.00", + "payable": "19550.00" + } + } +} \ No newline at end of file diff --git a/regimes/cz/bill_invoices.go b/regimes/cz/bill_invoices.go new file mode 100644 index 000000000..1dc54ce37 --- /dev/null +++ b/regimes/cz/bill_invoices.go @@ -0,0 +1,45 @@ +package cz + +import ( + "fmt" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// billInvoiceRules defines Czech invoice validation rules. +// Supplier must have either a DIČ (tax ID) or IČO (business registration). +func billInvoiceRules() *rules.Set { + return rules.For(new(bill.Invoice), + rules.When( + is.InContext(tax.RegimeIn(CountryCode)), + rules.Field("supplier", + rules.Assert("01", fmt.Sprintf("invoice CZ supplier must have either tax ID code or identity with '%s' key", IdentityKeyICO), + is.Func( + fmt.Sprintf("has tax ID code or identity with '%s' key", IdentityKeyICO), + hasTaxIDOrIdentity, + ), + ), + ), + ), + ) +} + +func hasTaxIDOrIdentity(value any) bool { + party, _ := value.(*org.Party) + return hasTaxIDCode(party) || hasIdentityICO(party) +} + +func hasTaxIDCode(party *org.Party) bool { + return party != nil && party.TaxID != nil && party.TaxID.Code != "" +} + +func hasIdentityICO(party *org.Party) bool { + if party == nil || len(party.Identities) == 0 { + return false + } + return org.IdentityForKey(party.Identities, IdentityKeyICO) != nil +} diff --git a/regimes/cz/bill_invoices_test.go b/regimes/cz/bill_invoices_test.go new file mode 100644 index 000000000..c42d1bd36 --- /dev/null +++ b/regimes/cz/bill_invoices_test.go @@ -0,0 +1,87 @@ +package cz_test + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/cz" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func validInvoice() *bill.Invoice { + return &bill.Invoice{ + Regime: tax.WithRegime("CZ"), + Series: "TEST", + Code: "0002", + Supplier: &org.Party{ + Name: "Test Supplier", + TaxID: &tax.Identity{ + Country: "CZ", + Code: "00177041", + }, + }, + Customer: &org.Party{ + Name: "Test Customer", + TaxID: &tax.Identity{ + Country: "CZ", + Code: "45274649", + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "bogus", + Price: num.NewAmount(10000, 2), + Unit: org.UnitPackage, + }, + Taxes: tax.Set{ + { + Category: "VAT", + Rate: "standard", + }, + }, + }, + }, + } +} + +func TestInvoiceValidation(t *testing.T) { + t.Run("valid invoice with tax ID", func(t *testing.T) { + inv := validInvoice() + require.NoError(t, inv.Calculate()) + assert.NoError(t, rules.Validate(inv)) + }) + + t.Run("valid invoice with IČO instead of tax ID", func(t *testing.T) { + inv := validInvoice() + inv.Supplier.TaxID = nil + inv.Supplier.Identities = []*org.Identity{ + { + Key: cz.IdentityKeyICO, + Code: "00177041", + }, + } + require.NoError(t, inv.Calculate()) + assert.NoError(t, rules.Validate(inv)) + }) + + t.Run("missing both tax ID and IČO", func(t *testing.T) { + inv := validInvoice() + inv.Supplier.TaxID = nil + require.NoError(t, inv.Calculate()) + assert.ErrorContains(t, rules.Validate(inv), "[GOBL-CZ-BILL-INVOICE-01]") + }) + + t.Run("invalid supplier tax ID code", func(t *testing.T) { + inv := validInvoice() + inv.Supplier.TaxID.Code = "00177042" + require.NoError(t, inv.Calculate()) + assert.ErrorContains(t, rules.Validate(inv), "[GOBL-CZ-TAX-IDENTITY-01]") + }) +} diff --git a/regimes/cz/cz.go b/regimes/cz/cz.go new file mode 100644 index 000000000..b8ef13b3a --- /dev/null +++ b/regimes/cz/cz.go @@ -0,0 +1,79 @@ +// Package cz provides a regime definition for the Czech Republic. +package cz + +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/org" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" +) + +// CountryCode is the ISO 3166-1 alpha-2 code for the Czech Republic. +const CountryCode = "CZ" + +func init() { + tax.RegisterRegimeDef(New()) + rules.Register("cz", rules.GOBL.Add(CountryCode), + billInvoiceRules(), + orgIdentityRules(), + taxIdentityRules(), + ) +} + +// New instantiates a new Czech Republic regime. +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: CountryCode, + Currency: currency.CZK, + TaxScheme: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "Czech Republic", + i18n.CS: "Česká republika", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + The Czech Republic's tax system is administered by the Financial + Administration of the Czech Republic (Finanční správa ČR). As an EU member + state, the Czech Republic follows the EU VAT Directive. + + VAT (DPH — Daň z přidané hodnoty) applies at a standard rate of 21% and a + single reduced rate of 12% (since January 2024, when the previous first + reduced rate of 15% and second reduced rate of 10% were merged). Certain + supplies are zero-rated (e.g. exports) or exempt (e.g. healthcare, + education, financial services). + + Businesses are identified by their DIČ (Daňové identifikační číslo), which + consists of the prefix CZ followed by 8 to 10 digits. For legal entities + the DIČ is 8 digits with a modulo-11 checksum; for individuals it is + derived from the birth number (Rodné číslo) and is 9 or 10 digits. + `), + }, + TimeZone: "Europe/Prague", + Identities: identityDefinitions, + Categories: taxCategories, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + bill.InvoiceTypeDebitNote, + }, + }, + }, + Normalizer: Normalize, + } +} + +// Normalize will perform any regime specific normalization. +func Normalize(doc any) { + switch obj := doc.(type) { + case *tax.Identity: + tax.NormalizeIdentity(obj) + case *org.Identity: + normalizeIdentity(obj) + } +} diff --git a/regimes/cz/org_identities.go b/regimes/cz/org_identities.go new file mode 100644 index 000000000..33105132b --- /dev/null +++ b/regimes/cz/org_identities.go @@ -0,0 +1,61 @@ +package cz + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" +) + +const ( + // IdentityKeyICO represents the Czech business registration number + // (Identifikační číslo osoby) assigned to all businesses at formation. + // For international trade, the DIČ (VAT number) should be used via + // the TaxID field instead. + IdentityKeyICO cbc.Key = "cz-ico" +) + +var identityDefinitions = []*cbc.Definition{ + { + Key: IdentityKeyICO, + Name: i18n.String{ + i18n.EN: "Business Registration Number", + i18n.CS: "Identifikační číslo osoby", + }, + }, +} + +func orgIdentityRules() *rules.Set { + return rules.For(new(org.Identity), + rules.When( + is.Func("is IČO", isICOIdentity), + rules.Field("code", + rules.AssertIfPresent("01", "invalid Czech IČO code", + is.Func("valid", isValidICOCode), + ), + ), + ), + ) +} + +func isICOIdentity(val any) bool { + id, _ := val.(*org.Identity) + return id != nil && id.Key == IdentityKeyICO +} + +func isValidICOCode(val any) bool { + code, _ := val.(cbc.Code) + if code == "" || len(code.String()) != 8 { + return false + } + return validateLegalEntityCode(code.String()) == nil +} + +// normalizeIdentity normalizes IČO codes by stripping non-numeric characters. +func normalizeIdentity(id *org.Identity) { + if id == nil || id.Key != IdentityKeyICO { + return + } + id.Code = cbc.NormalizeNumericalCode(id.Code) +} diff --git a/regimes/cz/org_identities_test.go b/regimes/cz/org_identities_test.go new file mode 100644 index 000000000..b1c49de6d --- /dev/null +++ b/regimes/cz/org_identities_test.go @@ -0,0 +1,56 @@ +package cz_test + +import ( + "testing" + + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/cz" + "github.com/invopop/gobl/rules" + "github.com/stretchr/testify/assert" +) + +func TestOrgIdentityRules(t *testing.T) { + tests := []struct { + name string + identity *org.Identity + err string + }{ + { + name: "valid IČO - Škoda Auto", + identity: &org.Identity{Key: cz.IdentityKeyICO, Code: "00177041"}, + }, + { + name: "valid IČO - ČEZ", + identity: &org.Identity{Key: cz.IdentityKeyICO, Code: "45274649"}, + }, + { + name: "valid IČO - Komerční banka", + identity: &org.Identity{Key: cz.IdentityKeyICO, Code: "45317054"}, + }, + { + name: "invalid IČO checksum", + identity: &org.Identity{Key: cz.IdentityKeyICO, Code: "00177042"}, + err: "[GOBL-CZ-ORG-IDENTITY-01]", + }, + { + name: "invalid IČO too short", + identity: &org.Identity{Key: cz.IdentityKeyICO, Code: "0017704"}, + err: "[GOBL-CZ-ORG-IDENTITY-01]", + }, + { + name: "non-IČO identity ignored", + identity: &org.Identity{Key: "other", Code: "anything"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := rules.Validate(tt.identity) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.err) + } + }) + } +} diff --git a/regimes/cz/tax_categories.go b/regimes/cz/tax_categories.go new file mode 100644 index 000000000..9003d2423 --- /dev/null +++ b/regimes/cz/tax_categories.go @@ -0,0 +1,113 @@ +package cz + +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{ + { + Code: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "VAT", + i18n.CS: "DPH", + }, + Title: i18n.String{ + i18n.EN: "Value Added Tax", + i18n.CS: "Daň z přidané hodnoty", + }, + Keys: tax.GlobalVATKeys(), + Rates: []*tax.RateDef{ + // Standard rate + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateGeneral, + Name: i18n.String{ + i18n.EN: "Standard Rate", + i18n.CS: "Základní sazba", + }, + Values: []*tax.RateValueDef{ + { + Percent: num.MakePercentage(210, 3), + Since: cal.NewDate(2013, 1, 1), + }, + { + Percent: num.MakePercentage(200, 3), + Since: cal.NewDate(2010, 1, 1), + }, + { + Percent: num.MakePercentage(190, 3), + Since: cal.NewDate(2004, 5, 1), + }, + }, + }, + // Reduced rate (merged from two reduced rates in 2024) + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateReduced, + Name: i18n.String{ + i18n.EN: "Reduced Rate", + i18n.CS: "Snížená sazba", + }, + Values: []*tax.RateValueDef{ + { + Percent: num.MakePercentage(120, 3), + Since: cal.NewDate(2024, 1, 1), + }, + { + Percent: num.MakePercentage(150, 3), + Since: cal.NewDate(2013, 1, 1), + }, + { + Percent: num.MakePercentage(140, 3), + Since: cal.NewDate(2012, 1, 1), + }, + { + Percent: num.MakePercentage(100, 3), + Since: cal.NewDate(2010, 1, 1), + }, + { + Percent: num.MakePercentage(90, 3), + Since: cal.NewDate(2008, 1, 1), + }, + { + Percent: num.MakePercentage(50, 3), + Since: cal.NewDate(2004, 5, 1), + }, + }, + }, + // Second reduced rate (abolished 2024-01-01, merged into single 12% reduced rate) + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateSuperReduced, + Name: i18n.String{ + i18n.EN: "Second Reduced Rate", + i18n.CS: "Druhá snížená sazba", + }, + Values: []*tax.RateValueDef{ + { + Percent: num.MakePercentage(100, 3), + Since: cal.NewDate(2015, 1, 1), + }, + }, + }, + }, + Sources: []*cbc.Source{ + { + Title: i18n.String{ + i18n.EN: "Czech Republic - General rules and VAT rates", + }, + URL: "https://portal.gov.cz/en/informace/general-rules-and-vat-rates-INF-205", + }, + { + Title: i18n.String{ + i18n.EN: "Registering for VAT in the Czech Republic", + }, + URL: "https://portal.gov.cz/en/informace/registering-for-vat-INF-204", + }, + }, + }, +} diff --git a/regimes/cz/tax_identity.go b/regimes/cz/tax_identity.go new file mode 100644 index 000000000..fc78dddcb --- /dev/null +++ b/regimes/cz/tax_identity.go @@ -0,0 +1,110 @@ +package cz + +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" +) + +// Reference: https://arthurdejong.org/python-stdnum/doc/1.17/stdnum.cz.dic + +var taxCodeRegexps = []*regexp.Regexp{ + regexp.MustCompile(`^\d{8,10}$`), +} + +func taxIdentityRules() *rules.Set { + return rules.For(new(tax.Identity), + rules.When(tax.IdentityIn(CountryCode), + rules.Field("code", + rules.AssertIfPresent("01", "invalid Czech DIČ 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(value any) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + match := false + for _, re := range taxCodeRegexps { + if re.MatchString(val) { + match = true + break + } + } + if !match { + return errors.New("invalid format") + } + + switch len(val) { + case 8: + return validateLegalEntityCode(val) + case 9: + // 9-digit codes include special IDs (starting with 6) and + // older individual birth number formats; no checksum required. + return nil + case 10: + return validateIndividualCode(val) + } + + return nil +} + +// validateLegalEntityCode validates an 8-digit legal entity DIČ using +// modulo-11 checksum with weights [8,7,6,5,4,3,2]. +func validateLegalEntityCode(val string) error { + weights := []int{8, 7, 6, 5, 4, 3, 2} + total := 0 + + for i := range 7 { + total += int(val[i]-'0') * weights[i] + } + + expected := 11 - (total % 11) + if expected == 10 { + expected = 0 + } else if expected == 11 { + expected = 1 + } + + checkDigit := int(val[7] - '0') + if checkDigit != expected { + return errors.New("checksum mismatch") + } + + return nil +} + +// validateIndividualCode validates a 10-digit individual DIČ (derived from +// Rodné číslo). Must be divisible by 11. +func validateIndividualCode(val string) error { + n, err := strconv.ParseUint(val, 10, 64) + if err != nil { + return errors.New("invalid format") + } + + if n%11 != 0 { + return errors.New("checksum mismatch") + } + + return nil +} diff --git a/regimes/cz/tax_identity_test.go b/regimes/cz/tax_identity_test.go new file mode 100644 index 000000000..9808b3375 --- /dev/null +++ b/regimes/cz/tax_identity_test.go @@ -0,0 +1,97 @@ +package cz_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/cz" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestNormalizeTaxIdentity(t *testing.T) { + tests := []struct { + Code cbc.Code + Expected cbc.Code + }{ + {Code: "00177041", Expected: "00177041"}, + {Code: "CZ00177041", Expected: "00177041"}, + {Code: "001 770 41", Expected: "00177041"}, + {Code: "001-770-41", Expected: "00177041"}, + } + for _, ts := range tests { + tID := &tax.Identity{Country: "CZ", Code: ts.Code} + cz.Normalize(tID) + assert.Equal(t, ts.Expected, tID.Code) + } +} + +func TestTaxIdentityRules(t *testing.T) { + tests := []struct { + name string + code cbc.Code + err string + }{ + // Valid legal entity codes (8 digits) + {name: "valid legal entity - Škoda Auto", code: "00177041"}, + {name: "valid legal entity - ČEZ", code: "45274649"}, + {name: "valid legal entity", code: "25596641"}, + {name: "valid legal entity - T-Mobile CZ", code: "64949681"}, + {name: "valid legal entity - Komerční banka", code: "45317054"}, + // Valid individual code (10 digits, divisible by 11) + {name: "valid individual", code: "7103192745"}, + // Valid legal entity with check digit 0 (expected == 10 branch) + {name: "valid legal entity check digit 0", code: "00200000"}, + // Valid 9-digit codes + {name: "valid 9-digit special", code: "612345679"}, + {name: "valid 9-digit individual", code: "710319274"}, + // Invalid format + { + name: "too short", + code: "0017704", + err: "IDENTITY-01", + }, + { + name: "too long", + code: "00177041234", + err: "IDENTITY-01", + }, + { + name: "contains letters", + code: "0017704A", + err: "IDENTITY-01", + }, + // Bad checksum - legal entity + { + name: "bad checksum legal entity", + code: "00177042", + err: "IDENTITY-01", + }, + { + name: "bad checksum legal entity 2", + code: "45274640", + err: "IDENTITY-01", + }, + // Bad checksum - individual (10 digits, not divisible by 11) + { + name: "bad checksum individual", + code: "7103192746", + err: "IDENTITY-01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "CZ", 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 ff3cb73a8..58ebd6c2a 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -13,6 +13,7 @@ import ( _ "github.com/invopop/gobl/regimes/ca" _ "github.com/invopop/gobl/regimes/ch" _ "github.com/invopop/gobl/regimes/co" + _ "github.com/invopop/gobl/regimes/cz" _ "github.com/invopop/gobl/regimes/de" _ "github.com/invopop/gobl/regimes/dk" _ "github.com/invopop/gobl/regimes/es"