-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #410 from invopop/addon-verifactu
Veri*Factu Addon
- Loading branch information
Showing
27 changed files
with
1,803 additions
and
27 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
), | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
}, | ||
} | ||
} |
Oops, something went wrong.