Skip to content

Commit

Permalink
Merge pull request #410 from invopop/addon-verifactu
Browse files Browse the repository at this point in the history
Veri*Factu Addon
  • Loading branch information
samlown authored Dec 5, 2024
2 parents 6d09561 + 633fce7 commit 04ddb4c
Show file tree
Hide file tree
Showing 27 changed files with 1,803 additions and 27 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

## [Unreleased]

### Added

- `es-verifactu-v1`: added initial Spain VeriFactu addon.
- `tax`: Extensions `Get` convenience method that helps when using extensions using sub-keys.
- `tax`: `ExtensionsExclude` validator for checking that extensions do **not** include certain keys.
- `tax`: `ExtValue.In` for comparing extension values.
- `bill`: `Tax.MergeExtensions` convenience method for adding extensions to tax objects and avoid nil panics.
- `cbc`: `Key.Pop` method for splitting keys with sub-keys, e.g. `cbc.Key("a+b").Pop() == cbc.Key("a")`.

### Fixed

- `bill`: corrected issues around correction definitions and merging types.

## [v0.206.1] - 2024-11-28

### Fixed
Expand Down
1 change: 1 addition & 0 deletions addons/addons.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
_ "github.com/invopop/gobl/addons/de/xrechnung"
_ "github.com/invopop/gobl/addons/es/facturae"
_ "github.com/invopop/gobl/addons/es/tbai"
_ "github.com/invopop/gobl/addons/es/verifactu"
_ "github.com/invopop/gobl/addons/eu/en16931"
_ "github.com/invopop/gobl/addons/gr/mydata"
_ "github.com/invopop/gobl/addons/it/sdi"
Expand Down
163 changes: 163 additions & 0 deletions addons/es/verifactu/bill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package verifactu

import (
"github.com/invopop/gobl/bill"
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/org"
"github.com/invopop/gobl/regimes/es"
"github.com/invopop/gobl/tax"
"github.com/invopop/validation"
)

var invoiceCorrectionDefinitions = tax.CorrectionSet{
{
Schema: bill.ShortSchemaInvoice,
Extensions: []cbc.Key{
ExtKeyDocType,
},
},
}

func normalizeInvoice(inv *bill.Invoice) {
// Try to move any preceding choices to the document level
for _, row := range inv.Preceding {
if len(row.Ext) == 0 {
continue
}
found := false
if row.Ext.Has(ExtKeyDocType) {
if inv.Tax == nil || !found {
inv.Tax = inv.Tax.MergeExtensions(tax.Extensions{
ExtKeyDocType: row.Ext[ExtKeyDocType],
})
found = true // only assign first one
}
delete(row.Ext, ExtKeyDocType)
}
}

// Try to normalize the correction type, which is especially complex for
// Verifactu implying that scenarios cannot be used.
switch inv.Type {
case bill.InvoiceTypeCreditNote, bill.InvoiceTypeDebitNote:
inv.Tax = inv.Tax.MergeExtensions(tax.Extensions{
ExtKeyCorrectionType: "I",
})
case bill.InvoiceTypeCorrective:
if inv.Tax == nil || inv.Tax.Ext.Get(ExtKeyDocType) != "F3" {
inv.Tax = inv.Tax.MergeExtensions(tax.Extensions{
ExtKeyCorrectionType: "S",
})
} else {
// Substitutions of simplified invoices cannot have a correction type
delete(inv.Tax.Ext, ExtKeyCorrectionType)
}
}
}

func validateInvoice(inv *bill.Invoice) error {
return validation.ValidateStruct(inv,
validation.Field(&inv.Customer,
validation.By(validateInvoiceCustomer),
validation.Skip,
),
validation.Field(&inv.Preceding,
validation.When(
inv.Type.In(es.InvoiceCorrectionTypes...),
validation.Required,
),
validation.Each(
validation.By(validateInvoicePreceding),
),
validation.Skip,
),
validation.Field(&inv.Tax,
validation.Required,
validation.By(validateInvoiceTax(inv.Type)),
validation.Skip,
),
validation.Field(&inv.Notes,
cbc.ValidateNotesHasKey(cbc.NoteKeyGeneral),
validation.Skip,
),
)
}

var docTypesStandard = []tax.ExtValue{ // Standard invoices
"F1", "F2",
}
var docTypesCreditDebit = []tax.ExtValue{ // Credit or Debit notes
"R1", "R2", "R3", "R4", "R5",
}
var docTypesCorrective = []tax.ExtValue{ // Substitutions
"F3", "R1", "R2", "R3", "R4", "R5",
}

func validateInvoiceTax(it cbc.Key) validation.RuleFunc {
return func(val any) error {
obj := val.(*bill.Tax)
return validation.ValidateStruct(obj,
validation.Field(&obj.Ext,
tax.ExtensionsRequires(ExtKeyDocType),
validation.When(
it.In(bill.InvoiceTypeStandard),
tax.ExtensionsHasValues(
ExtKeyDocType,
docTypesStandard...,
),
),
validation.When(
it.In(bill.InvoiceTypeCreditNote, bill.InvoiceTypeDebitNote),
tax.ExtensionsHasValues(
ExtKeyDocType,
docTypesCreditDebit...,
),
),
validation.When(
it.In(bill.InvoiceTypeCorrective),
tax.ExtensionsHasValues(
ExtKeyDocType,
docTypesCorrective...,
),
),
validation.When(
obj.Ext.Get(ExtKeyDocType).In(docTypesCreditDebit...),
tax.ExtensionsRequires(ExtKeyCorrectionType),
),
validation.Skip,
),
)
}
}

func validateInvoiceCustomer(val any) error {
obj, ok := val.(*org.Party)
if !ok || obj == nil {
return nil
}
// Customers must have a tax ID to at least set the country,
// and Spanish ones should also have an ID. There are more complex
// rules for exports.
return validation.ValidateStruct(obj,
validation.Field(&obj.TaxID,
validation.Required,
validation.When(
obj.TaxID != nil && obj.TaxID.Country.In("ES"),
tax.RequireIdentityCode,
),
validation.Skip,
),
)
}

func validateInvoicePreceding(val any) error {
p, ok := val.(*org.DocumentRef)
if !ok || p == nil {
return nil
}
return validation.ValidateStruct(p,
validation.Field(&p.IssueDate,
validation.Required,
),
)
}
162 changes: 162 additions & 0 deletions addons/es/verifactu/bill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package verifactu_test

import (
"encoding/json"
"testing"

"github.com/invopop/gobl/addons/es/verifactu"
"github.com/invopop/gobl/bill"
"github.com/invopop/gobl/cal"
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/num"
"github.com/invopop/gobl/org"
"github.com/invopop/gobl/tax"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInvoiceValidation(t *testing.T) {
t.Run("standard invoice", func(t *testing.T) {
inv := testInvoiceStandard(t)
require.NoError(t, inv.Calculate())
require.NoError(t, inv.Validate())
assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "F1")
})
t.Run("missing customer tax ID", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.Customer.TaxID = nil
assertValidationError(t, inv, "customer: (tax_id: cannot be blank.)")
})

t.Run("without exemption reason", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.Lines[0].Taxes[0].Rate = ""
inv.Lines[0].Taxes[0].Percent = num.NewPercentage(21, 2)
inv.Lines[0].Taxes[0].Ext = nil
assertValidationError(t, inv, "es-verifactu-op-class: required")
})

t.Run("without notes", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.Notes = nil
assertValidationError(t, inv, "notes: with key 'general' missing")
})

t.Run("missing doc type", func(t *testing.T) {
inv := testInvoiceStandard(t)
require.NoError(t, inv.Calculate())
inv.Tax.Ext = nil
err := inv.Validate()
require.ErrorContains(t, err, "es-verifactu-doc-type: required")
})

t.Run("simplified invoice", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.SetTags(tax.TagSimplified)
inv.Customer = nil
require.NoError(t, inv.Calculate())
require.NoError(t, inv.Validate())
assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "F2")
})

t.Run("simplified substitution", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.SetTags(tax.TagSimplified)
require.NoError(t, inv.Calculate())

require.NoError(t, inv.Correct(bill.Corrective, bill.WithExtension(verifactu.ExtKeyDocType, "F3")))
require.NoError(t, inv.Validate())
assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "F3")
assert.Empty(t, inv.Tax.Ext[verifactu.ExtKeyCorrectionType])
})

t.Run("correction invoice requires preceding", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.Type = bill.InvoiceTypeCreditNote
assertValidationError(t, inv, "preceding: cannot be blank")
})

t.Run("correction invoice preceding requires issue date", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.Type = bill.InvoiceTypeCreditNote
inv.Preceding = []*org.DocumentRef{
{
Code: "123",
},
}
assertValidationError(t, inv, "preceding: (0: (issue_date: cannot be blank.).)")
})

t.Run("correction invoice with preceding", func(t *testing.T) {
inv := testInvoiceStandard(t)
inv.Type = bill.InvoiceTypeCreditNote
d := cal.MakeDate(2024, 1, 1)
inv.Preceding = []*org.DocumentRef{
{
Series: "ABC",
Code: "122",
IssueDate: &d,
Ext: tax.Extensions{
verifactu.ExtKeyDocType: "R1",
},
},
}
require.NoError(t, inv.Calculate())
data, _ := json.MarshalIndent(inv, "", " ")
t.Log(string(data))
require.NoError(t, inv.Validate())
assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "R1")
assert.Empty(t, inv.Preceding[0].Ext)
})

}

func assertValidationError(t *testing.T, inv *bill.Invoice, expected string) {
require.NoError(t, inv.Calculate())
err := inv.Validate()
require.ErrorContains(t, err, expected)
}

func testInvoiceStandard(t *testing.T) *bill.Invoice {
t.Helper()
return &bill.Invoice{
Addons: tax.WithAddons(verifactu.V1),
Code: "123",
Supplier: &org.Party{
Name: "Test Supplier",
TaxID: &tax.Identity{
Country: "ES",
Code: "B98602642",
},
},
Customer: &org.Party{
Name: "Test Customer",
TaxID: &tax.Identity{
Country: "NL",
Code: "000099995B57",
},
},
Lines: []*bill.Line{
{
Quantity: num.MakeAmount(1, 0),
Item: &org.Item{
Name: "bogus",
Price: num.MakeAmount(10000, 2),
Unit: org.UnitPackage,
},
Taxes: tax.Set{
{
Category: "VAT",
Rate: "standard",
},
},
},
},
Notes: []*cbc.Note{
{
Key: cbc.NoteKeyGeneral,
Text: "This is a test invoice",
},
},
}
}
Loading

0 comments on commit 04ddb4c

Please sign in to comment.