Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Added

- `it-ticket-v1`: implemented addon for AdE e-receipt format
- `bill`: line discount and charge `base` property, to use instead of the line sum in order to comply with EN16931.
- `bill`: line Charge support for Quantity and Rate special cases for charges like tariffs that result in a fixed amount base on a rate, like, 1 cent for every 100g of sugar.

Expand Down
1 change: 1 addition & 0 deletions addons/addons.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
_ "github.com/invopop/gobl/addons/fr/facturx"
_ "github.com/invopop/gobl/addons/gr/mydata"
_ "github.com/invopop/gobl/addons/it/sdi"
_ "github.com/invopop/gobl/addons/it/ticket"
_ "github.com/invopop/gobl/addons/mx/cfdi"
_ "github.com/invopop/gobl/addons/pt/saft"
)
97 changes: 97 additions & 0 deletions addons/it/ticket/extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package ticket

import (
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/i18n"
"github.com/invopop/gobl/pkg/here"
)

// Italian extension keys required by the AdE ticket format.
const (
ExtKeyExempt cbc.Key = "it-ticket-exempt"
ExtKeyProduct cbc.Key = "it-ticket-product"
)

var extensions = []*cbc.Definition{
{
// Used to clarify the reason for the exemption from VAT.
Key: ExtKeyExempt,
Name: i18n.String{
i18n.EN: "Exemption Code",
i18n.IT: "Natura Esenzione",
},
Values: []*cbc.Definition{
{
Code: "N1",
Name: i18n.String{
i18n.EN: "Excluded pursuant to Art. 15, DPR 633/72",
i18n.IT: "Escluse ex. art. 15 del D.P.R. 633/1972",
},
},
{
Code: "N2",
Name: i18n.String{
i18n.EN: "Not subject",
i18n.IT: "Non soggette",
},
},
{
Code: "N3",
Name: i18n.String{
i18n.EN: "Not taxable",
i18n.IT: "Non imponibili",
},
},
{
Code: "N4",
Name: i18n.String{
i18n.EN: "Exempt",
i18n.IT: "Esenti",
},
},
{
Code: "N5",
Name: i18n.String{
i18n.EN: "Margin regime / VAT not exposed",
i18n.IT: "Regime del margine/IVA non esposta in fattura",
},
},
{
Code: "N6",
Name: i18n.String{
i18n.EN: "Reverse charge",
i18n.IT: "Inversione contabile",
},
},
},
},
{
Key: ExtKeyProduct,
Name: i18n.String{
i18n.EN: "AdE CF Product Key",
i18n.IT: "Chiave Prodotto AdE CF",
},
Desc: i18n.String{
i18n.EN: here.Doc(`
Product keys are used by AdE CF to differentiate between goods
and services.
`),
},
Values: []*cbc.Definition{
{
Code: "goods",
Name: i18n.String{
i18n.EN: "Delivery of goods",
i18n.IT: "Consegna di beni",
},
},
{
Code: "services",
Name: i18n.String{
i18n.EN: "Provision of services",
i18n.IT: "Prestazione di servizi",
},
},
},
},
}
71 changes: 71 additions & 0 deletions addons/it/ticket/invoices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package ticket

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

func normalizeInvoice(inv *bill.Invoice) {
if inv.Tax == nil {
inv.Tax = new(bill.Tax)
}
if inv.Tax.PricesInclude == "" {
inv.Tax.PricesInclude = tax.CategoryVAT
}
}

func validateInvoice(inv *bill.Invoice) error {
return validation.ValidateStruct(inv,
validation.Field(&inv.Tax,
validation.Required,
validation.By(validateInvoiceTax),
validation.Skip,
),
validation.Field(&inv.Supplier,
validation.By(validateInvoiceSupplier),
validation.Skip,
),
validation.Field(&inv.Lines,
validation.Each(
bill.RequireLineTaxCategory(tax.CategoryVAT),
validation.Skip,
),
validation.Skip,
),
)
}

func validateInvoiceSupplier(value interface{}) error {
supplier, ok := value.(*org.Party)
if !ok || supplier == nil {
return nil
}

return validation.ValidateStruct(supplier,
validation.Field(&supplier.TaxID,
validation.Required,
tax.RequireIdentityCode,
validation.Skip,
),
)
}

// This done because the format requires tax to be calculated at item level
// By forcing this we can ensure that the price already has the tax included
func validateInvoiceTax(value interface{}) error {
t, ok := value.(*bill.Tax)
if !ok || t == nil {
return nil
}

return validation.ValidateStruct(t,
validation.Field(&t.PricesInclude,
validation.Required,
validation.In(tax.CategoryVAT),
validation.Skip,
),
)

}
190 changes: 190 additions & 0 deletions addons/it/ticket/invoices_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package ticket_test

import (
"testing"

_ "github.com/invopop/gobl"
"github.com/invopop/gobl/addons/it/ticket"
"github.com/invopop/gobl/bill"
"github.com/invopop/gobl/cal"
"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 exampleStandardInvoice(t *testing.T) *bill.Invoice {
t.Helper()
i := &bill.Invoice{
Regime: tax.WithRegime("IT"),
Addons: tax.WithAddons(ticket.V1),
Code: "123TEST",
Currency: "EUR",
Tax: &bill.Tax{
PricesInclude: tax.CategoryVAT,
},
Type: bill.InvoiceTypeStandard,
Supplier: &org.Party{
Name: "Test Supplier",
TaxID: &tax.Identity{
Country: "IT",
Code: "12345678903",
},
},
IssueDate: cal.MakeDate(2022, 6, 13),
Lines: []*bill.Line{
{
Quantity: num.MakeAmount(10, 0),
Item: &org.Item{
Name: "Test Item 0",
Price: num.NewAmount(10000, 2),
},
Taxes: tax.Set{
{
Category: "VAT",
Rate: "standard",
},
},
Discounts: []*bill.LineDiscount{
{
Reason: "Testing",
Percent: num.NewPercentage(10, 2),
},
},
},
{
Quantity: num.MakeAmount(13, 0),
Item: &org.Item{
Name: "Test Item 1",
Price: num.NewAmount(1000, 2),
},
Taxes: tax.Set{
{
Category: "VAT",
Ext: tax.Extensions{
ticket.ExtKeyExempt: "N4",
},
},
},
Discounts: []*bill.LineDiscount{
{
Reason: "Testing",
Percent: num.NewPercentage(10, 2),
},
},
},
},
}
return i
}

func TestInvoiceValidation(t *testing.T) {
inv := exampleStandardInvoice(t)
require.NoError(t, inv.Calculate())
require.NoError(t, inv.Validate())
}

func TestSupplierValidation(t *testing.T) {
t.Run("invalid Tax ID", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Supplier.TaxID = &tax.Identity{
Country: "IT",
Code: "RSSGNN60R30H501U",
}
require.NoError(t, inv.Calculate())
err := inv.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "code: contains invalid characters")
})

t.Run("missing supplier", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Supplier = nil
require.NoError(t, inv.Calculate())
err := inv.Validate()
assert.Error(t, err)
assert.Contains(t, err.Error(), "supplier: cannot be blank.")
})
}

func TestInvoiceLineTaxes(t *testing.T) {
t.Run("item with no taxes", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Lines = append(inv.Lines, &bill.Line{
Quantity: num.MakeAmount(10, 0),
Item: &org.Item{
Name: "Test Item 2",
Price: num.NewAmount(10000, 2),
},
})
require.NoError(t, inv.Calculate())
err := inv.Validate()
require.EqualError(t, err, "lines: (2: (taxes: missing category VAT.).).")
})

t.Run("item with no Rate and missing Ext", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Lines = append(inv.Lines, &bill.Line{
Quantity: num.MakeAmount(10, 0),
Item: &org.Item{
Name: "Test Item 2",
Price: num.NewAmount(10000, 2),
},
Taxes: tax.Set{
{
Category: "VAT",
},
},
})
require.NoError(t, inv.Calculate())
err := inv.Validate()
require.EqualError(t, err, "lines: (2: (taxes: (0: (ext: (it-ticket-exempt: required.).).).).).")
})

t.Run("item with Invalid Percentage", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Lines = append(inv.Lines, &bill.Line{
Quantity: num.MakeAmount(10, 0),
Item: &org.Item{
Name: "Test Item 2",
Price: num.NewAmount(10000, 2),
},
Taxes: tax.Set{
{
Category: "VAT",
Percent: num.NewPercentage(24, 2),
},
},
})
require.NoError(t, inv.Calculate())
err := inv.Validate()
require.EqualError(t, err, "lines: (2: (taxes: (0: (percent: must be a valid value.).).).).")
})
}

func TestInvoiceTax(t *testing.T) {
t.Run("invalid PricesInclude", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Tax.PricesInclude = tax.CategoryGST
require.NoError(t, inv.Calculate())
err := inv.Validate()
require.EqualError(t, err, "tax: (prices_include: must be a valid value.).")
})

t.Run("missing PricesInclude", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Tax.PricesInclude = ""
require.NoError(t, inv.Calculate())
err := inv.Validate()
require.NoError(t, err)
})

t.Run("missing Tax", func(t *testing.T) {
inv := exampleStandardInvoice(t)
inv.Tax = nil
require.NoError(t, inv.Calculate())
err := inv.Validate()
require.NoError(t, err)
})
}
19 changes: 19 additions & 0 deletions addons/it/ticket/org.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ticket

import (
"github.com/invopop/gobl/org"
"github.com/invopop/gobl/tax"
)

func normalizeOrgItem(item *org.Item) {
if item == nil {
return
}
if item.Ext == nil {
item.Ext = make(tax.Extensions)
}
if !item.Ext.Has(ExtKeyProduct) {
// Assume all items are services by default.
item.Ext[ExtKeyProduct] = "services"
}
}
Loading