From 92487616ace3771d49491e167a7a3cc6a43b0f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luismi=20Cavall=C3=A9?= Date: Fri, 18 Oct 2024 10:18:03 +0000 Subject: [PATCH 1/3] Add basic Brazil regime --- CHANGELOG.md | 4 + regimes/br/README.md | 3 + regimes/br/br.go | 60 +++++++++++++ regimes/br/examples/invoice-br-br.yaml | 38 +++++++++ regimes/br/examples/out/invoice-br-br.json | 98 ++++++++++++++++++++++ regimes/br/invoices.go | 24 ++++++ regimes/br/tax_categories.go | 94 +++++++++++++++++++++ regimes/br/tax_identity.go | 75 +++++++++++++++++ regimes/br/tax_identity_test.go | 81 ++++++++++++++++++ regimes/regimes.go | 1 + 10 files changed, 478 insertions(+) create mode 100644 regimes/br/README.md create mode 100644 regimes/br/br.go create mode 100644 regimes/br/examples/invoice-br-br.yaml create mode 100644 regimes/br/examples/out/invoice-br-br.json create mode 100644 regimes/br/invoices.go create mode 100644 regimes/br/tax_categories.go create mode 100644 regimes/br/tax_identity.go create mode 100644 regimes/br/tax_identity_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 662d435e..c043e5b8 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 + +- `br`: added basic Brazil regime + ### Fixed - `bill.Invoice` - remove empty taxes instances. diff --git a/regimes/br/README.md b/regimes/br/README.md new file mode 100644 index 00000000..2b9b0b1f --- /dev/null +++ b/regimes/br/README.md @@ -0,0 +1,3 @@ +# 🇧🇷 GOBL Brazil Tax Regime + +Example BR GOBL files can be found in the [`examples`](./examples) (YAML uncalculated documents) and [`examples/out`](./examples/out) (JSON calculated envelopes) subdirectories. diff --git a/regimes/br/br.go b/regimes/br/br.go new file mode 100644 index 00000000..7f1addbe --- /dev/null +++ b/regimes/br/br.go @@ -0,0 +1,60 @@ +// Package br provides the tax region definition for Brazil. +package br + +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/regimes/common" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterRegimeDef(New()) +} + +// New provides the tax region definition +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: "BR", + Currency: currency.BRL, + Name: i18n.String{ + i18n.EN: "Brazil", + i18n.PT: "Brasil", + }, + TimeZone: "America/Sao_Paulo", + Validator: Validate, + Tags: []*tax.TagSet{ + common.InvoiceTags(), + }, + Categories: taxCategories, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + }, + }, + }, + } +} + +// Validate checks the document type and determines if it can be validated. +func Validate(doc interface{}) error { + switch obj := doc.(type) { + case *bill.Invoice: + return validateInvoice(obj) + case *tax.Identity: + return validateTaxIdentity(obj) + } + return nil +} + +// Normalize will attempt to clean the object passed to it. +func Normalize(doc interface{}) { + switch obj := doc.(type) { + case *tax.Identity: + tax.NormalizeIdentity(obj) + } +} diff --git a/regimes/br/examples/invoice-br-br.yaml b/regimes/br/examples/invoice-br-br.yaml new file mode 100644 index 00000000..ec799707 --- /dev/null +++ b/regimes/br/examples/invoice-br-br.yaml @@ -0,0 +1,38 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +uuid: "3aea7b56-59d8-4beb-90bd-f8f280d852a0" +currency: "BRL" +issue_date: "2023-04-21" +series: "SAMPLE" +code: "001" + +supplier: + tax_id: + country: "BR" + name: "TechSolutions Brasil Ltda." + emails: + - addr: "supplier_br@example.com" + addresses: + - num: "595" + street: "Rua Haddock Lobo" + locality: "São Paulo" + region: "SP" + code: "01311-000" + country: "BR" + +customer: + name: "Sample Consumer" + emails: + - addr: "customer_br@example.com" + +lines: + - quantity: 20 + item: + name: "Development services" + price: "90.00" + unit: "h" + discounts: + - percent: "10%" + reason: "Special discount" + taxes: + - cat: ISS + percent: "15%" diff --git a/regimes/br/examples/out/invoice-br-br.json b/regimes/br/examples/out/invoice-br-br.json new file mode 100644 index 00000000..eebde428 --- /dev/null +++ b/regimes/br/examples/out/invoice-br-br.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "5b7bb418d378de68d73da3f12f211648c5238e8ba916f4f5f00b2712f24d2476" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "BR", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2023-04-21", + "currency": "BRL", + "supplier": { + "name": "TechSolutions Brasil Ltda.", + "tax_id": { + "country": "BR" + }, + "addresses": [ + { + "num": "595", + "street": "Rua Haddock Lobo", + "locality": "São Paulo", + "region": "SP", + "code": "01311-000", + "country": "BR" + } + ], + "emails": [ + { + "addr": "supplier_br@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "emails": [ + { + "addr": "customer_br@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "percent": "10%", + "amount": "180.00", + "reason": "Special discount" + } + ], + "taxes": [ + { + "cat": "ISS", + "percent": "15%" + } + ], + "total": "1620.00" + } + ], + "totals": { + "sum": "1620.00", + "total": "1620.00", + "taxes": { + "categories": [ + { + "code": "ISS", + "rates": [ + { + "base": "1620.00", + "percent": "15%", + "amount": "243.00" + } + ], + "amount": "243.00" + } + ], + "sum": "243.00" + }, + "tax": "243.00", + "total_with_tax": "1863.00", + "payable": "1863.00" + } + } +} \ No newline at end of file diff --git a/regimes/br/invoices.go b/regimes/br/invoices.go new file mode 100644 index 00000000..d7659fca --- /dev/null +++ b/regimes/br/invoices.go @@ -0,0 +1,24 @@ +package br + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/validation" +) + +// invoiceValidator adds validation checks to invoices which are relevant +// for the region. +type invoiceValidator struct { + inv *bill.Invoice +} + +func validateInvoice(inv *bill.Invoice) error { + v := &invoiceValidator{inv: inv} + return v.validate() +} + +func (v *invoiceValidator) validate() error { + inv := v.inv + return validation.ValidateStruct(inv, + validation.Field(&inv.Supplier, validation.Required), + ) +} diff --git a/regimes/br/tax_categories.go b/regimes/br/tax_categories.go new file mode 100644 index 00000000..2e60ae51 --- /dev/null +++ b/regimes/br/tax_categories.go @@ -0,0 +1,94 @@ +package br + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/tax" +) + +// Tax categories specific for Brazil. +const ( + TaxCategoryISS cbc.Code = "ISS" + TaxCategoryICMS cbc.Code = "ICMS" + TaxCategoryIPI cbc.Code = "IPI" + TaxCategoryPIS cbc.Code = "PIS" + TaxCategoryCOFINS cbc.Code = "COFINS" +) + +var taxCategories = []*tax.CategoryDef{ + // + // Municipal Service Tax (ISS) + // + { + Code: TaxCategoryISS, + Name: i18n.String{ + i18n.EN: "ISS", + i18n.PT: "ISS", + }, + Title: i18n.String{ + i18n.EN: "Municipal Service Tax", + i18n.PT: "Imposto Sobre Serviços", + }, + Retained: false, + }, + // + // State value-added tax (ICMS) + // + { + Code: TaxCategoryICMS, + Name: i18n.String{ + i18n.EN: "ICMS", + i18n.PT: "ICMS", + }, + Title: i18n.String{ + i18n.EN: "State value-added tax", + i18n.PT: "Imposto sobre Circulação de Mercadorias e Serviços", + }, + Retained: false, + }, + // + // Federal value-added Tax (IPI) + // + { + Code: TaxCategoryIPI, + Name: i18n.String{ + i18n.EN: "IPI", + i18n.PT: "IPI", + }, + Title: i18n.String{ + i18n.EN: "Federal value-added Tax", + i18n.PT: "Imposto sobre Produtos Industrializados", + }, + Retained: false, + }, + // + // Social Integration Program (PIS) + // + { + Code: TaxCategoryPIS, + Name: i18n.String{ + i18n.EN: "PIS", + i18n.PT: "PIS", + }, + Title: i18n.String{ + i18n.EN: "Social Integration Program", + i18n.PT: "Programa de Integração Social", + }, + Retained: true, + }, + // + // Contribution for the Financing of Social Security (COFINS) + // + { + Code: TaxCategoryCOFINS, + Name: i18n.String{ + i18n.EN: "COFINS", + i18n.PT: "COFINS", + }, + Title: i18n.String{ + i18n.EN: "Contribution for the Financing of Social Security", + i18n.PT: "Contribuição para o Financiamento da Seguridade Social", + }, + Retained: true, + }, +} diff --git a/regimes/br/tax_identity.go b/regimes/br/tax_identity.go new file mode 100644 index 00000000..bb606575 --- /dev/null +++ b/regimes/br/tax_identity.go @@ -0,0 +1,75 @@ +package br + +import ( + "errors" + "strconv" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// Reference: https://pt.wikipedia.org/wiki/Cadastro_Nacional_da_Pessoa_Jurídica + +func validateTaxIdentity(tID *tax.Identity) error { + return validation.ValidateStruct(tID, + validation.Field(&tID.Code, validation.By(validateTaxCode)), + ) +} + +func validateTaxCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + // Verify length + if len(val) != 14 { + return errors.New("must have 14 digits") + } + + // Verify first verification digit + weights1 := []int{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + if err := verifyDigit(val, weights1, 12); err != nil { + return err + } + + // Verify second verification digit + weights2 := []int{6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2} + if err := verifyDigit(val, weights2, 13); err != nil { + return err + } + + return nil +} + +func verifyDigit(cnpj string, weights []int, position int) error { + sum := 0 + for i := 0; i < len(weights); i++ { + digit, err := strconv.Atoi(string(cnpj[i])) + if err != nil { + return errors.New("must contain only digits") + } + sum += digit * weights[i] + } + + remainder := sum % 11 + var expectedDigit int + if remainder < 2 { + expectedDigit = 0 + } else { + expectedDigit = 11 - remainder + } + + actualDigit, err := strconv.Atoi(string(cnpj[position])) + if err != nil { + return errors.New("must contain only digits") + } + + if actualDigit != expectedDigit { + return errors.New("verification digit mismatch") + } + + return nil +} diff --git a/regimes/br/tax_identity_test.go b/regimes/br/tax_identity_test.go new file mode 100644 index 00000000..c19ce1e3 --- /dev/null +++ b/regimes/br/tax_identity_test.go @@ -0,0 +1,81 @@ +package br_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/br" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestTaxIdentityValidation(t *testing.T) { + tests := []struct { + name string + code cbc.Code + err string + }{ + {name: "valid1", code: "05104582000170"}, + {name: "valid2", code: "10909402000167"}, + { + name: "too long", + code: "123456789012345", + err: "must have 14 digits", + }, + { + name: "too short", + code: "1234567890123", + err: "must have 14 digits", + }, + { + name: "non-numeric", + code: "A2345678901234", + err: "must contain only digits", + }, + { + name: "first verification digit wrong", + code: "05104582000160", + err: "verification digit mismatch", + }, + { + name: "second verification digit wrong", + code: "05104582000171", + err: "verification digit mismatch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "BR", Code: tt.code} + err := br.Validate(tID) + if tt.err == "" { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.err) + } + } + }) + } +} + +func TestTaxIdentityNormalization(t *testing.T) { + tests := []struct { + name string + code cbc.Code + want cbc.Code + }{ + {name: "valid1", code: "05104582000170", want: "05104582000170"}, + {name: "valid2", code: "10909402000167", want: "10909402000167"}, + {name: "valid3", code: "051.045.820/0017-0", want: "05104582000170"}, + {name: "valid4", code: "109.094.020/0016-7", want: "10909402000167"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "BR", Code: tt.code} + br.Normalize(tID) + assert.Equal(t, tt.want, tID.Code) + }) + } +} diff --git a/regimes/regimes.go b/regimes/regimes.go index f73d09ad..8a3b49c4 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -7,6 +7,7 @@ import ( // add themselves to the tax regime register. _ "github.com/invopop/gobl/regimes/at" _ "github.com/invopop/gobl/regimes/be" + _ "github.com/invopop/gobl/regimes/br" _ "github.com/invopop/gobl/regimes/ca" _ "github.com/invopop/gobl/regimes/ch" _ "github.com/invopop/gobl/regimes/co" From 2103b22b629aeb7bf47377d7bbb9019ef5abdea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luismi=20Cavall=C3=A9?= Date: Fri, 18 Oct 2024 13:48:52 +0000 Subject: [PATCH 2/3] Improve test coverage --- regimes/br/invoices_test.go | 30 ++++++++++++++++++++++++++++++ regimes/br/tax_identity_test.go | 5 +++++ 2 files changed, 35 insertions(+) create mode 100644 regimes/br/invoices_test.go diff --git a/regimes/br/invoices_test.go b/regimes/br/invoices_test.go new file mode 100644 index 00000000..57a183ee --- /dev/null +++ b/regimes/br/invoices_test.go @@ -0,0 +1,30 @@ +package br_test + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/br" + "github.com/stretchr/testify/assert" +) + +func TestInvoiceValidation(t *testing.T) { + t.Run("supplier required", func(t *testing.T) { + inv := new(bill.Invoice) + err := br.Validate(inv) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "supplier: cannot be blank") + } + }) + + t.Run("valid invoice", func(t *testing.T) { + inv := &bill.Invoice{ + Supplier: &org.Party{ + Name: "Test Supplier", + }, + } + err := br.Validate(inv) + assert.NoError(t, err) + }) +} diff --git a/regimes/br/tax_identity_test.go b/regimes/br/tax_identity_test.go index c19ce1e3..b44a7df1 100644 --- a/regimes/br/tax_identity_test.go +++ b/regimes/br/tax_identity_test.go @@ -32,6 +32,11 @@ func TestTaxIdentityValidation(t *testing.T) { code: "A2345678901234", err: "must contain only digits", }, + { + name: "non-numeric verification digit", + code: "123456789012AB", + err: "must contain only digits", + }, { name: "first verification digit wrong", code: "05104582000160", From ce4698cf9fdcf004e14210e12f8c5552200acc48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luismi=20Cavall=C3=A9?= Date: Fri, 18 Oct 2024 13:57:29 +0000 Subject: [PATCH 3/3] Replace validator structs with plain function --- regimes/br/invoices.go | 12 ------------ regimes/us/invoices.go | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/regimes/br/invoices.go b/regimes/br/invoices.go index d7659fca..6c1464fe 100644 --- a/regimes/br/invoices.go +++ b/regimes/br/invoices.go @@ -5,19 +5,7 @@ import ( "github.com/invopop/validation" ) -// invoiceValidator adds validation checks to invoices which are relevant -// for the region. -type invoiceValidator struct { - inv *bill.Invoice -} - func validateInvoice(inv *bill.Invoice) error { - v := &invoiceValidator{inv: inv} - return v.validate() -} - -func (v *invoiceValidator) validate() error { - inv := v.inv return validation.ValidateStruct(inv, validation.Field(&inv.Supplier, validation.Required), ) diff --git a/regimes/us/invoices.go b/regimes/us/invoices.go index c976bfd1..c36182d6 100644 --- a/regimes/us/invoices.go +++ b/regimes/us/invoices.go @@ -5,19 +5,7 @@ import ( "github.com/invopop/validation" ) -// invoiceValidator adds validation checks to invoices which are relevant -// for the region. -type invoiceValidator struct { - inv *bill.Invoice -} - func validateInvoice(inv *bill.Invoice) error { - v := &invoiceValidator{inv: inv} - return v.validate() -} - -func (v *invoiceValidator) validate() error { - inv := v.inv return validation.ValidateStruct(inv, validation.Field(&inv.Supplier, validation.Required), )