diff --git a/regimes/ca/README.md b/regimes/ca/README.md new file mode 100644 index 00000000..3fd6bf1f --- /dev/null +++ b/regimes/ca/README.md @@ -0,0 +1,3 @@ +# 🇨🇦 GOBL Canada Tax Regime + +Example Canada GOBL files can be found in the [`examples`](./examples) (YAML uncalculated documents) and [`examples/out`](./examples/out) (JSON calculated envelopes) subdirectories. diff --git a/regimes/ca/ca.go b/regimes/ca/ca.go new file mode 100644 index 00000000..b900600e --- /dev/null +++ b/regimes/ca/ca.go @@ -0,0 +1,129 @@ +// Package ca provides models for dealing with Canada. +package ca + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterRegime(New()) +} + +// Tax categories specific for Canada. +const ( + TaxCategoryHST cbc.Code = "HST" + TaxCategoryPST cbc.Code = "PST" +) + +// New provides the tax region definition +func New() *tax.Regime { + return &tax.Regime{ + Country: l10n.CA, + Currency: currency.CAD, + Name: i18n.String{ + i18n.EN: "Canada", + }, + TimeZone: "America/Toronto", // Toronto + Validator: Validate, + Categories: []*tax.Category{ + // + // General Sales Tax (GST) + // + { + Code: tax.CategoryGST, + Name: i18n.String{ + i18n.EN: "GST", + }, + Title: i18n.String{ + i18n.EN: "General Sales Tax", + }, + Sources: []*tax.Source{ + { + Title: i18n.String{ + i18n.EN: "GST/HST provincial rates table", + }, + URL: "https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/charge-collect-which-rate/calculator.html", + }, + }, + Retained: false, + Rates: []*tax.Rate{ + { + Key: tax.RateZero, + Name: i18n.String{ + i18n.EN: "Zero Rate", + }, + Description: i18n.String{ + i18n.EN: "Some supplies are zero-rated under the GST, mainly: basic groceries, agricultural products, farm livestock, most fishery products such, prescription drugs and drug-dispensing services, certain medical devices, feminine hygiene products, exports, many transportation services where the origin or destination is outside Canada", + }, + Values: []*tax.RateValue{ + { + Percent: num.MakePercentage(0, 3), + }, + }, + }, + { + Key: tax.RateStandard, + Name: i18n.String{ + i18n.EN: "Standard rate", + }, + Description: i18n.String{ + i18n.EN: "For the majority of sales of goods and services: it applies to all products or services for which no other rate is expressly provided.", + }, + + Values: []*tax.RateValue{ + { + Since: cal.NewDate(2022, 1, 1), + Percent: num.MakePercentage(5, 2), + }, + }, + }, + }, + }, + // + // Harmonized Sales Tax (HST) + // + { + Code: TaxCategoryHST, + Name: i18n.String{ + i18n.EN: "HST", + }, + Title: i18n.String{ + i18n.EN: "Harmonized Sales Tax", + }, + // TODO: determine local rates + Rates: []*tax.Rate{}, + }, + + // + // Provincial Sales Tax (PST) + // + { + Code: TaxCategoryPST, + Name: i18n.String{ + i18n.EN: "PST", + }, + Title: i18n.String{ + i18n.EN: "Provincial Sales Tax", + }, + // TODO: determine local rates + Rates: []*tax.Rate{}, + }, + }, + } +} + +// Validate checks the document type and determines if it can be validated. +func Validate(doc interface{}) error { + switch obj := doc.(type) { + case *bill.Invoice: + return validateInvoice(obj) + } + return nil +} diff --git a/regimes/ca/examples/invoice-ca-ca.yaml b/regimes/ca/examples/invoice-ca-ca.yaml new file mode 100644 index 00000000..b062700f --- /dev/null +++ b/regimes/ca/examples/invoice-ca-ca.yaml @@ -0,0 +1,37 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +currency: "CAD" +issue_date: "2023-04-21" +series: "SAMPLE" +code: "001" + +supplier: + tax_id: + country: "CA" + name: "Provide One Inc." + emails: + - addr: "billing@provideone.com" + addresses: + - num: "151" + street: "O'Connor Street" + locality: "Ottawa" + region: "ON" + code: "K2P 2L8" + country: "CA" + +customer: + name: "Sample Consumer" + emails: + - addr: "email@sample.com" + +lines: + - quantity: 20 + item: + name: "Development services" + price: "90.00" + unit: "h" + discounts: + - percent: "10%" + reason: "Special discount" + taxes: + - cat: GST + percent: "8.5%" diff --git a/regimes/ca/examples/out/invoice-ca-ca.json b/regimes/ca/examples/out/invoice-ca-ca.json new file mode 100644 index 00000000..470ab043 --- /dev/null +++ b/regimes/ca/examples/out/invoice-ca-ca.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "3d1cd0dd9fb113e244bddbdb7cefdb8be5c1a9ab3d60928cc225303cc1a2326b" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2023-04-21", + "currency": "CAD", + "supplier": { + "name": "Provide One Inc.", + "tax_id": { + "country": "CA" + }, + "addresses": [ + { + "num": "151", + "street": "O'Connor Street", + "locality": "Ottawa", + "region": "ON", + "code": "K2P 2L8", + "country": "CA" + } + ], + "emails": [ + { + "addr": "billing@provideone.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "emails": [ + { + "addr": "email@sample.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "percent": "10%", + "amount": "180.00", + "reason": "Special discount" + } + ], + "taxes": [ + { + "cat": "GST", + "percent": "8.5%" + } + ], + "total": "1620.00" + } + ], + "totals": { + "sum": "1620.00", + "total": "1620.00", + "taxes": { + "categories": [ + { + "code": "GST", + "rates": [ + { + "base": "1620.00", + "percent": "8.5%", + "amount": "137.70" + } + ], + "amount": "137.70" + } + ], + "sum": "137.70" + }, + "tax": "137.70", + "total_with_tax": "1757.70", + "payable": "1757.70" + } + } +} \ No newline at end of file diff --git a/regimes/ca/invoice_validator.go b/regimes/ca/invoice_validator.go new file mode 100644 index 00000000..35e789bf --- /dev/null +++ b/regimes/ca/invoice_validator.go @@ -0,0 +1,27 @@ +package ca + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/currency" + "github.com/invopop/validation" +) + +// invoiceValidator adds validation checks to invoices which are relevant +// for the region. +type invoiceValidator struct { + inv *bill.Invoice +} + +func validateInvoice(inv *bill.Invoice) error { + v := &invoiceValidator{inv: inv} + return v.validate() +} + +func (v *invoiceValidator) validate() error { + inv := v.inv + return validation.ValidateStruct(inv, + validation.Field(&inv.Currency, validation.In(currency.CAD)), + validation.Field(&inv.Supplier, validation.Required), + validation.Field(&inv.Customer), + ) +} diff --git a/regimes/common/invoice_tags.go b/regimes/common/invoice_tags.go index 8748ba86..2a41f023 100644 --- a/regimes/common/invoice_tags.go +++ b/regimes/common/invoice_tags.go @@ -14,11 +14,13 @@ var invoiceTags = []*tax.KeyDefinition{ i18n.EN: "Simplified Invoice", i18n.ES: "Factura Simplificada", i18n.IT: "Fattura Semplificata", + i18n.DE: "Vereinfachte Rechnung", }, Desc: i18n.String{ i18n.EN: "Used for B2C transactions when the client details are not available, check with local authorities for limits.", i18n.ES: "Usado para transacciones B2C cuando los detalles del cliente no están disponibles, consulte con las autoridades locales para los límites.", i18n.IT: "Utilizzato per le transazioni B2C quando i dettagli del cliente non sono disponibili, controllare con le autorità locali per i limiti.", + i18n.DE: "Wird für B2C-Transaktionen verwendet, wenn die Kundendaten nicht verfügbar sind. Bitte wenden Sie sich an die örtlichen Behörden, um die Grenzwerte zu ermitteln.", }, }, @@ -31,6 +33,7 @@ var invoiceTags = []*tax.KeyDefinition{ i18n.EN: "Reverse Charge", i18n.ES: "Inversión del Sujeto Pasivo", i18n.IT: "Inversione del soggetto passivo", + i18n.DE: "Umkehr der Steuerschuld", }, }, @@ -43,6 +46,7 @@ var invoiceTags = []*tax.KeyDefinition{ i18n.EN: "Self-billed", i18n.ES: "Facturación por el destinatario", i18n.IT: "Autofattura", + i18n.DE: "Rechnung durch den Leistungsempfänger", }, }, @@ -52,6 +56,8 @@ var invoiceTags = []*tax.KeyDefinition{ Name: i18n.String{ i18n.EN: "Customer rates", i18n.ES: "Tarifas aplicables al destinatario", + i18n.IT: "Aliquote applicabili al destinatario", + i18n.DE: "Kundensätze", }, }, } diff --git a/regimes/de/README.md b/regimes/de/README.md new file mode 100644 index 00000000..ceb844b6 --- /dev/null +++ b/regimes/de/README.md @@ -0,0 +1,3 @@ +# 🇩🇪 GOBL Germany Tax Regime + +Example DE GOBL files can be found in the [`examples`](./examples) (YAML uncalculated documents) and [`examples/out`](./examples/out) (JSON calculated envelopes) subdirectories. diff --git a/regimes/de/de.go b/regimes/de/de.go new file mode 100644 index 00000000..155beb15 --- /dev/null +++ b/regimes/de/de.go @@ -0,0 +1,62 @@ +// Package de provides the tax region definition for Germany. +package de + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/regimes/common" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterRegime(New()) +} + +// New provides the tax region definition +func New() *tax.Regime { + return &tax.Regime{ + Country: l10n.DE, + Currency: "EUR", + Name: i18n.String{ + i18n.EN: "Germany", + i18n.FR: "Deutschland", + }, + TimeZone: "Europe/Berlin", + Tags: common.InvoiceTags(), + Scenarios: []*tax.ScenarioSet{ + invoiceScenarios, + }, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + // Germany only supports credit notes to correct an invoice + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + }, + }, + }, + Validator: Validate, + Calculator: Calculate, + Categories: taxCategories, + } +} + +// Validate checks the document type and determines if it can be validated. +func Validate(doc interface{}) error { + switch obj := doc.(type) { + case *tax.Identity: + return validateTaxIdentity(obj) + } + return nil +} + +// Calculate will attempt to clean the object passed to it. +func Calculate(doc interface{}) error { + switch obj := doc.(type) { + case *tax.Identity: + return common.NormalizeTaxIdentity(obj) + } + return nil +} diff --git a/regimes/de/examples/invoice-de-de.yaml b/regimes/de/examples/invoice-de-de.yaml new file mode 100644 index 00000000..f6ca698b --- /dev/null +++ b/regimes/de/examples/invoice-de-de.yaml @@ -0,0 +1,46 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +currency: "EUR" +issue_date: "2022-02-01" +series: "SAMPLE" +code: "001" + +supplier: + tax_id: + country: "DE" + code: "111111125" # random + name: "Provide One GmbH" + emails: + - addr: "billing@example.com" + addresses: + - num: "16" + street: "Dietmar-Hopp-Allee" + locality: "Walldorf" + code: "69190" + country: "DE" + +customer: + tax_id: + country: "DE" + code: "282741168" + name: "Sample Consumer" + emails: + - addr: "email@sample.com" + addresses: + - num: "25" + street: "Werner-Heisenberg-Allee" + locality: "München" + code: "80939" + country": "DE" + +lines: + - quantity: 20 + item: + name: "Development services" + price: "90.00" + unit: "h" + discounts: + - percent: "10%" + reason: "Special discount" + taxes: + - cat: VAT + rate: standard diff --git a/regimes/de/examples/out/invoice-de-de.json b/regimes/de/examples/out/invoice-de-de.json new file mode 100644 index 00000000..99d3859e --- /dev/null +++ b/regimes/de/examples/out/invoice-de-de.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2022-02-01", + "currency": "EUR", + "supplier": { + "name": "Provide One GmbH", + "tax_id": { + "country": "DE", + "code": "111111125" + }, + "addresses": [ + { + "num": "16", + "street": "Dietmar-Hopp-Allee", + "locality": "Walldorf", + "code": "69190", + "country": "DE" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "DE", + "code": "282741168" + }, + "addresses": [ + { + "num": "25", + "street": "Werner-Heisenberg-Allee", + "locality": "München", + "code": "80939" + } + ], + "emails": [ + { + "addr": "email@sample.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "percent": "10%", + "amount": "180.00", + "reason": "Special discount" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "19%" + } + ], + "total": "1620.00" + } + ], + "totals": { + "sum": "1620.00", + "total": "1620.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1620.00", + "percent": "19%", + "amount": "307.80" + } + ], + "amount": "307.80" + } + ], + "sum": "307.80" + }, + "tax": "307.80", + "total_with_tax": "1927.80", + "payable": "1927.80" + } + } +} \ No newline at end of file diff --git a/regimes/de/scenarios.go b/regimes/de/scenarios.go new file mode 100644 index 00000000..1c09757b --- /dev/null +++ b/regimes/de/scenarios.go @@ -0,0 +1,23 @@ +package de + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +var invoiceScenarios = &tax.ScenarioSet{ + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // ** Special Messages ** + // Reverse Charges + { + Tags: []cbc.Key{tax.TagReverseCharge}, + Note: &cbc.Note{ + Key: cbc.NoteKeyLegal, + Src: tax.TagReverseCharge, + Text: "Reverse Charge / Umkehr der Steuerschuld.", + }, + }, + }, +} diff --git a/regimes/de/tax_categories.go b/regimes/de/tax_categories.go new file mode 100644 index 00000000..fdb81848 --- /dev/null +++ b/regimes/de/tax_categories.go @@ -0,0 +1,108 @@ +package de + +import ( + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" +) + +var taxCategories = []*tax.Category{ + // + // VAT + // + { + Code: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "VAT", + i18n.DE: "MwSt", + }, + Title: i18n.String{ + i18n.EN: "Value Added Tax", + i18n.DE: "Mehrwertsteuer", + }, + Sources: []*tax.Source{ + { + Title: i18n.String{ + i18n.EN: "Value Added Tax/Goods and Services Tax (VAT/GST) (1976-2023)", + i18n.DE: "Umsatzsteuer/Güter - und Dienstleistungssteuer (USt/GST) (1976-2023)", + }, + URL: "https://www.oecd.org/tax/tax-policy/tax-database/", + }, + }, + Retained: false, + Rates: []*tax.Rate{ + { + Key: tax.RateZero, + Name: i18n.String{ + i18n.EN: "Zero Rate", + i18n.DE: "Nullsatz", + }, + Values: []*tax.RateValue{ + { + Percent: num.MakePercentage(0, 3), + }, + }, + }, + { + Key: tax.RateStandard, + Name: i18n.String{ + i18n.EN: "Standard rate", + i18n.DE: "Standardsteuersatz", + }, + Description: i18n.String{ + i18n.EN: "For the majority of sales of goods and services: it applies to all products or services for which no other rate is expressly provided.", + i18n.DE: "Für den Großteil der Verkäufe von Waren und Dienstleistungen gilt: Dies gilt für alle Produkte oder Dienstleistungen, für die ausdrücklich kein anderer Satz festgelegt ist.", + }, + + Values: []*tax.RateValue{ + { + Since: cal.NewDate(2022, 1, 1), + Percent: num.MakePercentage(19, 2), + }, + { + Since: cal.NewDate(2020, 7, 1), // COVID temporary measures + Percent: num.MakePercentage(16, 2), + }, + { + Since: cal.NewDate(2007, 7, 1), + Percent: num.MakePercentage(19, 2), + }, + { + Since: cal.NewDate(1993, 1, 1), + Percent: num.MakePercentage(16, 2), + }, + }, + }, + { + Key: tax.RateReduced, + Name: i18n.String{ + i18n.EN: "Reduced rate", + i18n.DE: "Verminderter Steuersatz", + }, + Description: i18n.String{ + i18n.EN: "Applicable in particular to basic foodstuffs, books and magazines, cultural events, hotel accommodations, public transportation, medical products, or home renovation.", + i18n.DE: "Insbesondere anwendbar auf Grundnahrungsmittel, Bücher und Zeitschriften, kulturelle Veranstaltungen, Hotelunterkünfte, öffentliche Verkehrsmittel, medizinische Produkte oder Hausrenovierung.", + }, + Values: []*tax.RateValue{ + { + Since: cal.NewDate(2022, 1, 1), + Percent: num.MakePercentage(7, 2), + }, + { + Since: cal.NewDate(2020, 7, 1), // COVID temporary measures + Percent: num.MakePercentage(5, 2), + }, + { + Since: cal.NewDate(2007, 7, 1), + Percent: num.MakePercentage(7, 2), + }, + { + Since: cal.NewDate(1993, 1, 1), + Percent: num.MakePercentage(5, 2), + }, + }, + }, + }, + }, +} diff --git a/regimes/de/tax_identity.go b/regimes/de/tax_identity.go new file mode 100644 index 00000000..713881a4 --- /dev/null +++ b/regimes/de/tax_identity.go @@ -0,0 +1,80 @@ +package de + +import ( + "errors" + "regexp" + "strconv" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// Reference: https://github.com/ltns35/go-vat/blob/main/countries/germany.go + +var ( + taxCodeRegexps = []*regexp.Regexp{ + regexp.MustCompile(`^[1-9]\d{8}$`), + } +) + +// validateTaxIdentity checks to ensure the NIT code looks okay. +func validateTaxIdentity(tID *tax.Identity) error { + return validation.ValidateStruct(tID, + validation.Field(&tID.Code, validation.By(validateTaxCode)), + ) +} + +func validateTaxCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + match := false + for _, re := range taxCodeRegexps { + if re.MatchString(val) { + match = true + break + } + } + if !match { + return errors.New("invalid format") + } + + return validateTaxCodeChecksum(val) +} + +func validateTaxCodeChecksum(val string) error { + p := 10 + sum := 0 + cd := 0 + for i := 0; i < 8; i++ { + digit, err := strconv.Atoi(string(val[i])) + if err != nil { + return errors.New("invalid digit") + } + sum = (digit + p) % 10 + if sum == 0 { + sum = 10 + } + p = (2 * sum) % 11 + } + + if 11-p == 10 { + cd = 0 + } else { + cd = 11 - p + } + + ecd, err := strconv.Atoi(string(val[8])) + if err != nil { + return errors.New("invalid checksum") + } + if cd != ecd { + return errors.New("checksum mismatch") + } + + return nil +} diff --git a/regimes/de/tax_identity_test.go b/regimes/de/tax_identity_test.go new file mode 100644 index 00000000..32edcbd1 --- /dev/null +++ b/regimes/de/tax_identity_test.go @@ -0,0 +1,73 @@ +package de_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/regimes/de" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestValidateTaxIdentity(t *testing.T) { + tests := []struct { + name string + code cbc.Code + err string + }{ + {name: "good 1", code: "111111125"}, + {name: "good 2", code: "160459932"}, + {name: "good 3", code: "282741168"}, + {name: "good 4", code: "813495425"}, + { + name: "zeros", + code: "000000000", + err: "invalid format", + }, + { + name: "start with zero", + code: "011111112", + err: "invalid format", + }, + { + name: "bad mid length", + code: "12345678910", + err: "invalid format", + }, + { + name: "too long", + code: "1234567890123", + err: "invalid format", + }, + { + name: "too short", + code: "123456", + err: "invalid format", + }, + { + name: "not normalized", + code: "12.449.965-4", + err: "invalid format", + }, + { + name: "bad checksum", + code: "999999991", + err: "checksum mismatch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: l10n.DE, Code: tt.code} + err := de.Validate(tID) + if tt.err == "" { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.err) + } + } + }) + } +} diff --git a/regimes/regimes.go b/regimes/regimes.go index b4ccad33..60371b45 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -5,7 +5,9 @@ package regimes import ( // Import all the regime definitions which will automatically // add themselves to the tax regime register. + _ "github.com/invopop/gobl/regimes/ca" _ "github.com/invopop/gobl/regimes/co" + _ "github.com/invopop/gobl/regimes/de" _ "github.com/invopop/gobl/regimes/es" _ "github.com/invopop/gobl/regimes/fr" _ "github.com/invopop/gobl/regimes/gb"