Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extensions and validations for the typical service note #429

Merged
merged 9 commits into from
Nov 21, 2024
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

- `org`: `Address` includes `LineOne()`, `LineTwo()`, `CompleteNumber()` methods to help with conversion to other formats with some regional formatting.
- `br`: supplier extensions, validations & identities
- `br-nfse-v1`: new extensions, validations & identities for the typical service note and supplier.

### Changes

Expand Down
106 changes: 98 additions & 8 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"
cavalle marked this conversation as resolved.
Show resolved Hide resolved
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
`),
},
cavalle marked this conversation as resolved.
Show resolved Hide resolved
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 Down Expand Up @@ -113,8 +201,9 @@ var extensions = []*cbc.KeyDefinition{
i18n.EN: here.Doc(`
Indicates whether a party is opting for the “Simples Nacional” tax regime.

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-67)
`),
},
Expand Down Expand Up @@ -171,10 +260,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
9 changes: 9 additions & 0 deletions 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
43 changes: 39 additions & 4 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
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