Skip to content

Commit

Permalink
Merge pull request #429 from invopop/br-typical-nfse
Browse files Browse the repository at this point in the history
Extensions and validations for the typical service note
  • Loading branch information
cavalle authored Nov 21, 2024
2 parents 76e5d83 + 2216d92 commit 27c9101
Show file tree
Hide file tree
Showing 11 changed files with 488 additions and 46 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
### Added

- `ae`: added UAE regime
- `br`: supplier extensions, validations & identities
- `br-nfse-v1`: new extensions, validations & identities for the typical service note and supplier.

## [v0.205.1] - 2024-11-19

Expand Down
116 changes: 104 additions & 12 deletions addons/br/nfse/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,33 @@ import (
// of these extensions are common, they can be moved to the regime or to a
// shared addon.
const (
ExtKeyCNAE = "br-nfse-cnae"
ExtKeyFiscalIncentive = "br-nfse-fiscal-incentive"
ExtKeyISSLiability = "br-nfse-iss-liability"
ExtKeyMunicipality = "br-nfse-municipality"
ExtKeyService = "br-nfse-service"
ExtKeySimplesNacional = "br-nfse-simples-nacional"
ExtKeySimples = "br-nfse-simples"
ExtKeySpecialRegime = "br-nfse-special-regime"
)

var extensions = []*cbc.KeyDefinition{
{
Key: ExtKeyCNAE,
Name: i18n.String{
i18n.EN: "CNAE code",
i18n.PT: "Código CNAE",
},
Desc: i18n.String{
i18n.EN: here.Doc(`
The CNAE (National Classification of Economic Activities) code for a service.
List of codes from the IBGE (Brazilian Institute of Geography and Statistics):
* https://www.ibge.gov.br/en/statistics/technical-documents/statistical-lists-and-classifications/17245-national-classification-of-economic-activities.html
`),
},
Pattern: `^\d{2}[\s\.\-\/]?\d{2}[\s\.\-\/]?\d[\s\.\-\/]?\d{2}$`,
},
{
Key: ExtKeyFiscalIncentive,
Name: i18n.String{
Expand All @@ -46,12 +65,81 @@ var extensions = []*cbc.KeyDefinition{
i18n.EN: here.Doc(`
Indicates whether a party benefits from a fiscal incentive.
List of codes taken from the national NFSe standard:
https://abrasf.org.br/biblioteca/arquivos-publicos/nfs-e-manual-de-orientacao-do-contribuinte-2-04/download
List of codes from the national NFSe ABRASF (v2.04) model:
* https://abrasf.org.br/biblioteca/arquivos-publicos/nfs-e-manual-de-orientacao-do-contribuinte-2-04/download
(Section 10.2, Field B-68)
`),
},
},
{
Key: ExtKeyISSLiability,
Name: i18n.String{
i18n.EN: "ISS Liability",
i18n.PT: "Exigibilidade ISS",
},
Values: []*cbc.ValueDefinition{
{
Value: "1",
Name: i18n.String{
i18n.EN: "Liable",
i18n.PT: "Exigível",
},
},
{
Value: "2",
Name: i18n.String{
i18n.EN: "Not subject",
i18n.PT: "Não incidência",
},
},
{
Value: "3",
Name: i18n.String{
i18n.EN: "Exempt",
i18n.PT: "Isenção",
},
},
{
Value: "4",
Name: i18n.String{
i18n.EN: "Export",
i18n.PT: "Exportação",
},
},
{
Value: "5",
Name: i18n.String{
i18n.EN: "Immune",
i18n.PT: "Imunidade",
},
},
{
Value: "6",
Name: i18n.String{
i18n.EN: "Suspended Judicially",
i18n.PT: "Suspensa por Decisão Judicial",
},
},
{
Value: "7",
Name: i18n.String{
i18n.EN: "Suspended Administratively",
i18n.PT: "Suspensa por Processo Administrativo",
},
},
},
Desc: i18n.String{
i18n.EN: here.Doc(`
Indicates the ISS liability status, i.e., whether the ISS tax is due or not and why.
List of codes from the national NFSe ABRASF (v2.04) model:
* https://abrasf.org.br/biblioteca/arquivos-publicos/nfs-e-manual-de-orientacao-do-contribuinte-2-04/download
(Section 10.2, Field B-38)
`),
},
},
{
Key: ExtKeyMunicipality,
Name: i18n.String{
Expand All @@ -63,7 +151,7 @@ var extensions = []*cbc.KeyDefinition{
The municipality code as defined by the IGBE (Brazilian Institute of Geography and
Statistics).
For further details on the list of possible codes, see:
List of codes from the IGBE:
* https://www.ibge.gov.br/explica/codigos-dos-municipios.php
`),
Expand All @@ -88,9 +176,9 @@ var extensions = []*cbc.KeyDefinition{
},
},
{
Key: ExtKeySimplesNacional,
Key: ExtKeySimples,
Name: i18n.String{
i18n.EN: "Opting for “Simples Nacional”",
i18n.EN: "Opting for “Simples Nacional” regime",
i18n.PT: "Optante pelo Simples Nacional",
},
Values: []*cbc.ValueDefinition{
Expand All @@ -111,10 +199,13 @@ var extensions = []*cbc.KeyDefinition{
},
Desc: i18n.String{
i18n.EN: here.Doc(`
Indicates whether a party is opting for the “Simples Nacional” tax regime.
Indicates whether a party is opting for the “Simples Nacional” (Regime Especial
Unificado de Arrecadação de Tributos e Contribuições devidos pelas Microempresas e
Empresas de Pequeno Porte) tax regime
List of codes from the national NFSe ABRASF (v2.04) model:
List of codes taken from the national NFSe standard:
https://abrasf.org.br/biblioteca/arquivos-publicos/nfs-e-manual-de-orientacao-do-contribuinte-2-04/download
* https://abrasf.org.br/biblioteca/arquivos-publicos/nfs-e-manual-de-orientacao-do-contribuinte-2-04/download
(Section 10.2, Field B-67)
`),
},
Expand Down Expand Up @@ -171,10 +262,11 @@ var extensions = []*cbc.KeyDefinition{
},
Desc: i18n.String{
i18n.EN: here.Doc(`
Indicates a special tax regime that the party is subject to.
Indicates a special tax regime that a party is subject to.
List of codes from the national NFSe ABRASF (v2.04) model:
List of codes taken from the national NFSe standard:
https://abrasf.org.br/biblioteca/arquivos-publicos/nfs-e-manual-de-orientacao-do-contribuinte-2-04/download
* https://abrasf.org.br/biblioteca/arquivos-publicos/nfs-e-manual-de-orientacao-do-contribuinte-2-04/download
(Section 10.2, Field B-66)
`),
},
Expand Down
11 changes: 10 additions & 1 deletion addons/br/nfse/invoices.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package nfse

import (
"regexp"

"github.com/invopop/gobl/bill"
"github.com/invopop/gobl/org"
"github.com/invopop/gobl/tax"
Expand All @@ -12,12 +14,19 @@ const (
FiscalIncentiveDefault = "2" // No incentiva
)

var (
// CodeRegexp is the regular expression used to validate the invoice code
CodeRegexp = regexp.MustCompile(`^[1-9][0-9]*$`)
)

func validateInvoice(inv *bill.Invoice) error {
if inv == nil {
return nil
}

return validation.ValidateStruct(inv,
validation.Field(&inv.Series, validation.Required),
validation.Field(&inv.Code, validation.Match(CodeRegexp)),
validation.Field(&inv.Supplier,
validation.By(validateSupplier),
validation.Skip,
Expand Down Expand Up @@ -60,7 +69,7 @@ func validateSupplier(value interface{}) error {
),
validation.Field(&obj.Ext,
tax.ExtensionsRequires(
ExtKeySimplesNacional,
ExtKeySimples,
ExtKeyMunicipality,
ExtKeyFiscalIncentive,
),
Expand Down
49 changes: 42 additions & 7 deletions addons/br/nfse/invoices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,40 @@ func TestInvoicesValidation(t *testing.T) {
}{
{
name: "valid invoice",
inv: &bill.Invoice{},
inv: &bill.Invoice{
Series: "SAMPLE",
},
},
{
name: "nil invoice",
inv: nil,
},
{
name: "missing series",
inv: &bill.Invoice{},
err: "series: cannot be blank",
},
{
name: "invalid code (non-digits)",
inv: &bill.Invoice{
Code: "ABC-123",
},
err: "code: must be in a valid format",
},
{
name: "invalid code (padding zeroes)",
inv: &bill.Invoice{
Code: "000123",
},
err: "code: must be in a valid format",
},
{
name: "valid code",
inv: &bill.Invoice{
Series: "SAMPLE",
Code: "123000",
},
},
{
name: "charges present",
inv: &bill.Invoice{
Expand All @@ -34,7 +62,7 @@ func TestInvoicesValidation(t *testing.T) {
},
},
},
err: "charges: not supported by nfse.",
err: "charges: not supported by nfse",
},
{
name: "discounts present",
Expand All @@ -45,7 +73,12 @@ func TestInvoicesValidation(t *testing.T) {
},
},
},
err: "discounts: not supported by nfse.",
err: "discounts: not supported by nfse",
},
{
name: "series missing",
inv: &bill.Invoice{},
err: "series: cannot be blank",
},
}

Expand All @@ -54,7 +87,9 @@ func TestInvoicesValidation(t *testing.T) {
t.Run(ts.name, func(t *testing.T) {
err := addon.Validator(ts.inv)
if ts.err == "" {
assert.NoError(t, err)
if err != nil {
assert.NotContains(t, err.Error(), ts.err)
}
} else {
if assert.Error(t, err) {
assert.Contains(t, err.Error(), ts.err)
Expand Down Expand Up @@ -172,19 +207,19 @@ func TestSuppliersValidation(t *testing.T) {
}
err := addon.Validator(inv)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "br-nfse-simples-nacional: required")
assert.Contains(t, err.Error(), "br-nfse-simples: required")
assert.Contains(t, err.Error(), "br-nfse-municipality: required")
assert.Contains(t, err.Error(), "br-nfse-fiscal-incentive: required")
}

sup.Ext = tax.Extensions{
nfse.ExtKeySimplesNacional: "1",
nfse.ExtKeySimples: "1",
nfse.ExtKeyMunicipality: "12345678",
nfse.ExtKeyFiscalIncentive: "2",
}
err = addon.Validator(inv)
if assert.Error(t, err) {
assert.NotContains(t, err.Error(), "br-nfse-simples-nacional: required")
assert.NotContains(t, err.Error(), "br-nfse-simples: required")
assert.NotContains(t, err.Error(), "br-nfse-municipality: required")
assert.NotContains(t, err.Error(), "br-nfse-fiscal-incentive: required")
}
Expand Down
4 changes: 4 additions & 0 deletions addons/br/nfse/nfse.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ func validate(doc any) error {
return validateLine(obj)
case *org.Item:
return validateItem(obj)
case *tax.Combo:
return validateTaxCombo(obj)
}
return nil
}
Expand All @@ -48,5 +50,7 @@ func normalize(doc any) {
switch obj := doc.(type) {
case *bill.Invoice:
normalizeSupplier(obj.Supplier)
case *tax.Combo:
normalizeTaxCombo(obj)
}
}
35 changes: 35 additions & 0 deletions addons/br/nfse/tax_combo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package nfse

import (
"github.com/invopop/gobl/regimes/br"
"github.com/invopop/gobl/tax"
"github.com/invopop/validation"
)

const (
// ISSLiabilityDefault is the default value for the ISS liability extension
ISSLiabilityDefault = "1" // Liable
)

func validateTaxCombo(tc *tax.Combo) error {
return validation.ValidateStruct(tc,
validation.Field(&tc.Ext,
validation.When(tc.Category == br.TaxCategoryISS,
tax.ExtensionsRequires(ExtKeyISSLiability),
),
),
)
}

func normalizeTaxCombo(tc *tax.Combo) {
if tc == nil || tc.Category != br.TaxCategoryISS {
return
}

if !tc.Ext.Has(ExtKeyISSLiability) {
if tc.Ext == nil {
tc.Ext = make(tax.Extensions)
}
tc.Ext[ExtKeyISSLiability] = ISSLiabilityDefault
}
}
Loading

0 comments on commit 27c9101

Please sign in to comment.