diff --git a/CHANGELOG.md b/CHANGELOG.md index 597cb9c5..b8b6da98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to GOBL will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). See also the [GOBL versions](https://docs.gobl.org/overview/versions) documentation site for more details. +## [Unreleased] + +### Added + +- `br-nfse-v1`: added initial Brazil NFS-e addon + ## [v0.203.0] ### Added diff --git a/addons/addons.go b/addons/addons.go index 8f8cc110..7eeac335 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -9,6 +9,7 @@ package addons import ( // Import all the addons to ensure they're ready to use. + _ "github.com/invopop/gobl/addons/br/nfse" _ "github.com/invopop/gobl/addons/co/dian" _ "github.com/invopop/gobl/addons/es/facturae" _ "github.com/invopop/gobl/addons/es/tbai" diff --git a/addons/br/nfse/extensions.go b/addons/br/nfse/extensions.go new file mode 100644 index 00000000..b7fe2820 --- /dev/null +++ b/addons/br/nfse/extensions.go @@ -0,0 +1,28 @@ +package nfse + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +// Brazilian extension keys required to issue NFS-e documents. +const ( + ExtKeyService = "br-nfse-service" +) + +var extensions = []*cbc.KeyDefinition{ + { + Key: ExtKeyService, + Name: i18n.String{ + i18n.EN: "Service Code", + i18n.PT: "Código Item Lista Serviço", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + The service code as defined by the municipality. Typically, one of the codes listed + in the Lei Complementar 116/2003, but municipalities can make their own changes. + `), + }, + }, +} diff --git a/addons/br/nfse/item.go b/addons/br/nfse/item.go new file mode 100644 index 00000000..d84d7ce7 --- /dev/null +++ b/addons/br/nfse/item.go @@ -0,0 +1,21 @@ +package nfse + +import ( + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +func validateItem(value any) error { + item, _ := value.(*org.Item) + if item == nil { + return nil + } + + return validation.ValidateStruct(item, + validation.Field(&item.Ext, + tax.ExtensionsRequires(ExtKeyService), + validation.Skip, + ), + ) +} diff --git a/addons/br/nfse/item_test.go b/addons/br/nfse/item_test.go new file mode 100644 index 00000000..953f0e38 --- /dev/null +++ b/addons/br/nfse/item_test.go @@ -0,0 +1,62 @@ +package nfse_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/br/nfse" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestItemValidation(t *testing.T) { + tests := []struct { + name string + item *org.Item + err string + }{ + { + name: "valid item", + item: &org.Item{ + Ext: tax.Extensions{ + nfse.ExtKeyService: "12345678", + }, + }, + }, + { + name: "missing extensions", + item: &org.Item{}, + err: "ext: (br-nfse-service: required.)", + }, + { + name: "empty extensions", + item: &org.Item{ + Ext: tax.Extensions{}, + }, + err: "ext: (br-nfse-service: required.)", + }, + { + name: "missing extension", + item: &org.Item{ + Ext: tax.Extensions{ + "random": "12345678", + }, + }, + err: "ext: (br-nfse-service: required.).", + }, + } + + addon := tax.AddonForKey(nfse.V1) + for _, ts := range tests { + t.Run(ts.name, func(t *testing.T) { + err := addon.Validator(ts.item) + if ts.err == "" { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), ts.err) + } + } + }) + } +} diff --git a/addons/br/nfse/line.go b/addons/br/nfse/line.go new file mode 100644 index 00000000..92ae8d28 --- /dev/null +++ b/addons/br/nfse/line.go @@ -0,0 +1,18 @@ +package nfse + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/regimes/br" + "github.com/invopop/validation" +) + +func validateLine(value any) error { + line, _ := value.(*bill.Line) + if line == nil { + return nil + } + + return validation.Validate(line, + bill.RequireLineTaxCategory(br.TaxCategoryISS), + ) +} diff --git a/addons/br/nfse/line_test.go b/addons/br/nfse/line_test.go new file mode 100644 index 00000000..5173aacd --- /dev/null +++ b/addons/br/nfse/line_test.go @@ -0,0 +1,67 @@ +package nfse_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/br/nfse" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/regimes/br" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestLineValidation(t *testing.T) { + tests := []struct { + name string + line *bill.Line + err string + }{ + { + name: "valid line", + line: &bill.Line{ + Taxes: tax.Set{ + { + Category: br.TaxCategoryISS, + }, + }, + }, + }, + { + name: "missing taxes", + line: &bill.Line{}, + err: "taxes: missing category ISS.", + }, + { + name: "empty taxes", + line: &bill.Line{ + Taxes: tax.Set{}, + }, + err: "taxes: missing category ISS.", + }, + { + name: "missing ISS tax", + line: &bill.Line{ + Taxes: tax.Set{ + { + Category: br.TaxCategoryPIS, + }, + }, + }, + err: "taxes: missing category ISS.", + }, + } + + addon := tax.AddonForKey(nfse.V1) + for _, ts := range tests { + t.Run(ts.name, func(t *testing.T) { + err := addon.Validator(ts.line) + if ts.err == "" { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), ts.err) + } + } + }) + } +} diff --git a/addons/br/nfse/nfse.go b/addons/br/nfse/nfse.go new file mode 100644 index 00000000..07310b34 --- /dev/null +++ b/addons/br/nfse/nfse.go @@ -0,0 +1,41 @@ +// Package nfse handles extensions and validation rules to issue NFS-e in +// Brazil. +package nfse + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" +) + +const ( + // V1 identifies the NFS-e addon version + V1 cbc.Key = "br-nfse-v1" +) + +func init() { + tax.RegisterAddonDef(newAddon()) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V1, + Name: i18n.String{ + i18n.EN: "Brazil NFS-e 1.X", + }, + Extensions: extensions, + Validator: validate, + } +} + +func validate(doc any) error { + switch obj := doc.(type) { + case *bill.Line: + return validateLine(obj) + case *org.Item: + return validateItem(obj) + } + return nil +}