From e80e161750d2e7a825eda5ed531c4bf6d49480bc Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 09:13:04 +0000 Subject: [PATCH] Final proposal for EN16931 addon and catalogues with XRechnung --- CHANGELOG.md | 12 + addons/addons.go | 1 + addons/de/xrechnung/extensions.go | 169 ---- addons/de/xrechnung/instructions.go | 58 +- addons/de/xrechnung/instructions_test.go | 31 +- addons/de/xrechnung/invoices.go | 203 +---- addons/de/xrechnung/invoices_test.go | 118 +-- addons/de/xrechnung/scenarios.go | 171 ---- addons/de/xrechnung/tax_combo.go | 51 -- addons/de/xrechnung/tax_combo_test.go | 35 - addons/de/xrechnung/xrechnung.go | 34 +- addons/eu/en16931/bill.go | 31 + addons/eu/en16931/bill_test.go | 116 +++ addons/eu/en16931/en16931.go | 66 ++ addons/eu/en16931/pay.go | 64 ++ addons/eu/en16931/pay_test.go | 82 ++ addons/eu/en16931/scenarios.go | 85 ++ addons/eu/en16931/tax_combo.go | 52 ++ addons/eu/en16931/tax_combo_test.go | 98 ++ bill/invoice.go | 17 +- bill/invoice_correct.go | 2 +- bill/invoice_scenarios.go | 2 +- catalogues/catalogues.go | 10 + catalogues/generate.go | 40 + catalogues/iso/extensions.go | 28 + catalogues/iso/iso.go | 20 + catalogues/iso/iso_test.go | 14 + catalogues/untdid/extensions.go | 746 +++++++++++++++ catalogues/untdid/untdid.go | 19 + catalogues/untdid/untdid_test.go | 14 + cbc/code.go | 24 +- cbc/code_test.go | 127 +++ cbc/key.go | 11 + cbc/key_test.go | 6 + data/addons/de-xrechnung-v3.json | 16 + data/catalogues/iso.json | 20 + data/catalogues/untdid.json | 997 +++++++++++++++++++++ data/data.go | 2 +- data/regimes/de.json | 12 + data/schemas/bill/invoice.json | 2 +- data/schemas/org/identity.json | 5 + data/schemas/org/party.json | 4 + data/schemas/pay/advance.json | 10 + data/schemas/pay/instructions.json | 10 + data/schemas/tax/addon-def.json | 8 + data/schemas/tax/catalogue-def.json | 38 + gobl.go | 2 + i18n/string.go | 8 + i18n/string_test.go | 3 + internal/cli/bulk_test.go | 2 +- org/identity.go | 17 +- org/identity_test.go | 54 ++ org/party.go | 11 +- org/party_test.go | 19 + pay/instructions.go | 10 - pay/means_key.go | 106 ++- regimes/de/examples/invoice-de-de.yaml | 9 + regimes/de/examples/out/invoice-de-de.json | 33 +- regimes/de/identities.go | 4 +- regimes/de/invoices.go | 12 +- regimes/de/invoices_test.go | 13 + regimes/de/tax_categories.go | 12 + tax/addons.go | 22 +- tax/addons_test.go | 8 +- tax/catalogue.go | 62 ++ tax/catalogue_test.go | 22 + tax/extensions.go | 49 +- tax/extensions_test.go | 124 +++ tax/identity.go | 2 +- tax/regime_def.go | 4 +- tax/tax.go | 33 +- 71 files changed, 3448 insertions(+), 874 deletions(-) delete mode 100644 addons/de/xrechnung/extensions.go delete mode 100644 addons/de/xrechnung/scenarios.go delete mode 100644 addons/de/xrechnung/tax_combo.go delete mode 100644 addons/de/xrechnung/tax_combo_test.go create mode 100644 addons/eu/en16931/bill.go create mode 100644 addons/eu/en16931/bill_test.go create mode 100644 addons/eu/en16931/en16931.go create mode 100644 addons/eu/en16931/pay.go create mode 100644 addons/eu/en16931/pay_test.go create mode 100644 addons/eu/en16931/scenarios.go create mode 100644 addons/eu/en16931/tax_combo.go create mode 100644 addons/eu/en16931/tax_combo_test.go create mode 100644 catalogues/catalogues.go create mode 100644 catalogues/generate.go create mode 100644 catalogues/iso/extensions.go create mode 100644 catalogues/iso/iso.go create mode 100644 catalogues/iso/iso_test.go create mode 100644 catalogues/untdid/extensions.go create mode 100644 catalogues/untdid/untdid.go create mode 100644 catalogues/untdid/untdid_test.go create mode 100644 data/addons/de-xrechnung-v3.json create mode 100644 data/catalogues/iso.json create mode 100644 data/catalogues/untdid.json create mode 100644 data/schemas/tax/catalogue-def.json create mode 100644 tax/catalogue.go create mode 100644 tax/catalogue_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 597cb9c5..fdbf228b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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 + +- New "tax catalogues" used for defining extensions for specific standards. +- `eu-en16931-v2017`: addon for underlying support of the EN16931 semantic specifications. +- `de-xrechnung-v3`: addon with extra normalization for XRechnung specification in Germany. + +### Removed + +- `pay`: UNTDID 4461 mappings from payment means table, now provided by catalogues + ## [v0.203.0] ### Added diff --git a/addons/addons.go b/addons/addons.go index 73c1bf34..63a5f773 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -13,6 +13,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/eu/en16931" _ "github.com/invopop/gobl/addons/gr/mydata" _ "github.com/invopop/gobl/addons/it/sdi" _ "github.com/invopop/gobl/addons/mx/cfdi" diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go deleted file mode 100644 index e742933f..00000000 --- a/addons/de/xrechnung/extensions.go +++ /dev/null @@ -1,169 +0,0 @@ -package xrechnung - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" -) - -// ExtKeyTaxRate is the key for the tax rate extension in XRechnung -const ( - ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" - ExtKeyDocType cbc.Key = "de-xrechnung-doc-type" -) - -var extensions = []*cbc.KeyDefinition{ - { - Key: ExtKeyTaxRate, - Name: i18n.String{ - i18n.EN: "Tax Rate", - i18n.DE: "Steuersatz", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - Code used to describe the applicable tax rate. Taken from the UNTDID 5305 code list. - `), - i18n.DE: here.Doc(` - Code verwendet um den anwendbaren Steuersatz zu beschreiben. Entnommen aus der UNTDID 5305 Code-Liste. - `), - }, - Values: []*cbc.ValueDefinition{ - { - Value: "S", - Name: i18n.String{ - i18n.EN: "Standard Rate", - i18n.DE: "Standardsteuersatz", - }, - }, - { - Value: "Z", - Name: i18n.String{ - i18n.EN: "Zero rated goods", - i18n.DE: "Güter mit Nullbewertung", - }, - }, - { - Value: "E", - Name: i18n.String{ - i18n.EN: "Exempt from tax", - i18n.DE: "von der Steuer befreit", - }, - }, - { - Value: "AE", - Name: i18n.String{ - i18n.EN: "VAT Reverse Charge", - i18n.DE: "Mehrwertsteuer Umkehrung der Steuerschuldnerschaft", - }, - }, - { - Value: "K", - Name: i18n.String{ - i18n.EN: "VAT exempt for EEA intra-community supply of goods and services", - i18n.DE: "Mehrwertsteuerbefreiung für innergemeinschaftliche Lieferungen von Gegenständen und Dienstleistungen im EWR", - }, - }, - { - Value: "G", - Name: i18n.String{ - i18n.EN: "Free export item, tax not charged", - i18n.DE: "Kostenlose Ausfuhrsendung, ohne Steuer", - }, - }, - { - Value: "O", - Name: i18n.String{ - i18n.EN: "Services outside scope of tax", - i18n.DE: "Dienstleistungen, die nicht unter die Steuer fallen", - }, - }, - { - Value: "L", - Name: i18n.String{ - i18n.EN: "Canary Islands general indirect tax", - i18n.DE: "Allgemeine indirekte Steuer der Kanarischen Inseln", - }, - }, - { - Value: "M", - Name: i18n.String{ - i18n.EN: "Tax for production, services and importation in Ceuta and Melilla", - i18n.DE: "Steuer auf Produktion, Dienstleistungen und Importe in Ceuta und Melilla", - }, - }, - }, - }, - { - Key: ExtKeyDocType, - Name: i18n.String{ - i18n.EN: "Document Type", - i18n.DE: "Dokumentenart", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - Code used to describe the type of document. - `), - i18n.DE: here.Doc(` - Code verwendet um die Art des Dokuments zu beschreiben. - `), - }, - Values: []*cbc.ValueDefinition{ - { - Value: "326", - Name: i18n.String{ - i18n.EN: "Partial Invoice", - i18n.DE: "Teilrechnung", - }, - }, - { - Value: "380", - Name: i18n.String{ - i18n.EN: "Standard Invoice", - i18n.DE: "Standardrechnung", - }, - }, - { - Value: "381", - Name: i18n.String{ - i18n.EN: "Credit Note", - i18n.DE: "Gutschrift", - }, - }, - { - Value: "384", - Name: i18n.String{ - i18n.EN: "Corrected Invoice", - i18n.DE: "Korrigierte Rechnung", - }, - }, - { - Value: "389", - Name: i18n.String{ - i18n.EN: "Self-Billed Invoice", - i18n.DE: "Gutschrift", - }, - }, - { - Value: "875", - Name: i18n.String{ - i18n.EN: "Partial Construction Invoice", - i18n.DE: "Teilrechnung für Bauleistungen", - }, - }, - { - Value: "876", - Name: i18n.String{ - i18n.EN: "Partial Final Construction Invoice", - i18n.DE: "Schlussrechnung für Bauleistungen mit Teilrechnungen", - }, - }, - { - Value: "877", - Name: i18n.String{ - i18n.EN: "Final Construction Invoice", - i18n.DE: "Schlussrechnung für Bauleistungen", - }, - }, - }, - }, -} diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index 8d3d9c44..d09fb6b8 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -1,28 +1,10 @@ package xrechnung import ( - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/invopop/validation" ) -// Payment keys for XRechnung SEPA direct debit and credit transfer -const ( - KeyPaymentMeansSEPACreditTransfer cbc.Key = "sepa-credit-transfer" - KeyPaymentMeansSEPADirectDebit cbc.Key = "sepa-direct-debit" -) - -var validPaymentKeys = []cbc.Key{ - pay.MeansKeyCash, - pay.MeansKeyCheque, - pay.MeansKeyCreditTransfer, - pay.MeansKeyCard, - pay.MeansKeyDirectDebit, - pay.MeansKeyOther, - KeyPaymentMeansSEPACreditTransfer, - KeyPaymentMeansSEPADirectDebit, -} - // ValidatePaymentInstructions validates the payment instructions according to the XRechnung standard func validatePaymentInstructions(value interface{}) error { instr, ok := value.(*pay.Instructions) @@ -30,14 +12,10 @@ func validatePaymentInstructions(value interface{}) error { return nil } return validation.ValidateStruct(instr, - validation.Field(&instr.Key, - validation.Required, - validation.By(validatePaymentKey), - validation.Skip, - ), // BR-DE-23 validation.Field(&instr.CreditTransfer, - validation.When(instr.Key == KeyPaymentMeansSEPACreditTransfer, + validation.When( + instr.Key.Has(pay.MeansKeyCreditTransfer), validation.Required, validation.Each(validation.By(validateCreditTransfer)), ), @@ -45,34 +23,25 @@ func validatePaymentInstructions(value interface{}) error { ), // BR-DE-24 validation.Field(&instr.Card, - validation.When(instr.Key == pay.MeansKeyCard, + validation.When( + instr.Key.Has(pay.MeansKeyCard), validation.Required, ), validation.Skip, ), // BR-DE-25 validation.Field(&instr.DirectDebit, - validation.When(instr.Key == KeyPaymentMeansSEPADirectDebit || instr.Key == pay.MeansKeyDirectDebit, + validation.When( + instr.Key.Has(pay.MeansKeyDirectDebit), validation.Required, - validation.By(validateDirectDebit), + validation.By(validateInstructionsDirectDebit), validation.Skip, ), ), ) } -func validatePaymentKey(value interface{}) error { - t, ok := value.(cbc.Key) - if !ok { - return validation.NewError("invalid_key", "invalid payment key") - } - if !t.In(validPaymentKeys...) { - return validation.NewError("invalid", "invalid payment key") - } - return nil -} - -func validateDirectDebit(value interface{}) error { +func validateInstructionsDirectDebit(value interface{}) error { dd, ok := value.(*pay.DirectDebit) if !ok || dd == nil { return nil @@ -95,13 +64,14 @@ func validateDirectDebit(value interface{}) error { // BR-DE-19 func validateCreditTransfer(value interface{}) error { - creditTransfer, _ := value.(*pay.CreditTransfer) - if creditTransfer == nil { + ct, ok := value.(*pay.CreditTransfer) + if ct == nil || !ok { return nil } - return validation.ValidateStruct(creditTransfer, - validation.Field(&creditTransfer.Number, - validation.When(creditTransfer.IBAN == "", + return validation.ValidateStruct(ct, + validation.Field(&ct.Number, + validation.When( + ct.IBAN == "", validation.Required, ), ), diff --git a/addons/de/xrechnung/instructions_test.go b/addons/de/xrechnung/instructions_test.go index 36e8a14f..2f3cdf05 100644 --- a/addons/de/xrechnung/instructions_test.go +++ b/addons/de/xrechnung/instructions_test.go @@ -3,11 +3,11 @@ package xrechnung_test import ( "testing" - "github.com/invopop/gobl/addons/de/xrechnung" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func invoiceTemplate(t *testing.T) *bill.Invoice { @@ -22,7 +22,7 @@ func TestValidateInvoice(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: cbc.Key("sepa-credit-transfer"), + Key: "credit-transfer+sepa", CreditTransfer: []*pay.CreditTransfer{ { IBAN: "DE89370400440532013000", @@ -31,14 +31,15 @@ func TestValidateInvoice(t *testing.T) { }, }, } - assert.NoError(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) }) t.Run("invalid invoice with missing IBAN for SEPA credit transfer", func(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, + Key: pay.MeansKeyCreditTransfer.With(pay.MeansKeySEPA), CreditTransfer: []*pay.CreditTransfer{ { BIC: "DEUTDEFF", @@ -46,7 +47,9 @@ func TestValidateInvoice(t *testing.T) { }, }, } - assert.Error(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "payment: (instructions: (credit_transfer: (0: (number: cannot be blank.).).).)") }) t.Run("valid invoice with card payment", func(t *testing.T) { @@ -57,14 +60,15 @@ func TestValidateInvoice(t *testing.T) { Card: &pay.Card{}, }, } - assert.NoError(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) }) t.Run("valid invoice with SEPA direct debit", func(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + Key: "direct-debit+sepa", DirectDebit: &pay.DirectDebit{ Ref: "MANDATE123", Creditor: "DE98ZZZ09999999999", @@ -72,21 +76,24 @@ func TestValidateInvoice(t *testing.T) { }, }, } - assert.NoError(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) }) t.Run("invalid invoice with missing mandate reference for direct debit", func(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + Key: "direct-debit+sepa", DirectDebit: &pay.DirectDebit{ Creditor: "DE98ZZZ09999999999", Account: "DE89370400440532013000", }, }, } - assert.Error(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "payment: (instructions: (direct_debit: (ref: cannot be blank.).).)") }) t.Run("invalid invoice with invalid payment key", func(t *testing.T) { @@ -96,6 +103,8 @@ func TestValidateInvoice(t *testing.T) { Key: cbc.Key("invalid-key"), }, } - assert.Error(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "payment: (instructions: (key: must be or start with a valid key.).)") }) } diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 1b713be3..c00a98c0 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -2,202 +2,53 @@ package xrechnung import ( "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/org" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) -var validTypes = []cbc.Key{ - bill.InvoiceTypeStandard, - bill.InvoiceTypeCreditNote, - bill.InvoiceTypeCorrective, +// BR-DE-17 - restricted subset of UNTDID document type codes +var validInvoiceUNTDIDDocumentTypeValues = []tax.ExtValue{ + "326", // Partial + "380", // Commercial + "384", // Corrected + "389", // Self-billed + "381", // Credit note + "875", // Partial construction invoice + "876", // Partial Final construction invoice + "877", // Final construction invoice } -// ValidateInvoice validates the invoice according to the XRechnung standard -func ValidateInvoice(inv *bill.Invoice) error { +// validateInvoice validates the invoice according to the XRechnung standard +func validateInvoice(inv *bill.Invoice) error { return validation.ValidateStruct(inv, // BR-DE-17 - validation.Field(&inv.Type, - validation.By(validateInvoiceType), + validation.Field(&inv.Tax, + validation.By(validateInvoiceTax), validation.Skip, ), - // BR-DE-01 - validation.Field(&inv.Payment, - validation.Required, - validation.By(validatePayment), - validation.Skip, - ), - // BR-DE-15 - validation.Field(&inv.Ordering, - validation.Required, - validation.By(validateOrdering), - validation.Skip, - ), - validation.Field(&inv.Supplier, - validation.By(validateSupplier), - validation.Skip, - ), - validation.Field(&inv.Supplier, - validation.By(validateSupplierTaxInfo), - validation.Skip, - ), - validation.Field(&inv.Customer, - validation.By(validateCustomerReceiver), - validation.Skip, - ), - validation.Field(&inv.Delivery, - validation.By(validateDelivery), - validation.Skip, - ), - // BR-DE-26 validation.Field(&inv.Preceding, - validation.When(inv.Type.In(bill.InvoiceTypeCorrective), - validation.Required, - ), - ), - ) -} - -func validatePayment(value interface{}) error { - payment, ok := value.(*bill.Payment) - if !ok || payment == nil { - return nil - } - return validation.ValidateStruct(payment, - validation.Field(&payment.Instructions, - validation.Required, - validation.By(validatePaymentInstructions), - ), - ) -} - -func validateOrdering(value interface{}) error { - ordering, ok := value.(*bill.Ordering) - if !ok || ordering == nil { - return nil - } - return validation.ValidateStruct(ordering, - validation.Field(&ordering.Code, - validation.Required, - ), - ) -} - -func validateInvoiceType(value interface{}) error { - t, ok := value.(cbc.Key) - if !ok { - return validation.NewError("type", "invalid invoice type") - } - if !t.In(validTypes...) { - return validation.NewError("invalid", "invalid invoice type") - } - return nil -} - -func validateSupplier(value interface{}) error { - p, _ := value.(*org.Party) - if p == nil { - return nil - } - return validation.ValidateStruct(p, - // BR-DE-02 - validation.Field(&p.Name, - validation.Required, - ), - // BR-DE-03, BR-DE-04 - validation.Field(&p.Addresses, - validation.Required, - validation.Each(validation.By(validatePartyAddress)), - validation.Skip, - ), - // BR-DE-06 - validation.Field(&p.People, - validation.Required, - ), - // BR-DE-05 - validation.Field(&p.Telephones, - validation.Required, - ), - // BR-DE-07 - validation.Field(&p.Emails, - validation.Required, - ), - ) -} - -func validateSupplierTaxInfo(value interface{}) error { - supplier, ok := value.(*org.Party) - if !ok || supplier == nil { - return validation.NewError("invalid_supplier", "Supplier is invalid or nil") - } - - return validation.ValidateStruct(supplier, - validation.Field(&supplier.TaxID, - validation.When(supplier.Identities == nil || org.IdentityForKey(supplier.Identities, "de-tax-number") == nil, - validation.Required, - ), - ), - validation.Field(&supplier.Identities, - validation.When(supplier.TaxID == nil || supplier.TaxID.Code == "", + validation.When( + inv.Type.In( + bill.InvoiceTypeCorrective, + bill.InvoiceTypeCreditNote, + ), validation.Required, - validation.By(validateTaxNumber), - validation.Skip, ), - ), - ) -} - -func validateTaxNumber(value interface{}) error { - identities, ok := value.([]*org.Identity) - if !ok { - return validation.NewError("invalid_identities", "identities are invalid") - } - if org.IdentityForKey(identities, "de-tax-number") == nil { - return validation.NewError("missing_tax_identifier", "tax identifier (de-tax-number) is required") - } - return nil -} - -func validateDelivery(value interface{}) error { - d, _ := value.(*bill.Delivery) - if d == nil { - return nil - } - return validation.ValidateStruct(d, - validation.Field(&d.Receiver, - validation.By(validateCustomerReceiver), validation.Skip, ), ) } -// As the fields for customer and delivery reciver have the same requirements -// they are handled by the same validation function. -func validateCustomerReceiver(value interface{}) error { - p, _ := value.(*org.Party) - if p == nil { +func validateInvoiceTax(value any) error { + tx, ok := value.(*bill.Tax) + if !ok || tx == nil { return nil } - return validation.ValidateStruct(p, - // BR-DE-08, BR-DE-09 - validation.Field(&p.Addresses, - validation.Required, - validation.Each(validation.By(validatePartyAddress)), + return validation.ValidateStruct(tx, + validation.Field(&tx.Ext, + tax.ExtensionsHasValues(untdid.ExtKeyTaxCategory, validInvoiceUNTDIDDocumentTypeValues...), validation.Skip, ), ) } -func validatePartyAddress(value interface{}) error { - addr, _ := value.(*org.Address) - if addr == nil { - return nil - } - return validation.ValidateStruct(addr, - validation.Field(&addr.Locality, - validation.Required, - ), - validation.Field(&addr.Code, - validation.Required, - ), - ) -} diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go index 906de81a..6c07ed39 100644 --- a/addons/de/xrechnung/invoices_test.go +++ b/addons/de/xrechnung/invoices_test.go @@ -3,6 +3,7 @@ package xrechnung_test import ( "testing" + _ "github.com/invopop/gobl" "github.com/invopop/gobl/addons/de/xrechnung" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" @@ -17,7 +18,6 @@ import ( func testInvoiceStandard(t *testing.T) *bill.Invoice { t.Helper() - p := num.MakePercentage(19, 2) inv := &bill.Invoice{ Regime: tax.WithRegime("DE"), Addons: tax.WithAddons(xrechnung.V3), @@ -92,8 +92,7 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { Taxes: tax.Set{ { Category: "VAT", - // Rate: "standard", - Percent: &p, + Rate: "standard", }, }, Discounts: []*bill.LineDiscount{ @@ -131,10 +130,12 @@ func TestInvoiceValidation(t *testing.T) { inv := testInvoiceStandard(t) inv.Supplier.TaxID = nil require.NoError(t, inv.Calculate()) - errr := inv.Validate() - assert.ErrorContains(t, errr, "supplier: (identities: cannot be blank; tax_id: cannot be blank.).") + err := inv.Validate() + assert.ErrorContains(t, err, "supplier: (identities: missing key de-tax-number; tax_id: cannot be blank.).") }) t.Run("missing supplier tax ID but has tax number", func(t *testing.T) { + // this is validation is performed in the DE regime, but we're + // leaving it here for completeness. inv := testInvoiceStandard(t) inv.Supplier.TaxID = nil inv.Supplier.Identities = []*org.Identity{ @@ -148,111 +149,4 @@ func TestInvoiceValidation(t *testing.T) { assert.NoError(t, err) }) - t.Run("missing invoice type", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Type = "" - err := inv.Validate() - assert.ErrorContains(t, err, "type: cannot be blank.") - }) - - t.Run("missing payment instructions", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Payment.Instructions = nil - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "payment: (instructions: cannot be blank.).") - }) - - t.Run("missing ordering code", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Ordering.Code = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "ordering: (code: cannot be blank.).") - }) - - t.Run("missing supplier city", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Supplier.Addresses[0].Locality = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "supplier: (addresses: (0: (locality: cannot be blank.).).).") - }) - - t.Run("missing supplier postcode", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Supplier.Addresses[0].Code = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "supplier: (addresses: (0: (code: cannot be blank.).).).") - }) - - t.Run("missing customer city", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Customer.Addresses[0].Locality = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "customer: (addresses: (0: (locality: cannot be blank.).).).") - }) - - t.Run("missing customer postcode", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Customer.Addresses[0].Code = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "customer: (addresses: (0: (code: cannot be blank.).).).") - }) - - t.Run("missing supplier name", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Supplier.Name = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "supplier: (name: cannot be blank.)") - }) - - t.Run("missing delivery address", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Delivery = nil - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.NoError(t, err, "Delivery address should be optional") - }) - - t.Run("incomplete delivery address", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Delivery = &bill.Delivery{ - Receiver: &org.Party{ - Addresses: []*org.Address{ - { - Street: "Delivery Street", - Country: "DE", - }, - }, - }, - } - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "delivery: (receiver: (addresses: (0: (code: cannot be blank; locality: cannot be blank.).).).).") - }) - - t.Run("valid delivery address", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Delivery = &bill.Delivery{ - Receiver: &org.Party{ - Addresses: []*org.Address{ - { - Street: "Delivery Street", - Locality: "Delivery City", - Code: "12345", - Country: "DE", - }, - }, - }, - } - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.NoError(t, err, "Valid delivery address should not cause validation errors") - }) - } diff --git a/addons/de/xrechnung/scenarios.go b/addons/de/xrechnung/scenarios.go deleted file mode 100644 index 204868c8..00000000 --- a/addons/de/xrechnung/scenarios.go +++ /dev/null @@ -1,171 +0,0 @@ -package xrechnung - -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/tax" -) - -// Document tag keys -const ( - // Tags for invoice types - TagSelfBilled cbc.Key = "self-billed" - TagPartial cbc.Key = "partial" - TagPartialConstruction cbc.Key = "partial-construction" - TagPartialFinalConstruction cbc.Key = "partial-final-construction" - TagFinalConstruction cbc.Key = "final-construction" -) - -// Invoice type constants -const ( - invoiceTypeSelfBilled = "389" - invoiceTypePartial = "326" - invoiceTypePartialConstruction = "875" - invoiceTypePartialFinalConstruction = "876" - invoiceTypeFinalConstruction = "877" -) - -var invoiceTags = &tax.TagSet{ - Schema: bill.ShortSchemaInvoice, - List: []*cbc.KeyDefinition{ - { - Key: TagSelfBilled, - Name: i18n.String{ - i18n.EN: "Self-billed Invoice", - i18n.DE: "Gutschrift", - }, - }, - { - Key: TagPartial, - Name: i18n.String{ - i18n.EN: "Partial Invoice", - i18n.DE: "Abschlagsrechnung", - }, - }, - { - Key: TagPartialConstruction, - Name: i18n.String{ - i18n.EN: "Partial Construction Invoice", - i18n.DE: "Abschlagsrechnung (Bauleistung)", - }, - }, - { - Key: TagPartialFinalConstruction, - Name: i18n.String{ - i18n.EN: "Partial Final Construction Invoice", - i18n.DE: "Schlussrechnung (Bauleistung)", - }, - }, - { - Key: TagFinalConstruction, - Name: i18n.String{ - i18n.EN: "Final Construction Invoice", - i18n.DE: "Schlussrechnung", - }, - }, - }, -} - -var scenarios = []*tax.ScenarioSet{ - { - Schema: bill.ShortSchemaInvoice, - List: []*tax.Scenario{ - // ** Invoice Document Types ** - { - Types: []cbc.Key{ - bill.InvoiceTypeStandard, - bill.InvoiceTypeCorrective, - bill.InvoiceTypeCreditNote, - bill.InvoiceTypeDebitNote, - }, - }, - { - Tags: []cbc.Key{ - tax.TagSelfBilled, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypeSelfBilled), - }, - }, - { - Tags: []cbc.Key{ - tax.TagPartial, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypePartial), - }, - }, - { - Tags: []cbc.Key{ - tax.TagPartial, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypePartialConstruction), - }, - }, - { - Tags: []cbc.Key{ - tax.TagPartial, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypePartialFinalConstruction), - }, - }, - { - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypeFinalConstruction), - }, - }, - // ** Tax Rates ** - { - Tags: []cbc.Key{ - tax.RateStandard, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "S", - }, - }, - { - Tags: []cbc.Key{ - tax.RateZero, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "Z", - }, - }, - { - Tags: []cbc.Key{ - tax.RateExempt, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "E", - }, - }, - { - Tags: []cbc.Key{ - tax.TagReverseCharge, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "AE", - }, - }, - // TODO: Map Scenarios - { - Ext: tax.Extensions{ - ExtKeyTaxRate: "K", - }, - }, - { - Ext: tax.Extensions{ - ExtKeyTaxRate: "G", - }, - }, - { - Ext: tax.Extensions{ - ExtKeyTaxRate: "O", - }, - }, - }, - }, -} diff --git a/addons/de/xrechnung/tax_combo.go b/addons/de/xrechnung/tax_combo.go deleted file mode 100644 index 957172ca..00000000 --- a/addons/de/xrechnung/tax_combo.go +++ /dev/null @@ -1,51 +0,0 @@ -package xrechnung - -import ( - "github.com/invopop/gobl/tax" - "github.com/invopop/validation" -) - -// TaxRateExtensions returns the mapping of tax rates defined in DE -// to their extension values used by XRechnung. -func TaxRateExtensions() tax.Extensions { - return taxRateMap -} - -var taxRateMap = tax.Extensions{ - tax.RateStandard: "S", - tax.RateZero: "Z", - tax.RateExempt: "E", -} - -// NormalizeTaxCombo adds the XRechnung tax rate code to the tax combo. -func NormalizeTaxCombo(combo *tax.Combo) { - // copy the SAF-T tax rate code to the line - switch combo.Category { - case tax.CategoryVAT: - if combo.Rate.IsEmpty() { - return - } - k, ok := taxRateMap[combo.Rate] - if !ok { - return - } - if combo.Ext == nil { - combo.Ext = make(tax.Extensions) - } - combo.Ext[ExtKeyTaxRate] = k - } -} - -// ValidateTaxCombo validates percentage is included as BR-DE-14 indicates -func ValidateTaxCombo(tc *tax.Combo) error { - if tc == nil { - return nil - } - // BR-DE-14: Percentage required for VAT - return validation.ValidateStruct(tc, - validation.Field(&tc.Percent, - validation.When(tc.Category == tax.CategoryVAT, - validation.Required), - ), - ) -} diff --git a/addons/de/xrechnung/tax_combo_test.go b/addons/de/xrechnung/tax_combo_test.go deleted file mode 100644 index 48601f19..00000000 --- a/addons/de/xrechnung/tax_combo_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package xrechnung_test - -import ( - "testing" - - "github.com/invopop/gobl/addons/de/xrechnung" - "github.com/invopop/gobl/num" - "github.com/invopop/gobl/tax" - "github.com/stretchr/testify/assert" -) - -func TestTaxComboValidation(t *testing.T) { - t.Run("standard VAT rate", func(t *testing.T) { - p := num.MakePercentage(19, 2) - c := &tax.Combo{ - Category: tax.CategoryVAT, - Rate: tax.RateStandard, - Percent: &p, - } - xrechnung.NormalizeTaxCombo(c) - assert.NoError(t, xrechnung.ValidateTaxCombo(c)) - assert.Equal(t, "S", c.Ext[xrechnung.ExtKeyTaxRate].String()) - assert.Equal(t, "19%", c.Percent.String()) - }) - - t.Run("missing rate", func(t *testing.T) { - c := &tax.Combo{ - Category: tax.CategoryVAT, - Rate: tax.RateStandard, - } - err := xrechnung.ValidateTaxCombo(c) - assert.EqualError(t, err, "percent: cannot be blank.") - }) - -} diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 9ed4a02a..604a97c5 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -2,9 +2,11 @@ package xrechnung import ( + "github.com/invopop/gobl/addons/eu/en16931" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pay" "github.com/invopop/gobl/pkg/here" "github.com/invopop/gobl/tax" ) @@ -22,42 +24,36 @@ func newAddon() *tax.AddonDef { return &tax.AddonDef{ Key: V3, Name: i18n.String{ - i18n.EN: "German XRechnung 3.0.2", + i18n.EN: "German XRechnung 3.X", + }, + Requires: []cbc.Key{ + en16931.V2017, }, Description: i18n.String{ i18n.EN: here.Doc(` - Extensions to support the German XRechnung standard version 3.0.2 for electronic invoicing. - XRechnung is based on the European Norm (EN) 16931 and is mandatory for business-to-government - (B2G) invoices in Germany. This addon provides the necessary structures and validations to - ensure compliance with the XRechnung format. + Support for the German XRechnung version 3.X standard for electronic invoicing. + XRechnung is based on the European Norm (EN) 16931 and is mandatory for business-to-government + (B2G) invoices in Germany. This addon provides the necessary structures and validations to + ensure compliance with the XRechnung specifications. - For more information on XRechnung, visit: - https://www.xrechnung.de/ + For more information on XRechnung, visit [www.xrechnung.de](https://www.xrechnung.de/). `), }, - Tags: []*tax.TagSet{ - invoiceTags, - }, - Scenarios: scenarios, - Extensions: extensions, Normalizer: normalize, Validator: validate, } } func normalize(doc any) { - switch obj := doc.(type) { - case *tax.Combo: - NormalizeTaxCombo(obj) - } + // nothing to normalize yet } func validate(doc any) error { switch obj := doc.(type) { case *bill.Invoice: - return ValidateInvoice(obj) - case *tax.Combo: - return ValidateTaxCombo(obj) + return validateInvoice(obj) + case *pay.Instructions: + return validatePaymentInstructions(obj) } return nil } diff --git a/addons/eu/en16931/bill.go b/addons/eu/en16931/bill.go new file mode 100644 index 00000000..1e41ebff --- /dev/null +++ b/addons/eu/en16931/bill.go @@ -0,0 +1,31 @@ +package en16931 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +func validateBillInvoice(inv *bill.Invoice) error { + return validation.ValidateStruct(inv, + validation.Field(&inv.Tax, + validation.Required, + validation.By(validateBillInvoiceTax), + validation.Skip, + ), + ) +} + +func validateBillInvoiceTax(value any) error { + tx, ok := value.(*bill.Tax) + if !ok || tx == nil { + return nil + } + return validation.ValidateStruct(tx, + validation.Field(&tx.Ext, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + validation.Skip, + ), + ) +} diff --git a/addons/eu/en16931/bill_test.go b/addons/eu/en16931/bill_test.go new file mode 100644 index 00000000..a54abaec --- /dev/null +++ b/addons/eu/en16931/bill_test.go @@ -0,0 +1,116 @@ +package en16931_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/addons/eu/en16931" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "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) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("valid invoice", func(t *testing.T) { + inv := testInvoiceStandard(t) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "380", inv.Tax.Ext[untdid.ExtKeyDocumentType].String()) + err := inv.Validate() + assert.NoError(t, err) + }) + t.Run("missing tax", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Type = bill.InvoiceTypeOther + require.NoError(t, inv.Calculate()) + assert.Nil(t, inv.Tax) + err := ad.Validator(inv) + assert.ErrorContains(t, err, "tax: cannot be blank") + }) + t.Run("missing tax document type", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Type = bill.InvoiceTypeOther + inv.Tax = &bill.Tax{PricesInclude: "VAT"} + require.NoError(t, inv.Calculate()) + err := ad.Validator(inv) + assert.ErrorContains(t, err, "tax: (ext: (untdid-document-type: required.).)") + }) +} + +func testInvoiceStandard(t *testing.T) *bill.Invoice { + t.Helper() + inv := &bill.Invoice{ + Regime: tax.WithRegime("DE"), + Addons: tax.WithAddons(en16931.V2017), + Type: "standard", + Currency: "EUR", + Series: "2024", + Code: "1000", + Supplier: &org.Party{ + Name: "Cursor AG", + TaxID: &tax.Identity{ + Country: "DE", + Code: "505898911", + }, + People: []*org.Person{ + { + Name: &org.Name{ + Given: "Peter", + Surname: "Cursorstone", + }, + }, + }, + Addresses: []*org.Address{ + { + Street: "Dietmar-Hopp-Allee", + Locality: "Walldorf", + Code: "69190", + Country: "DE", + }, + }, + }, + Customer: &org.Party{ + Name: "Sample Consumer", + TaxID: &tax.Identity{ + Country: "DE", + Code: "449674701", + }, + People: []*org.Person{ + { + Name: &org.Name{ + Given: "Max", + Surname: "Musterman", + }, + }, + }, + Addresses: []*org.Address{ + { + Street: "Werner-Heisenberg-Allee", + Locality: "München", + Code: "80939", + Country: "DE", + }, + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(10, 0), + Item: &org.Item{ + Name: "Test Item", + Price: num.MakeAmount(10000, 2), + }, + Taxes: tax.Set{ + { + Category: tax.CategoryVAT, + Rate: "standard", + }, + }, + }, + }, + } + return inv +} diff --git a/addons/eu/en16931/en16931.go b/addons/eu/en16931/en16931.go new file mode 100644 index 00000000..5048ab4e --- /dev/null +++ b/addons/eu/en16931/en16931.go @@ -0,0 +1,66 @@ +// Package en16931 defines an addon that will apply rules from the EN 16931 specification to +// GOBL documents. +package en16931 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/tax" +) + +const ( + // V2017 is the key for the EN16931-1:2017 specification. + V2017 cbc.Key = "eu-en16931-v2017" +) + +func init() { + tax.RegisterAddonDef(newAddon()) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V2017, + Name: i18n.String{ + i18n.EN: "EN 16931-1:2017", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Support for the European Norm (EN) 16931-1:2017 standard for electronic invoicing. + + This addon ensures the basic rules and mappings are applied to the GOBL document + ensure that it is compliant and easily convertible to other formats. + `), + }, + Scenarios: scenarios, + Normalizer: normalize, + Validator: validate, + } +} + +func normalize(doc any) { + switch obj := doc.(type) { + case *pay.Advance: + normalizePayAdvance(obj) + case *pay.Instructions: + normalizePayInstructions(obj) + case *tax.Combo: + normalizeTaxCombo(obj) + } +} + +func validate(doc any) error { + switch obj := doc.(type) { + case *pay.Advance: + return validatePayAdvance(obj) + case *pay.Instructions: + return validatePayInstructions(obj) + case *bill.Invoice: + return validateBillInvoice(obj) + case *tax.Combo: + return validateTaxCombo(obj) + } + return nil +} diff --git a/addons/eu/en16931/pay.go b/addons/eu/en16931/pay.go new file mode 100644 index 00000000..05b24e05 --- /dev/null +++ b/addons/eu/en16931/pay.go @@ -0,0 +1,64 @@ +package en16931 + +import ( + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +var paymentMeansMap = tax.Extensions{ + pay.MeansKeyAny: "1", + pay.MeansKeyCard: "48", + pay.MeansKeyCreditTransfer: "30", + pay.MeansKeyDebitTransfer: "31", + pay.MeansKeyCash: "10", + pay.MeansKeyCheque: "20", + pay.MeansKeyBankDraft: "21", + pay.MeansKeyDirectDebit: "49", + pay.MeansKeyOnline: "68", + pay.MeansKeyPromissoryNote: "60", + pay.MeansKeyNetting: "97", + pay.MeansKeyCreditTransfer.With(pay.MeansKeySEPA): "58", + pay.MeansKeyDirectDebit.With(pay.MeansKeySEPA): "59", +} + +func normalizePayAdvance(adv *pay.Advance) { + if adv == nil { + return + } + if val, ok := paymentMeansMap[adv.Key]; ok { + adv.Ext = adv.Ext.Merge( + tax.Extensions{untdid.ExtKeyPaymentMeans: val}, + ) + } +} + +func validatePayAdvance(adv *pay.Advance) error { + return validation.ValidateStruct(adv, + validation.Field(&adv.Ext, + tax.ExtensionsRequires(untdid.ExtKeyPaymentMeans), + validation.Skip, + ), + ) +} + +func normalizePayInstructions(instr *pay.Instructions) { + if instr == nil { + return + } + if val, ok := paymentMeansMap[instr.Key]; ok { + instr.Ext = instr.Ext.Merge( + tax.Extensions{untdid.ExtKeyPaymentMeans: val}, + ) + } +} + +func validatePayInstructions(instr *pay.Instructions) error { + return validation.ValidateStruct(instr, + validation.Field(&instr.Ext, + tax.ExtensionsRequires(untdid.ExtKeyPaymentMeans), + validation.Skip, + ), + ) +} diff --git a/addons/eu/en16931/pay_test.go b/addons/eu/en16931/pay_test.go new file mode 100644 index 00000000..67c5f90c --- /dev/null +++ b/addons/eu/en16931/pay_test.go @@ -0,0 +1,82 @@ +package en16931_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/eu/en16931" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPayAdvances(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + + t.Run("valid advance", func(t *testing.T) { + adv := &pay.Advance{ + Key: pay.MeansKeyCreditTransfer, + } + ad.Normalizer(adv) + assert.Equal(t, "30", adv.Ext[untdid.ExtKeyPaymentMeans].String()) + }) + + t.Run("nil advance", func(t *testing.T) { + var adv *pay.Advance + assert.NotPanics(t, func() { + ad.Normalizer(adv) + }) + }) + + t.Run("validation", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Payment = &bill.Payment{ + Advances: []*pay.Advance{ + { + Key: pay.MeansKeyCreditTransfer, + Description: "Advance payment", + Percent: num.NewPercentage(100, 2), + }, + }, + } + require.NoError(t, inv.Calculate()) + assert.Equal(t, "30", inv.Payment.Advances[0].Ext[untdid.ExtKeyPaymentMeans].String()) + err := inv.Validate() + assert.NoError(t, err) + }) +} + +func TestPayInstructions(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + + t.Run("valid", func(t *testing.T) { + m := &pay.Instructions{ + Key: pay.MeansKeyCreditTransfer, + } + ad.Normalizer(m) + assert.Equal(t, "30", m.Ext[untdid.ExtKeyPaymentMeans].String()) + }) + + t.Run("nil", func(t *testing.T) { + var m *pay.Instructions + assert.NotPanics(t, func() { + ad.Normalizer(m) + }) + }) + + t.Run("validation", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: pay.MeansKeyCreditTransfer, + }, + } + require.NoError(t, inv.Calculate()) + assert.Equal(t, "30", inv.Payment.Instructions.Ext[untdid.ExtKeyPaymentMeans].String()) + err := inv.Validate() + assert.NoError(t, err) + }) +} diff --git a/addons/eu/en16931/scenarios.go b/addons/eu/en16931/scenarios.go new file mode 100644 index 00000000..90771e35 --- /dev/null +++ b/addons/eu/en16931/scenarios.go @@ -0,0 +1,85 @@ +package en16931 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +// Scenarios provides a list of scenarios related to the UNTDID addon +// that can be used inside other addons. +func Scenarios() []*tax.ScenarioSet { + return scenarios +} + +var scenarios = []*tax.ScenarioSet{ + { + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // ** Invoice Document Type Mappings for most common use cases ** + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "380", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "381", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeDebitNote, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "383", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeCorrective, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "384", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeProforma, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "325", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Tags: []cbc.Key{ + tax.TagPartial, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Tags: []cbc.Key{ + tax.TagSelfBilled, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "389", + }, + }, + }, + }, +} diff --git a/addons/eu/en16931/tax_combo.go b/addons/eu/en16931/tax_combo.go new file mode 100644 index 00000000..06a03aff --- /dev/null +++ b/addons/eu/en16931/tax_combo.go @@ -0,0 +1,52 @@ +package en16931 + +import ( + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/regimes/es" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +var taxCategoryMap = tax.Extensions{ + tax.RateStandard: "S", + tax.RateReduced: "S", // Same as standard + tax.RateZero: "Z", + tax.RateExempt: "E", + tax.RateExempt.With(tax.TagReverseCharge): "AE", +} + +func normalizeTaxCombo(tc *tax.Combo) { + switch tc.Category { + case tax.CategoryVAT: + if tc.Rate.IsEmpty() { + return + } + k, ok := taxCategoryMap[tc.Rate] + if !ok { + return + } + tc.Ext = tc.Ext.Merge( + tax.Extensions{untdid.ExtKeyTaxCategory: k}, + ) + case es.TaxCategoryIGIC: + tc.Ext = tc.Ext.Merge( + tax.Extensions{untdid.ExtKeyTaxCategory: "L"}, + ) + case es.TaxCategoryIPSI: + tc.Ext = tc.Ext.Merge( + tax.Extensions{untdid.ExtKeyTaxCategory: "M"}, + ) + } +} + +func validateTaxCombo(tc *tax.Combo) error { + if tc == nil { + return nil + } + return validation.ValidateStruct(tc, + validation.Field(&tc.Ext, + tax.ExtensionsRequires(untdid.ExtKeyTaxCategory), + validation.Skip, + ), + ) +} diff --git a/addons/eu/en16931/tax_combo_test.go b/addons/eu/en16931/tax_combo_test.go new file mode 100644 index 00000000..943588f0 --- /dev/null +++ b/addons/eu/en16931/tax_combo_test.go @@ -0,0 +1,98 @@ +package en16931_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/eu/en16931" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/regimes/es" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestTaxComboNormalization(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("standard VAT rate", func(t *testing.T) { + p := num.MakePercentage(19, 2) + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + Percent: &p, + } + ad.Normalizer(c) + assert.Equal(t, "S", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "19%", c.Percent.String()) + }) + + t.Run("unkown rate", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: cbc.Key("unknown"), + Percent: num.NewPercentage(19, 2), + } + ad.Normalizer(c) + assert.Empty(t, c.Ext) + }) + t.Run("IGIC", func(t *testing.T) { + c := &tax.Combo{ + Category: es.TaxCategoryIGIC, + Percent: num.NewPercentage(7, 2), + } + ad.Normalizer(c) + assert.Equal(t, "L", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "7%", c.Percent.String()) + }) + + t.Run("IPSI", func(t *testing.T) { + c := &tax.Combo{ + Category: es.TaxCategoryIPSI, + Percent: num.NewPercentage(7, 2), + } + ad.Normalizer(c) + assert.Equal(t, "M", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "7%", c.Percent.String()) + }) + + t.Run("missing rate", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + } + ad.Normalizer(c) + assert.Empty(t, c.Ext) + }) +} + +func TestTaxComboValidation(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("standard VAT rate", func(t *testing.T) { + p := num.MakePercentage(19, 2) + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + Percent: &p, + } + ad.Normalizer(c) + assert.NoError(t, ad.Validator(c)) + assert.Equal(t, "S", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "19%", c.Percent.String()) + }) + + t.Run("nil", func(t *testing.T) { + var tc *tax.Combo + err := ad.Validator(tc) + assert.NoError(t, err) + }) + + t.Run("missing rate", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Percent: num.NewPercentage(19, 2), + } + ad.Normalizer(c) + err := ad.Validator(c) + assert.ErrorContains(t, err, "ext: (untdid-tax-category: required.)") + }) + +} diff --git a/bill/invoice.go b/bill/invoice.go index b915d8fe..4842ea79 100644 --- a/bill/invoice.go +++ b/bill/invoice.go @@ -266,7 +266,7 @@ func (inv *Invoice) Calculate() error { inv.SetRegime(inv.supplierTaxCountry()) } - inv.Normalize(inv.normalizers()) + inv.Normalize(tax.ExtractNormalizers(inv)) if err := inv.calculate(); err != nil { return err @@ -301,23 +301,12 @@ func (inv *Invoice) Normalize(normalizers tax.Normalizers) { tax.Normalize(normalizers, inv.Payment) } -func (inv *Invoice) normalizers() tax.Normalizers { - normalizers := make(tax.Normalizers, 0) - if r := inv.RegimeDef(); r != nil { - normalizers = normalizers.Append(r.Normalizer) - } - for _, a := range inv.GetAddonDefs() { - normalizers = normalizers.Append(a.Normalizer) - } - return normalizers -} - func (inv *Invoice) supportedTags() []cbc.Key { var ts *tax.TagSet if r := inv.RegimeDef(); r != nil { ts = ts.Merge(tax.TagSetForSchema(r.Tags, ShortSchemaInvoice)) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { ts = ts.Merge(tax.TagSetForSchema(a.Tags, ShortSchemaInvoice)) } return ts.Keys() @@ -329,7 +318,7 @@ func (inv *Invoice) ValidationContext(ctx context.Context) context.Context { if r := inv.RegimeDef(); r != nil { ctx = r.WithContext(ctx) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { ctx = a.WithContext(ctx) } return ctx diff --git a/bill/invoice_correct.go b/bill/invoice_correct.go index 01af9304..82cece22 100644 --- a/bill/invoice_correct.go +++ b/bill/invoice_correct.go @@ -314,7 +314,7 @@ func (inv *Invoice) correctionDef() *tax.CorrectionDefinition { if r != nil { cd = cd.Merge(r.Corrections.Def(ShortSchemaInvoice)) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { cd = cd.Merge(a.Corrections.Def(ShortSchemaInvoice)) } diff --git a/bill/invoice_scenarios.go b/bill/invoice_scenarios.go index dddd3f50..32b1116c 100644 --- a/bill/invoice_scenarios.go +++ b/bill/invoice_scenarios.go @@ -43,7 +43,7 @@ func (inv *Invoice) scenarioSummary() *tax.ScenarioSummary { if r := inv.RegimeDef(); r != nil { ss.Merge(r.Scenarios) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { ss.Merge(a.Scenarios) } diff --git a/catalogues/catalogues.go b/catalogues/catalogues.go new file mode 100644 index 00000000..19e3534c --- /dev/null +++ b/catalogues/catalogues.go @@ -0,0 +1,10 @@ +// Package catalogues provides a set of re-useable extensions, scenarios, and validators +// for specific international standards that can be re-used and incorporated by addons +// or tax regimes. +package catalogues + +import ( + // Ensure all the catalogues are registered + _ "github.com/invopop/gobl/catalogues/iso" + _ "github.com/invopop/gobl/catalogues/untdid" +) diff --git a/catalogues/generate.go b/catalogues/generate.go new file mode 100644 index 00000000..4edd9abf --- /dev/null +++ b/catalogues/generate.go @@ -0,0 +1,40 @@ +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/schema" + "github.com/invopop/gobl/tax" +) + +func main() { + if error := generate(); error != nil { + panic(error) + } +} + +func generate() error { + for _, cd := range tax.AllCatalogueDefs() { + doc, err := schema.NewObject(cd) + if err != nil { + return err + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + n := string(cd.Key) + f := filepath.Join("data", "catalogues", n+".json") + if err := os.WriteFile(f, data, 0644); err != nil { + return err + } + fmt.Printf("Processed %v\n", f) + } + return nil +} diff --git a/catalogues/iso/extensions.go b/catalogues/iso/extensions.go new file mode 100644 index 00000000..6497ebc7 --- /dev/null +++ b/catalogues/iso/extensions.go @@ -0,0 +1,28 @@ +package iso + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeySchemeID is used by the ISO 6523 scheme identifier. + ExtKeySchemeID cbc.Key = "iso-scheme-id" +) + +var extensions = []*cbc.KeyDefinition{ + { + Key: ExtKeySchemeID, + Name: i18n.NewString("ISO/IEC 6523 Identifier scheme code"), + Desc: i18n.NewString(here.Doc(` + Defines a global structure for uniquely identifying organizations or entities. + This standard is essential in environments where electronic communications require + unambiguous identification of organizations, especially in automated systems or + electronic data interchange (EDI). + + The ISO 6523 set of identifies is used by the EN16931 standard for electronic invoicing. + `)), + Pattern: `^\d{4}$`, + }, +} diff --git a/catalogues/iso/iso.go b/catalogues/iso/iso.go new file mode 100644 index 00000000..1c6b4cfe --- /dev/null +++ b/catalogues/iso/iso.go @@ -0,0 +1,20 @@ +// Package iso is used to define ISO/IEC extensions and codes that may be used +// in documents. +package iso + +import ( + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterCatalogueDef(newCatalogue()) +} + +func newCatalogue() *tax.CatalogueDef { + return &tax.CatalogueDef{ + Key: "iso", + Name: i18n.NewString("ISO/IEC Data Elements"), + Extensions: extensions, + } +} diff --git a/catalogues/iso/iso_test.go b/catalogues/iso/iso_test.go new file mode 100644 index 00000000..2a229c35 --- /dev/null +++ b/catalogues/iso/iso_test.go @@ -0,0 +1,14 @@ +package iso_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestInit(t *testing.T) { + ext := tax.ExtensionForKey("iso-scheme-id") + assert.NotNil(t, ext) +} diff --git a/catalogues/untdid/extensions.go b/catalogues/untdid/extensions.go new file mode 100644 index 00000000..ed2b66d8 --- /dev/null +++ b/catalogues/untdid/extensions.go @@ -0,0 +1,746 @@ +package untdid + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeyDocumentType is used to identify the UNTDID 1001 document type code. + ExtKeyDocumentType cbc.Key = "untdid-document-type" + // ExtKeyPaymentMeans is used to identify the UNTDID 4461 payment means code. + ExtKeyPaymentMeans cbc.Key = "untdid-payment-means" + // ExtKeyTaxCategory is used to identify the UNTDID 5305 duty/tax/fee category code. + ExtKeyTaxCategory cbc.Key = "untdid-tax-category" +) + +var extensions = []*cbc.KeyDefinition{ + { + Key: ExtKeyDocumentType, + Name: i18n.String{ + i18n.EN: "UNTDID 1001 Document Type", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 1001 code used to describe the type of document. Ths list is based + on the [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists) + values table which focusses on invoices and payments. + + Other tax regimes and addons may use their own subset of codes. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "71", + Name: i18n.NewString("Request for payment"), + }, + { + Value: "80", + Name: i18n.NewString("Debit note related to goods or services"), + }, + { + Value: "81", + Name: i18n.NewString("Credit note related to goods or services"), + }, + { + Value: "82", + Name: i18n.NewString("Metered services invoice"), + }, + { + Value: "83", + Name: i18n.NewString("Credit note related to financial adjustments"), + }, + { + Value: "84", + Name: i18n.NewString("Debit note related to financial adjustments"), + }, + { + Value: "102", + Name: i18n.NewString("Tax notification"), + }, + { + Value: "130", + Name: i18n.NewString("Invoicing data sheet"), + }, + { + Value: "202", + Name: i18n.NewString("Direct payment valuation"), + }, + { + Value: "203", + Name: i18n.NewString("Provisional payment valuation"), + }, + { + Value: "204", + Name: i18n.NewString("Payment valuation"), + }, + { + Value: "211", + Name: i18n.NewString("Interim application for payment"), + }, + { + Value: "218", + Name: i18n.NewString("Final payment request based on completion of work"), + }, + { + Value: "219", + Name: i18n.NewString("Payment request for completed units"), + }, + { + Value: "261", + Name: i18n.NewString("Self billed credit note"), + }, + { + Value: "262", + Name: i18n.NewString("Consolidated credit note - goods and services"), + }, + { + Value: "295", + Name: i18n.NewString("Price variation invoice"), + }, + { + Value: "296", + Name: i18n.NewString("Credit note for price variation"), + }, + { + Value: "308", + Name: i18n.NewString("Delcredere credit note"), + }, + { + Value: "325", + Name: i18n.NewString("Proforma invoice"), + }, + { + Value: "326", + Name: i18n.NewString("Partial invoice"), + }, + { + Value: "380", + Name: i18n.NewString("Standard Invoice"), + }, + { + Value: "381", + Name: i18n.NewString("Credit note"), + }, + { + Value: "382", + Name: i18n.NewString("Commission note"), + }, + { + Value: "383", + Name: i18n.NewString("Debit note"), + }, + { + Value: "384", + Name: i18n.NewString("Corrected invoice"), + }, + { + Value: "385", + Name: i18n.NewString("Consolidated invoice"), + }, + { + Value: "386", + Name: i18n.NewString("Prepayment invoice"), + }, + { + Value: "387", + Name: i18n.NewString("Hire invoice"), + }, + { + Value: "388", + Name: i18n.NewString("Tax invoice"), + }, + { + Value: "389", + Name: i18n.NewString("Self-billed invoice"), + }, + { + Value: "390", + Name: i18n.NewString("Delcredere invoice"), + }, + { + Value: "393", + Name: i18n.NewString("Factored invoice"), + }, + { + Value: "394", + Name: i18n.NewString("Lease invoice"), + }, + { + Value: "395", + Name: i18n.NewString("Consignment invoice"), + }, + { + Value: "396", + Name: i18n.NewString("Factored credit note"), + }, + { + Value: "420", + Name: i18n.NewString("Optical Character Reading (OCR) payment credit note"), + }, + { + Value: "456", + Name: i18n.NewString("Debit advice"), + }, + { + Value: "457", + Name: i18n.NewString("Reversal of debit"), + }, + { + Value: "458", + Name: i18n.NewString("Reversal of credit"), + }, + { + Value: "527", + Name: i18n.NewString("Self billed debit note"), + }, + { + Value: "532", + Name: i18n.NewString("Forwarder's credit note"), + }, + { + Value: "553", + Name: i18n.NewString("Forwarder's invoice discrepancy report"), + }, + { + Value: "575", + Name: i18n.NewString("Insurer's invoice"), + }, + { + Value: "623", + Name: i18n.NewString("Forwarder's invoice"), + }, + { + Value: "633", + Name: i18n.NewString("Port charges documents"), + }, + { + Value: "751", + Name: i18n.NewString("Invoice information for accounting purposes"), + }, + { + Value: "780", + Name: i18n.NewString("Freight invoice"), + }, + { + Value: "817", + Name: i18n.NewString("Claim notification"), + }, + { + Value: "870", + Name: i18n.NewString("Consular invoice"), + }, + { + Value: "875", + Name: i18n.NewString("Partial construction invoice"), + }, + { + Value: "876", + Name: i18n.NewString("Partial final construction invoice"), + }, + { + Value: "877", + Name: i18n.NewString("Final construction invoice"), + }, + { + Value: "935", + Name: i18n.NewString("Customs invoice"), + }, + }, + }, + { + Key: ExtKeyPaymentMeans, + Name: i18n.String{ + i18n.EN: "UNTDID 4461 Payment Means", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 4461 code used to describe the means of payment. This list is based on the + [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists) + values table which focusses on invoices and payments. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "1", + Name: i18n.NewString("Instrument not defined"), + }, + { + Value: "2", + Name: i18n.NewString("Automated clearing house credit"), + }, + { + Value: "3", + Name: i18n.NewString("Automated clearing house debit"), + }, + { + Value: "4", + Name: i18n.NewString("ACH demand debit reversal"), + }, + { + Value: "5", + Name: i18n.NewString("ACH demand credit reversal"), + }, + { + Value: "6", + Name: i18n.NewString("ACH demand credit"), + }, + { + Value: "7", + Name: i18n.NewString("ACH demand debit"), + }, + { + Value: "8", + Name: i18n.NewString("Hold"), + }, + { + Value: "9", + Name: i18n.NewString("National or regional clearing"), + }, + { + Value: "10", + Name: i18n.NewString("In cash"), + }, + { + Value: "11", + Name: i18n.NewString("ACH savings credit reversal"), + }, + { + Value: "12", + Name: i18n.NewString("ACH savings debit reversal"), + }, + { + Value: "13", + Name: i18n.NewString("ACH savings credit"), + }, + { + Value: "14", + Name: i18n.NewString("ACH savings debit"), + }, + { + Value: "15", + Name: i18n.NewString("Bookentry credit"), + }, + { + Value: "16", + Name: i18n.NewString("Bookentry debit"), + }, + { + Value: "17", + Name: i18n.NewString("ACH demand cash concentration/disbursement (CCD) credit"), + }, + { + Value: "18", + Name: i18n.NewString("ACH demand cash concentration/disbursement (CCD) debit"), + }, + { + Value: "19", + Name: i18n.NewString("ACH demand corporate trade payment (CTP) credit"), + }, + { + Value: "20", + Name: i18n.NewString("Cheque"), + }, + { + Value: "21", + Name: i18n.NewString("Banker's draft"), + }, + { + Value: "22", + Name: i18n.NewString("Certified banker's draft"), + }, + { + Value: "23", + Name: i18n.NewString("Bank cheque (issued by a banking or similar establishment)"), + }, + { + Value: "24", + Name: i18n.NewString("Bill of exchange awaiting acceptance"), + }, + { + Value: "25", + Name: i18n.NewString("Certified cheque"), + }, + { + Value: "26", + Name: i18n.NewString("Local cheque"), + }, + { + Value: "27", + Name: i18n.NewString("ACH demand corporate trade payment (CTP) debit"), + }, + { + Value: "28", + Name: i18n.NewString("ACH demand corporate trade exchange (CTX) credit"), + }, + { + Value: "29", + Name: i18n.NewString("ACH demand corporate trade exchange (CTX) debit"), + }, + { + Value: "30", + Name: i18n.NewString("Credit transfer"), + }, + { + Value: "31", + Name: i18n.NewString("Debit transfer"), + }, + { + Value: "32", + Name: i18n.NewString("ACH demand cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "33", + Name: i18n.NewString("ACH demand cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "34", + Name: i18n.NewString("ACH prearranged payment and deposit (PPD)"), + }, + { + Value: "35", + Name: i18n.NewString("ACH savings cash concentration/disbursement (CCD) credit"), + }, + { + Value: "36", + Name: i18n.NewString("ACH savings cash concentration/disbursement (CCD) debit"), + }, + { + Value: "37", + Name: i18n.NewString("ACH savings corporate trade payment (CTP) credit"), + }, + { + Value: "38", + Name: i18n.NewString("ACH savings corporate trade payment (CTP) debit"), + }, + { + Value: "39", + Name: i18n.NewString("ACH savings corporate trade exchange (CTX) credit"), + }, + { + Value: "40", + Name: i18n.NewString("ACH savings corporate trade exchange (CTX) debit"), + }, + { + Value: "41", + Name: i18n.NewString("ACH savings cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "42", + Name: i18n.NewString("Payment to bank account"), + }, + { + Value: "43", + Name: i18n.NewString("ACH savings cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "44", + Name: i18n.NewString("Accepted bill of exchange"), + }, + { + Value: "45", + Name: i18n.NewString("Referenced home-banking credit transfer"), + }, + { + Value: "46", + Name: i18n.NewString("Interbank debit transfer"), + }, + { + Value: "47", + Name: i18n.NewString("Home-banking debit transfer"), + }, + { + Value: "48", + Name: i18n.NewString("Bank card"), + }, + { + Value: "49", + Name: i18n.NewString("Direct debit"), + }, + { + Value: "50", + Name: i18n.NewString("Payment by postgiro"), + }, + { + Value: "51", + Name: i18n.NewString("FR, norme 6 97-Telereglement CFONB (French Organisation for"), + }, + { + Value: "52", + Name: i18n.NewString("Urgent commercial payment"), + }, + { + Value: "53", + Name: i18n.NewString("Urgent Treasury Payment"), + }, + { + Value: "54", + Name: i18n.NewString("Credit card"), + }, + { + Value: "55", + Name: i18n.NewString("Debit card"), + }, + { + Value: "56", + Name: i18n.NewString("Bankgiro"), + }, + { + Value: "57", + Name: i18n.NewString("Standing agreement"), + }, + { + Value: "58", + Name: i18n.NewString("SEPA credit transfer"), + }, + { + Value: "59", + Name: i18n.NewString("SEPA direct debit"), + }, + { + Value: "60", + Name: i18n.NewString("Promissory note"), + }, + { + Value: "61", + Name: i18n.NewString("Promissory note signed by the debtor"), + }, + { + Value: "62", + Name: i18n.NewString("Promissory note signed by the debtor and endorsed by a bank"), + }, + { + Value: "63", + Name: i18n.NewString("Promissory note signed by the debtor and endorsed by a"), + }, + { + Value: "64", + Name: i18n.NewString("Promissory note signed by a bank"), + }, + { + Value: "65", + Name: i18n.NewString("Promissory note signed by a bank and endorsed by another"), + }, + { + Value: "66", + Name: i18n.NewString("Promissory note signed by a third party"), + }, + { + Value: "67", + Name: i18n.NewString("Promissory note signed by a third party and endorsed by a"), + }, + { + Value: "68", + Name: i18n.NewString("Online payment service"), + }, + { + Value: "69", + Name: i18n.NewString("Transfer Advice"), + }, + { + Value: "70", + Name: i18n.NewString("Bill drawn by the creditor on the debtor"), + }, + { + Value: "74", + Name: i18n.NewString("Bill drawn by the creditor on a bank"), + }, + { + Value: "75", + Name: i18n.NewString("Bill drawn by the creditor, endorsed by another bank"), + }, + { + Value: "76", + Name: i18n.NewString("Bill drawn by the creditor on a bank and endorsed by a"), + }, + { + Value: "77", + Name: i18n.NewString("Bill drawn by the creditor on a third party"), + }, + { + Value: "78", + Name: i18n.NewString("Bill drawn by creditor on third party, accepted and"), + }, + { + Value: "91", + Name: i18n.NewString("Not transferable banker's draft"), + }, + { + Value: "92", + Name: i18n.NewString("Not transferable local cheque"), + }, + { + Value: "93", + Name: i18n.NewString("Reference giro"), + }, + { + Value: "94", + Name: i18n.NewString("Urgent giro"), + }, + { + Value: "95", + Name: i18n.NewString("Free format giro"), + }, + { + Value: "96", + Name: i18n.NewString("Requested method for payment was not used"), + }, + { + Value: "97", + Name: i18n.NewString("Clearing between partners"), + }, + { + Value: "98", + Name: i18n.NewString("JP, Electronically Recorded Monetary Claims"), + }, + { + Value: "ZZZ", + Name: i18n.NewString("Mutually defined"), + }, + }, + }, + { + Key: ExtKeyTaxCategory, + Name: i18n.String{ + i18n.EN: "UNTDID 3505 Tax Category", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 5305 code used to describe the applicable duty/tax/fee category. There are + multiple versions and subsets of this table so regimes and addons may need to filter + options for a specific subset of values. + + Data from https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5305.htm. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "A", + Name: i18n.String{ + i18n.EN: "Mixed tax rate", + }, + }, + { + Value: "AA", + Name: i18n.String{ + i18n.EN: "Lower rate", + }, + }, + { + Value: "AB", + Name: i18n.String{ + i18n.EN: "Exempt for resale", + }, + }, + { + Value: "AC", + Name: i18n.String{ + i18n.EN: "Exempt for resale", + }, + }, + { + Value: "AD", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) due from a previous invoice", + }, + }, + { + Value: "AE", + Name: i18n.String{ + i18n.EN: "VAT Reverse Charge", + }, + }, + { + Value: "B", + Name: i18n.String{ + i18n.EN: "Transferred (VAT)", + }, + }, + { + Value: "C", + Name: i18n.String{ + i18n.EN: "Duty paid by supplier", + }, + }, + { + Value: "D", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - travel agents", + }, + }, + { + Value: "E", + Name: i18n.String{ + i18n.EN: "Exempt from tax", + }, + }, + { + Value: "F", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - second-hand goods", + }, + }, + { + Value: "G", + Name: i18n.String{ + i18n.EN: "Free export item, tax not charged", + }, + }, + { + Value: "H", + Name: i18n.String{ + i18n.EN: "Higher rate", + }, + }, + { + Value: "I", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - works of art", + }, + }, + { + Value: "J", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - collector's items and antiques", + }, + }, + { + Value: "K", + Name: i18n.String{ + i18n.EN: "VAT exempt for EEA intra-community supply of goods and services", + }, + }, + { + Value: "L", + Name: i18n.String{ + i18n.EN: "Canary Islands general indirect tax", + }, + }, + { + Value: "M", + Name: i18n.String{ + i18n.EN: "Tax for production, services and importation in Ceuta and Melilla", + }, + }, + { + Value: "O", + Name: i18n.String{ + i18n.EN: "Services outside scope of tax", + }, + }, + { + Value: "S", + Name: i18n.String{ + i18n.EN: "Standard Rate", + }, + }, + { + Value: "Z", + Name: i18n.String{ + i18n.EN: "Zero rated goods", + }, + }, + }, + }, +} diff --git a/catalogues/untdid/untdid.go b/catalogues/untdid/untdid.go new file mode 100644 index 00000000..57cfdf67 --- /dev/null +++ b/catalogues/untdid/untdid.go @@ -0,0 +1,19 @@ +// Package untdid defines the UN/EDIFACT data elements contained in the UNTDID (United Nations Trade Data Interchange Directory). +package untdid + +import ( + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterCatalogueDef(newCatalogue()) +} + +func newCatalogue() *tax.CatalogueDef { + return &tax.CatalogueDef{ + Key: "untdid", + Name: i18n.NewString("UN/EDIFACT Data Elements"), + Extensions: extensions, + } +} diff --git a/catalogues/untdid/untdid_test.go b/catalogues/untdid/untdid_test.go new file mode 100644 index 00000000..6b415558 --- /dev/null +++ b/catalogues/untdid/untdid_test.go @@ -0,0 +1,14 @@ +package untdid_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestInit(t *testing.T) { + ext := tax.ExtensionForKey("untdid-tax-category") + assert.NotNil(t, ext) +} diff --git a/cbc/code.go b/cbc/code.go index 28da0ac7..34a67f57 100644 --- a/cbc/code.go +++ b/cbc/code.go @@ -41,8 +41,10 @@ var ( ) var ( - codeSeparatorRegexp = regexp.MustCompile(`([\.\-\/ _])[^A-Za-z0-9]+`) - codeInvalidCharsRegexp = regexp.MustCompile(`[^A-Za-z0-9\.\-\/ _]`) + codeSeparatorRegexp = regexp.MustCompile(`([\.\-\/ _])[^A-Za-z0-9]+`) + codeInvalidCharsRegexp = regexp.MustCompile(`[^A-Za-z0-9\.\-\/ _]`) + codeNonAlphanumericalRegexp = regexp.MustCompile(`[^A-Z\d]`) + codeNonNumericalRegexp = regexp.MustCompile(`[^\d]`) ) // CodeEmpty is used when no code is defined. @@ -58,6 +60,24 @@ func NormalizeCode(c Code) Code { return Code(code) } +// NormalizeAlphanumericalCode cleans and normalizes the code, +// ensuring all letters are uppercase while also removing +// non-alphanumerical characters. +func NormalizeAlphanumericalCode(c Code) Code { + code := NormalizeCode(c).String() + code = strings.ToUpper(code) + code = codeNonAlphanumericalRegexp.ReplaceAllString(code, "") + return Code(code) +} + +// NormalizeNumericalCode cleans and normalizes the code, while also +// removing non-numerical characters. +func NormalizeNumericalCode(c Code) Code { + code := NormalizeCode(c).String() + code = codeNonNumericalRegexp.ReplaceAllString(code, "") + return Code(code) +} + // Validate ensures that the code complies with the expected rules. func (c Code) Validate() error { return validation.Validate(string(c), diff --git a/cbc/code_test.go b/cbc/code_test.go index 7de0649e..45505643 100644 --- a/cbc/code_test.go +++ b/cbc/code_test.go @@ -109,7 +109,134 @@ func TestNormalizeCode(t *testing.T) { assert.Equal(t, tt.want, cbc.NormalizeCode(tt.code)) }) } +} +func TestNormalizeAlphanumericalCode(t *testing.T) { + tests := []struct { + name string + code cbc.Code + want cbc.Code + }{ + { + name: "uppercase", + code: cbc.Code("FOO"), + want: cbc.Code("FOO"), + }, + { + name: "lowercase", + code: cbc.Code("foo"), + want: cbc.Code("FOO"), + }, + { + name: "mixed case", + code: cbc.Code("Foo"), + want: cbc.Code("FOO"), + }, + { + name: "with spaces", + code: cbc.Code("FOO BAR"), + want: cbc.Code("FOOBAR"), + }, + { + name: "empty", + code: cbc.Code(""), + want: cbc.Code(""), + }, + { + name: "underscore", + code: cbc.Code("FOO_BAR"), + want: cbc.Code("FOOBAR"), + }, + { + name: "whitespace", + code: cbc.Code(" foo-bar1 "), + want: cbc.Code("FOOBAR1"), + }, + { + name: "invalid chars", + code: cbc.Code("f$oo-bar1!"), + want: cbc.Code("FOOBAR1"), + }, + { + name: "multiple spaces", + code: cbc.Code("foo bar dome"), + want: cbc.Code("FOOBARDOME"), + }, + { + name: "multiple symbols 1", + code: cbc.Code("foo- bar-$dome"), + want: cbc.Code("FOOBARDOME"), + }, + { + name: "multiple symbols 2", + code: cbc.Code("FOO BAR--DOME"), + want: cbc.Code("FOOBARDOME"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, cbc.NormalizeAlphanumericalCode(tt.code)) + }) + } +} + +func TestNormalizeNumericalCode(t *testing.T) { + tests := []struct { + name string + code cbc.Code + want cbc.Code + }{ + { + name: "letters", + code: cbc.Code("FOO"), + want: cbc.Code(""), + }, + { + name: "numbers", + code: cbc.Code("1234"), + want: cbc.Code("1234"), + }, + { + name: "mixed case", + code: cbc.Code("Foo1234"), + want: cbc.Code("1234"), + }, + { + name: "with spaces", + code: cbc.Code("12 34"), + want: cbc.Code("1234"), + }, + { + name: "empty", + code: cbc.Code(""), + want: cbc.Code(""), + }, + { + name: "underscore", + code: cbc.Code("12_34"), + want: cbc.Code("1234"), + }, + { + name: "whitespace", + code: cbc.Code(" 345 "), + want: cbc.Code("345"), + }, + { + name: "invalid chars", + code: cbc.Code("f$oo-bar1!"), + want: cbc.Code("1"), + }, + { + name: "multiple spaces", + code: cbc.Code("1 2 3 4"), + want: cbc.Code("1234"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, cbc.NormalizeNumericalCode(tt.code)) + }) + } } func TestCode_Validate(t *testing.T) { diff --git a/cbc/key.go b/cbc/key.go index f2d6ffb5..ef571bd4 100644 --- a/cbc/key.go +++ b/cbc/key.go @@ -102,6 +102,17 @@ func (k Key) IsEmpty() bool { return k == KeyEmpty } +// AppendUniqueKeys is a convenience method to append keys to a list ensuring +// that any existing keys are not re-added. +func AppendUniqueKeys(keys []Key, key ...Key) []Key { + for _, k := range key { + if !k.In(keys...) { + keys = append(keys, k) + } + } + return keys +} + // HasValidKeyIn provides a validator to check the Key's // value is within the provided known set. func HasValidKeyIn(keys ...Key) validation.Rule { diff --git a/cbc/key_test.go b/cbc/key_test.go index 8d70ba36..928a2a09 100644 --- a/cbc/key_test.go +++ b/cbc/key_test.go @@ -65,3 +65,9 @@ func TestKeyIn(t *testing.T) { assert.True(t, c.In("pro", "reduced+eqs", "standard")) assert.False(t, c.In("pro", "reduced")) } + +func TestAppendUniqueKeys(t *testing.T) { + keys := []cbc.Key{"a", "b", "c"} + keys = cbc.AppendUniqueKeys(keys, "b", "d") + assert.Equal(t, []cbc.Key{"a", "b", "c", "d"}, keys) +} diff --git a/data/addons/de-xrechnung-v3.json b/data/addons/de-xrechnung-v3.json new file mode 100644 index 00000000..5e66dd25 --- /dev/null +++ b/data/addons/de-xrechnung-v3.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/addon-def", + "key": "de-xrechnung-v3", + "requires": [ + "eu-en16931-v2017" + ], + "name": { + "en": "German XRechnung 3.X" + }, + "description": { + "en": "Support for the German XRechnung version 3.X standard for electronic invoicing.\nXRechnung is based on the European Norm (EN) 16931 and is mandatory for business-to-government\n(B2G) invoices in Germany. This addon provides the necessary structures and validations to\nensure compliance with the XRechnung specifications.\n\nFor more information on XRechnung, visit [www.xrechnung.de](https://www.xrechnung.de/)." + }, + "extensions": null, + "scenarios": null, + "corrections": null +} \ No newline at end of file diff --git a/data/catalogues/iso.json b/data/catalogues/iso.json new file mode 100644 index 00000000..92f4a66c --- /dev/null +++ b/data/catalogues/iso.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/catalogue-def", + "key": "iso", + "name": { + "en": "ISO/IEC Data Elements" + }, + "description": null, + "extensions": [ + { + "key": "iso-scheme-id", + "name": { + "en": "ISO/IEC 6523 Identifier scheme code" + }, + "desc": { + "en": "Defines a global structure for uniquely identifying organizations or entities.\nThis standard is essential in environments where electronic communications require\nunambiguous identification of organizations, especially in automated systems or\nelectronic data interchange (EDI).\n\nThe ISO 6523 set of identifies is used by the EN16931 standard for electronic invoicing." + }, + "pattern": "^\\d{4}$" + } + ] +} \ No newline at end of file diff --git a/data/catalogues/untdid.json b/data/catalogues/untdid.json new file mode 100644 index 00000000..b3074f37 --- /dev/null +++ b/data/catalogues/untdid.json @@ -0,0 +1,997 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/catalogue-def", + "key": "untdid", + "name": { + "en": "UN/EDIFACT Data Elements" + }, + "description": null, + "extensions": [ + { + "key": "untdid-document-type", + "name": { + "en": "UNTDID 1001 Document Type" + }, + "desc": { + "en": "UNTDID 1001 code used to describe the type of document. Ths list is based\non the [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists)\nvalues table which focusses on invoices and payments.\n\nOther tax regimes and addons may use their own subset of codes." + }, + "values": [ + { + "value": "71", + "name": { + "en": "Request for payment" + } + }, + { + "value": "80", + "name": { + "en": "Debit note related to goods or services" + } + }, + { + "value": "81", + "name": { + "en": "Credit note related to goods or services" + } + }, + { + "value": "82", + "name": { + "en": "Metered services invoice" + } + }, + { + "value": "83", + "name": { + "en": "Credit note related to financial adjustments" + } + }, + { + "value": "84", + "name": { + "en": "Debit note related to financial adjustments" + } + }, + { + "value": "102", + "name": { + "en": "Tax notification" + } + }, + { + "value": "130", + "name": { + "en": "Invoicing data sheet" + } + }, + { + "value": "202", + "name": { + "en": "Direct payment valuation" + } + }, + { + "value": "203", + "name": { + "en": "Provisional payment valuation" + } + }, + { + "value": "204", + "name": { + "en": "Payment valuation" + } + }, + { + "value": "211", + "name": { + "en": "Interim application for payment" + } + }, + { + "value": "218", + "name": { + "en": "Final payment request based on completion of work" + } + }, + { + "value": "219", + "name": { + "en": "Payment request for completed units" + } + }, + { + "value": "261", + "name": { + "en": "Self billed credit note" + } + }, + { + "value": "262", + "name": { + "en": "Consolidated credit note - goods and services" + } + }, + { + "value": "295", + "name": { + "en": "Price variation invoice" + } + }, + { + "value": "296", + "name": { + "en": "Credit note for price variation" + } + }, + { + "value": "308", + "name": { + "en": "Delcredere credit note" + } + }, + { + "value": "325", + "name": { + "en": "Proforma invoice" + } + }, + { + "value": "326", + "name": { + "en": "Partial invoice" + } + }, + { + "value": "380", + "name": { + "en": "Standard Invoice" + } + }, + { + "value": "381", + "name": { + "en": "Credit note" + } + }, + { + "value": "382", + "name": { + "en": "Commission note" + } + }, + { + "value": "383", + "name": { + "en": "Debit note" + } + }, + { + "value": "384", + "name": { + "en": "Corrected invoice" + } + }, + { + "value": "385", + "name": { + "en": "Consolidated invoice" + } + }, + { + "value": "386", + "name": { + "en": "Prepayment invoice" + } + }, + { + "value": "387", + "name": { + "en": "Hire invoice" + } + }, + { + "value": "388", + "name": { + "en": "Tax invoice" + } + }, + { + "value": "389", + "name": { + "en": "Self-billed invoice" + } + }, + { + "value": "390", + "name": { + "en": "Delcredere invoice" + } + }, + { + "value": "393", + "name": { + "en": "Factored invoice" + } + }, + { + "value": "394", + "name": { + "en": "Lease invoice" + } + }, + { + "value": "395", + "name": { + "en": "Consignment invoice" + } + }, + { + "value": "396", + "name": { + "en": "Factored credit note" + } + }, + { + "value": "420", + "name": { + "en": "Optical Character Reading (OCR) payment credit note" + } + }, + { + "value": "456", + "name": { + "en": "Debit advice" + } + }, + { + "value": "457", + "name": { + "en": "Reversal of debit" + } + }, + { + "value": "458", + "name": { + "en": "Reversal of credit" + } + }, + { + "value": "527", + "name": { + "en": "Self billed debit note" + } + }, + { + "value": "532", + "name": { + "en": "Forwarder's credit note" + } + }, + { + "value": "553", + "name": { + "en": "Forwarder's invoice discrepancy report" + } + }, + { + "value": "575", + "name": { + "en": "Insurer's invoice" + } + }, + { + "value": "623", + "name": { + "en": "Forwarder's invoice" + } + }, + { + "value": "633", + "name": { + "en": "Port charges documents" + } + }, + { + "value": "751", + "name": { + "en": "Invoice information for accounting purposes" + } + }, + { + "value": "780", + "name": { + "en": "Freight invoice" + } + }, + { + "value": "817", + "name": { + "en": "Claim notification" + } + }, + { + "value": "870", + "name": { + "en": "Consular invoice" + } + }, + { + "value": "875", + "name": { + "en": "Partial construction invoice" + } + }, + { + "value": "876", + "name": { + "en": "Partial final construction invoice" + } + }, + { + "value": "877", + "name": { + "en": "Final construction invoice" + } + }, + { + "value": "935", + "name": { + "en": "Customs invoice" + } + } + ] + }, + { + "key": "untdid-payment-means", + "name": { + "en": "UNTDID 4461 Payment Means" + }, + "desc": { + "en": "UNTDID 4461 code used to describe the means of payment. This list is based on the\n[EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists)\nvalues table which focusses on invoices and payments." + }, + "values": [ + { + "value": "1", + "name": { + "en": "Instrument not defined" + } + }, + { + "value": "2", + "name": { + "en": "Automated clearing house credit" + } + }, + { + "value": "3", + "name": { + "en": "Automated clearing house debit" + } + }, + { + "value": "4", + "name": { + "en": "ACH demand debit reversal" + } + }, + { + "value": "5", + "name": { + "en": "ACH demand credit reversal" + } + }, + { + "value": "6", + "name": { + "en": "ACH demand credit" + } + }, + { + "value": "7", + "name": { + "en": "ACH demand debit" + } + }, + { + "value": "8", + "name": { + "en": "Hold" + } + }, + { + "value": "9", + "name": { + "en": "National or regional clearing" + } + }, + { + "value": "10", + "name": { + "en": "In cash" + } + }, + { + "value": "11", + "name": { + "en": "ACH savings credit reversal" + } + }, + { + "value": "12", + "name": { + "en": "ACH savings debit reversal" + } + }, + { + "value": "13", + "name": { + "en": "ACH savings credit" + } + }, + { + "value": "14", + "name": { + "en": "ACH savings debit" + } + }, + { + "value": "15", + "name": { + "en": "Bookentry credit" + } + }, + { + "value": "16", + "name": { + "en": "Bookentry debit" + } + }, + { + "value": "17", + "name": { + "en": "ACH demand cash concentration/disbursement (CCD) credit" + } + }, + { + "value": "18", + "name": { + "en": "ACH demand cash concentration/disbursement (CCD) debit" + } + }, + { + "value": "19", + "name": { + "en": "ACH demand corporate trade payment (CTP) credit" + } + }, + { + "value": "20", + "name": { + "en": "Cheque" + } + }, + { + "value": "21", + "name": { + "en": "Banker's draft" + } + }, + { + "value": "22", + "name": { + "en": "Certified banker's draft" + } + }, + { + "value": "23", + "name": { + "en": "Bank cheque (issued by a banking or similar establishment)" + } + }, + { + "value": "24", + "name": { + "en": "Bill of exchange awaiting acceptance" + } + }, + { + "value": "25", + "name": { + "en": "Certified cheque" + } + }, + { + "value": "26", + "name": { + "en": "Local cheque" + } + }, + { + "value": "27", + "name": { + "en": "ACH demand corporate trade payment (CTP) debit" + } + }, + { + "value": "28", + "name": { + "en": "ACH demand corporate trade exchange (CTX) credit" + } + }, + { + "value": "29", + "name": { + "en": "ACH demand corporate trade exchange (CTX) debit" + } + }, + { + "value": "30", + "name": { + "en": "Credit transfer" + } + }, + { + "value": "31", + "name": { + "en": "Debit transfer" + } + }, + { + "value": "32", + "name": { + "en": "ACH demand cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "33", + "name": { + "en": "ACH demand cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "34", + "name": { + "en": "ACH prearranged payment and deposit (PPD)" + } + }, + { + "value": "35", + "name": { + "en": "ACH savings cash concentration/disbursement (CCD) credit" + } + }, + { + "value": "36", + "name": { + "en": "ACH savings cash concentration/disbursement (CCD) debit" + } + }, + { + "value": "37", + "name": { + "en": "ACH savings corporate trade payment (CTP) credit" + } + }, + { + "value": "38", + "name": { + "en": "ACH savings corporate trade payment (CTP) debit" + } + }, + { + "value": "39", + "name": { + "en": "ACH savings corporate trade exchange (CTX) credit" + } + }, + { + "value": "40", + "name": { + "en": "ACH savings corporate trade exchange (CTX) debit" + } + }, + { + "value": "41", + "name": { + "en": "ACH savings cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "42", + "name": { + "en": "Payment to bank account" + } + }, + { + "value": "43", + "name": { + "en": "ACH savings cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "44", + "name": { + "en": "Accepted bill of exchange" + } + }, + { + "value": "45", + "name": { + "en": "Referenced home-banking credit transfer" + } + }, + { + "value": "46", + "name": { + "en": "Interbank debit transfer" + } + }, + { + "value": "47", + "name": { + "en": "Home-banking debit transfer" + } + }, + { + "value": "48", + "name": { + "en": "Bank card" + } + }, + { + "value": "49", + "name": { + "en": "Direct debit" + } + }, + { + "value": "50", + "name": { + "en": "Payment by postgiro" + } + }, + { + "value": "51", + "name": { + "en": "FR, norme 6 97-Telereglement CFONB (French Organisation for" + } + }, + { + "value": "52", + "name": { + "en": "Urgent commercial payment" + } + }, + { + "value": "53", + "name": { + "en": "Urgent Treasury Payment" + } + }, + { + "value": "54", + "name": { + "en": "Credit card" + } + }, + { + "value": "55", + "name": { + "en": "Debit card" + } + }, + { + "value": "56", + "name": { + "en": "Bankgiro" + } + }, + { + "value": "57", + "name": { + "en": "Standing agreement" + } + }, + { + "value": "58", + "name": { + "en": "SEPA credit transfer" + } + }, + { + "value": "59", + "name": { + "en": "SEPA direct debit" + } + }, + { + "value": "60", + "name": { + "en": "Promissory note" + } + }, + { + "value": "61", + "name": { + "en": "Promissory note signed by the debtor" + } + }, + { + "value": "62", + "name": { + "en": "Promissory note signed by the debtor and endorsed by a bank" + } + }, + { + "value": "63", + "name": { + "en": "Promissory note signed by the debtor and endorsed by a" + } + }, + { + "value": "64", + "name": { + "en": "Promissory note signed by a bank" + } + }, + { + "value": "65", + "name": { + "en": "Promissory note signed by a bank and endorsed by another" + } + }, + { + "value": "66", + "name": { + "en": "Promissory note signed by a third party" + } + }, + { + "value": "67", + "name": { + "en": "Promissory note signed by a third party and endorsed by a" + } + }, + { + "value": "68", + "name": { + "en": "Online payment service" + } + }, + { + "value": "69", + "name": { + "en": "Transfer Advice" + } + }, + { + "value": "70", + "name": { + "en": "Bill drawn by the creditor on the debtor" + } + }, + { + "value": "74", + "name": { + "en": "Bill drawn by the creditor on a bank" + } + }, + { + "value": "75", + "name": { + "en": "Bill drawn by the creditor, endorsed by another bank" + } + }, + { + "value": "76", + "name": { + "en": "Bill drawn by the creditor on a bank and endorsed by a" + } + }, + { + "value": "77", + "name": { + "en": "Bill drawn by the creditor on a third party" + } + }, + { + "value": "78", + "name": { + "en": "Bill drawn by creditor on third party, accepted and" + } + }, + { + "value": "91", + "name": { + "en": "Not transferable banker's draft" + } + }, + { + "value": "92", + "name": { + "en": "Not transferable local cheque" + } + }, + { + "value": "93", + "name": { + "en": "Reference giro" + } + }, + { + "value": "94", + "name": { + "en": "Urgent giro" + } + }, + { + "value": "95", + "name": { + "en": "Free format giro" + } + }, + { + "value": "96", + "name": { + "en": "Requested method for payment was not used" + } + }, + { + "value": "97", + "name": { + "en": "Clearing between partners" + } + }, + { + "value": "98", + "name": { + "en": "JP, Electronically Recorded Monetary Claims" + } + }, + { + "value": "ZZZ", + "name": { + "en": "Mutually defined" + } + } + ] + }, + { + "key": "untdid-tax-category", + "name": { + "en": "UNTDID 3505 Tax Category" + }, + "desc": { + "en": "UNTDID 5305 code used to describe the applicable duty/tax/fee category. There are\nmultiple versions and subsets of this table so regimes and addons may need to filter\noptions for a specific subset of values.\n\nData from https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5305.htm." + }, + "values": [ + { + "value": "A", + "name": { + "en": "Mixed tax rate" + } + }, + { + "value": "AA", + "name": { + "en": "Lower rate" + } + }, + { + "value": "AB", + "name": { + "en": "Exempt for resale" + } + }, + { + "value": "AC", + "name": { + "en": "Exempt for resale" + } + }, + { + "value": "AD", + "name": { + "en": "Value Added Tax (VAT) due from a previous invoice" + } + }, + { + "value": "AE", + "name": { + "en": "VAT Reverse Charge" + } + }, + { + "value": "B", + "name": { + "en": "Transferred (VAT)" + } + }, + { + "value": "C", + "name": { + "en": "Duty paid by supplier" + } + }, + { + "value": "D", + "name": { + "en": "Value Added Tax (VAT) margin scheme - travel agents" + } + }, + { + "value": "E", + "name": { + "en": "Exempt from tax" + } + }, + { + "value": "F", + "name": { + "en": "Value Added Tax (VAT) margin scheme - second-hand goods" + } + }, + { + "value": "G", + "name": { + "en": "Free export item, tax not charged" + } + }, + { + "value": "H", + "name": { + "en": "Higher rate" + } + }, + { + "value": "I", + "name": { + "en": "Value Added Tax (VAT) margin scheme - works of art" + } + }, + { + "value": "J", + "name": { + "en": "Value Added Tax (VAT) margin scheme - collector's items and antiques" + } + }, + { + "value": "K", + "name": { + "en": "VAT exempt for EEA intra-community supply of goods and services" + } + }, + { + "value": "L", + "name": { + "en": "Canary Islands general indirect tax" + } + }, + { + "value": "M", + "name": { + "en": "Tax for production, services and importation in Ceuta and Melilla" + } + }, + { + "value": "O", + "name": { + "en": "Services outside scope of tax" + } + }, + { + "value": "S", + "name": { + "en": "Standard Rate" + } + }, + { + "value": "Z", + "name": { + "en": "Zero rated goods" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/data/data.go b/data/data.go index a492ab36..cd5b00e5 100644 --- a/data/data.go +++ b/data/data.go @@ -3,7 +3,7 @@ package data import "embed" -//go:embed currency regimes schemas +//go:embed currency regimes schemas addons catalogues // Content contains the generated regimes and schemes // ready to serve as an embed.FS. diff --git a/data/regimes/de.json b/data/regimes/de.json index 2f36fdbd..1f5867d1 100644 --- a/data/regimes/de.json +++ b/data/regimes/de.json @@ -180,6 +180,18 @@ "percent": "5%" } ] + }, + { + "key": "exempt", + "name": { + "de": "Befreit", + "en": "Exempt" + }, + "desc": { + "de": "Bestimmte Waren und Dienstleistungen sind von der Umsatzsteuer befreit.", + "en": "Certain goods and services are exempt from VAT." + }, + "exempt": true } ], "sources": [ diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index b0bed939..66d3df12 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -254,7 +254,7 @@ }, { "const": "de-xrechnung-v3", - "title": "Germany XRechnung v3.x" + "title": "German XRechnung 3.X" }, { "const": "es-facturae-v3", diff --git a/data/schemas/org/identity.json b/data/schemas/org/identity.json index 31ddde7f..350f43d4 100644 --- a/data/schemas/org/identity.json +++ b/data/schemas/org/identity.json @@ -40,6 +40,11 @@ "type": "string", "title": "Description", "description": "Description adds details about what the code could mean or imply" + }, + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "title": "Extensions", + "description": "Ext provides a way to add additional information to the identity." } }, "type": "object", diff --git a/data/schemas/org/party.json b/data/schemas/org/party.json index 76166db9..dec2f382 100644 --- a/data/schemas/org/party.json +++ b/data/schemas/org/party.json @@ -5,6 +5,10 @@ "$defs": { "Party": { "properties": { + "$regime": { + "$ref": "https://gobl.org/draft-0/l10n/tax-country-code", + "title": "Tax Regime" + }, "uuid": { "type": "string", "format": "uuid", diff --git a/data/schemas/pay/advance.json b/data/schemas/pay/advance.json index 0b7925e3..e6f9b779 100644 --- a/data/schemas/pay/advance.json +++ b/data/schemas/pay/advance.json @@ -34,6 +34,11 @@ "title": "Credit Transfer", "description": "Sender initiated bank or wire transfer." }, + { + "const": "credit-transfer+sepa", + "title": "SEPA Credit Transfer", + "description": "Sender initiated bank or wire transfer via SEPA." + }, { "const": "debit-transfer", "title": "Debit Transfer", @@ -59,6 +64,11 @@ "title": "Direct Debit", "description": "Direct debit from the customers bank account." }, + { + "const": "direct-debit+sepa", + "title": "SEPA Direct Debit", + "description": "Direct debit from the customers bank account via SEPA." + }, { "const": "online", "title": "Online", diff --git a/data/schemas/pay/instructions.json b/data/schemas/pay/instructions.json index 5a98a10d..5e3597d4 100644 --- a/data/schemas/pay/instructions.json +++ b/data/schemas/pay/instructions.json @@ -95,6 +95,11 @@ "title": "Credit Transfer", "description": "Sender initiated bank or wire transfer." }, + { + "const": "credit-transfer+sepa", + "title": "SEPA Credit Transfer", + "description": "Sender initiated bank or wire transfer via SEPA." + }, { "const": "debit-transfer", "title": "Debit Transfer", @@ -120,6 +125,11 @@ "title": "Direct Debit", "description": "Direct debit from the customers bank account." }, + { + "const": "direct-debit+sepa", + "title": "SEPA Direct Debit", + "description": "Direct debit from the customers bank account via SEPA." + }, { "const": "online", "title": "Online", diff --git a/data/schemas/tax/addon-def.json b/data/schemas/tax/addon-def.json index 4d133765..68f9098d 100644 --- a/data/schemas/tax/addon-def.json +++ b/data/schemas/tax/addon-def.json @@ -10,6 +10,14 @@ "title": "Key", "description": "Key that defines how to uniquely idenitfy the add-on." }, + "requires": { + "items": { + "$ref": "https://gobl.org/draft-0/cbc/key" + }, + "type": "array", + "title": "Requires", + "description": "Requires defines any additional addons that this one depends on to operate\ncorrectly." + }, "name": { "$ref": "https://gobl.org/draft-0/i18n/string", "title": "Name", diff --git a/data/schemas/tax/catalogue-def.json b/data/schemas/tax/catalogue-def.json new file mode 100644 index 00000000..b9e70333 --- /dev/null +++ b/data/schemas/tax/catalogue-def.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gobl.org/draft-0/tax/catalogue-def", + "$ref": "#/$defs/CatalogueDef", + "$defs": { + "CatalogueDef": { + "properties": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "description": "Key defines a unique identifier for the catalogue." + }, + "name": { + "$ref": "https://gobl.org/draft-0/i18n/string", + "description": "Name is the name of the catalogue." + }, + "description": { + "$ref": "https://gobl.org/draft-0/i18n/string", + "description": "Description is a human readable description of the catalogue." + }, + "extensions": { + "items": { + "$ref": "https://gobl.org/draft-0/cbc/key-definition" + }, + "type": "array", + "description": "Extensions defines all the extensions offered by the catalogue." + } + }, + "type": "object", + "required": [ + "key", + "name", + "description", + "extensions" + ], + "description": "A CatalogueDef contains a set of re-useable extensions, scenarios, and validators that can be used by addons or tax regimes." + } + } +} \ No newline at end of file diff --git a/gobl.go b/gobl.go index d192f42a..8a06b7be 100644 --- a/gobl.go +++ b/gobl.go @@ -5,6 +5,7 @@ import ( // import all the dependencies to ensure all init() methods are called. _ "github.com/invopop/gobl/addons" _ "github.com/invopop/gobl/bill" + _ "github.com/invopop/gobl/catalogues" _ "github.com/invopop/gobl/currency" _ "github.com/invopop/gobl/dsig" _ "github.com/invopop/gobl/i18n" @@ -19,6 +20,7 @@ import ( //go:generate go run ./schema/generate.go //go:generate go run ./regimes/generate.go //go:generate go run ./addons/generate.go +//go:generate go run ./catalogues/generate.go //go:generate go run ./currency/generate.go func init() { diff --git a/i18n/string.go b/i18n/string.go index 326ca15e..d040420e 100644 --- a/i18n/string.go +++ b/i18n/string.go @@ -9,6 +9,14 @@ const ( // String provides a simple map of locales to texts. type String map[Lang]string +// NewString is a convenience method to create a new i18n.String +// using the default language. +func NewString(text string) String { + return String{ + defaultLanguage: text, + } +} + // In provides a single string from the map using the // language requested or resorts to the default. func (s String) In(lang Lang) string { diff --git a/i18n/string_test.go b/i18n/string_test.go index 26dcdcbf..fe560bc3 100644 --- a/i18n/string_test.go +++ b/i18n/string_test.go @@ -23,4 +23,7 @@ func TestI18nString(t *testing.T) { } assert.Equal(t, "Foo", snd.In("en")) assert.Equal(t, "Foo", snd.String()) + + s2 := i18n.NewString("Test") + assert.Equal(t, "Test", s2.In("en")) } diff --git a/internal/cli/bulk_test.go b/internal/cli/bulk_test.go index e46a1318..7adfea77 100644 --- a/internal/cli/bulk_test.go +++ b/internal/cli/bulk_test.go @@ -648,7 +648,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo // Following raw message is copied and pasted! (sorry!) Payload: json.RawMessage(`{ "list": [ - "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/key-definition", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/note", "https://gobl.org/draft-0/cbc/value-definition", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/advance", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/total" + "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/key-definition", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/note", "https://gobl.org/draft-0/cbc/value-definition", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/advance", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/total" ] }`), IsFinal: false, diff --git a/org/identity.go b/org/identity.go index f05cfec3..0f9f7c71 100644 --- a/org/identity.go +++ b/org/identity.go @@ -46,6 +46,18 @@ type Identity struct { Code cbc.Code `json:"code" jsonschema:"title=Code"` // Description adds details about what the code could mean or imply Description string `json:"description,omitempty" jsonschema:"title=Description"` + // Ext provides a way to add additional information to the identity. + Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` +} + +// Normalize will try to clean the identity's data. +func (i *Identity) Normalize(normalizers tax.Normalizers) { + if i == nil { + return + } + uuid.Normalize(&i.UUID) + i.Ext = tax.CleanExtensions(i.Ext) + normalizers.Each(i) } // Validate ensures the identity looks valid. @@ -61,12 +73,13 @@ func (i *Identity) ValidateWithContext(ctx context.Context) error { validation.Field(&i.Key), validation.Field(&i.Type, validation.When(i.Key != "", - validation.Empty, + validation.Empty.Error("must be empty when key is set"), ), ), validation.Field(&i.Code, validation.Required, ), + validation.Field(&i.Ext), ) } @@ -127,7 +140,7 @@ func IdentityForType(in []*Identity, typ cbc.Code) *Identity { return nil } -// IdentityForKey helps return the identity with on of the matching keys. +// IdentityForKey helps return the identity with the first matching key. func IdentityForKey(in []*Identity, key ...cbc.Key) *Identity { for _, v := range in { if v.Key.In(key...) { diff --git a/org/identity_test.go b/org/identity_test.go index 110ebced..005ed7ea 100644 --- a/org/identity_test.go +++ b/org/identity_test.go @@ -3,8 +3,10 @@ package org_test import ( "testing" + "github.com/invopop/gobl/catalogues/iso" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" ) @@ -27,3 +29,55 @@ func TestAddIdentity(t *testing.T) { assert.Len(t, st.Identities, 1) assert.Equal(t, "BARDOM", st.Identities[0].Code.String()) } + +func TestIdentityNormalize(t *testing.T) { + t.Run("with nil", func(t *testing.T) { + var id *org.Identity + assert.NotPanics(t, func() { + id.Normalize(nil) + }) + }) + t.Run("missing extensions", func(t *testing.T) { + id := &org.Identity{ + Type: cbc.Code("FOO"), + Code: "BAR", + Ext: tax.Extensions{}, + } + id.Normalize(nil) + assert.Equal(t, "FOO", id.Type.String()) + assert.Nil(t, id.Ext) + }) + t.Run("with extension", func(t *testing.T) { + id := &org.Identity{ + Code: "BAR", + Ext: tax.Extensions{ + iso.ExtKeySchemeID: "0004", + }, + } + id.Normalize(nil) + assert.Equal(t, "BAR", id.Code.String()) + assert.Equal(t, "0004", id.Ext[iso.ExtKeySchemeID].String()) + }) +} + +func TestIdentityValidate(t *testing.T) { + t.Run("with basics", func(t *testing.T) { + id := &org.Identity{ + Code: "BAR", + Ext: tax.Extensions{ + iso.ExtKeySchemeID: "0004", + }, + } + err := id.Validate() + assert.NoError(t, err) + }) + t.Run("with both key and type", func(t *testing.T) { + id := &org.Identity{ + Key: "fiscal-code", + Type: "NIF", + Code: "1234567890", + } + err := id.Validate() + assert.ErrorContains(t, err, "type: must be empty when key is set") + }) +} diff --git a/org/party.go b/org/party.go index a9188aba..4f4b3c90 100644 --- a/org/party.go +++ b/org/party.go @@ -14,7 +14,9 @@ import ( // Party represents a person or business entity. type Party struct { + tax.Regime uuid.Identify + // Label can be used to provide a custom label for the party in a given // context in a single language, for example "Supplier", "Host", or similar. Label string `json:"label,omitempty" jsonschema:"title=Label,example=Supplier"` @@ -51,7 +53,14 @@ type Party struct { // Calculate will perform basic normalization of the party's data without // using any tax regime or addon. func (p *Party) Calculate() error { - p.Normalize(nil) + p.Normalize(p.normalizers()) + return nil +} + +func (p *Party) normalizers() tax.Normalizers { + if r := p.RegimeDef(); r != nil { + return tax.Normalizers{r.Normalizer} + } return nil } diff --git a/org/party_test.go b/org/party_test.go index bd1ced11..bffbf250 100644 --- a/org/party_test.go +++ b/org/party_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/invopop/gobl/org" _ "github.com/invopop/gobl/regimes" @@ -32,6 +33,7 @@ func TestPartyNormalize(t *testing.T) { }, } party.Normalize(nil) + assert.Empty(t, party.GetRegime()) assert.Equal(t, "ES", party.TaxID.Country.String()) assert.Equal(t, "ES42342912G", party.TaxID.String()) }) @@ -45,6 +47,7 @@ func TestPartyNormalize(t *testing.T) { }, } assert.NoError(t, party.Calculate()) + assert.Empty(t, party.GetRegime()) assert.Equal(t, "ES", party.TaxID.Country.String()) assert.Equal(t, "ES42342912G", party.TaxID.String()) }) @@ -60,6 +63,22 @@ func TestPartyNormalize(t *testing.T) { party.Normalize(nil) // unknown entry should not cause problem assert.Equal(t, "42342912G", party.TaxID.Code.String()) }) + + t.Run("for specific regime", func(t *testing.T) { + party := org.Party{ + Regime: tax.WithRegime("DE"), + Name: "Invopop", + Identities: []*org.Identity{ + { + Key: "de-tax-number", + Code: "123 456 78901", + }, + }, + } + require.NoError(t, party.Calculate()) + assert.Equal(t, "DE", party.GetRegime().String()) + assert.Equal(t, "123/456/78901", party.Identities[0].Code.String()) + }) } func TestPartyAddressNill(t *testing.T) { diff --git a/pay/instructions.go b/pay/instructions.go index ad6ca40d..d7157c33 100644 --- a/pay/instructions.go +++ b/pay/instructions.go @@ -90,16 +90,6 @@ func (i *Instructions) Normalize(normalizers tax.Normalizers) { normalizers.Each(i) } -// UNTDID4461 provides the standard UNTDID 4461 code for the instruction's key. -func (i *Instructions) UNTDID4461() cbc.Code { - for _, v := range MeansKeyDefinitions { - if v.Key == i.Key { - return v.UNTDID4461 - } - } - return cbc.CodeEmpty -} - // Validate ensures the Online method details look correct. func (u *Online) Validate() error { return validation.ValidateStruct(u, diff --git a/pay/means_key.go b/pay/means_key.go index d7a8f2df..4919d08c 100644 --- a/pay/means_key.go +++ b/pay/means_key.go @@ -2,13 +2,13 @@ package pay import ( "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" "github.com/invopop/jsonschema" ) // Standard payment means codes for instructions. // If you require more payment means options, please make a pull request -// and try to include references to the use case. All new means keys should -// map to an existing UNTDID 4461 code. +// and try to include references to the use case. const ( MeansKeyAny cbc.Key = "any" // Use any method available. MeansKeyCard cbc.Key = "card" @@ -21,37 +21,83 @@ const ( MeansKeyBankDraft cbc.Key = "bank-draft" MeansKeyDirectDebit cbc.Key = "direct-debit" // aka. Mandate MeansKeyOnline cbc.Key = "online" // Website from which payment can be made + MeansKeySEPA cbc.Key = "sepa" // extension for SEPA payments MeansKeyOther cbc.Key = "other" ) -// MeansKeyDef is used to define each of the payment means keys -// that can be accepted by GOBL. -type MeansKeyDef struct { - // Key being described - Key cbc.Key `json:"key" jsonschema:"title=Key"` - // Human value of the key - Title string `json:"title" jsonschema:"title=Title"` - // Details about the meaning of the key - Description string `json:"description" jsonschema:"title=Description"` - // UNTDID 4461 Equivalent Code - UNTDID4461 cbc.Code `json:"untdid4461" jsonschema:"title=UNTDID 4461 Code"` -} - // MeansKeyDefinitions includes all the payment means keys that // are accepted by GOBL. -var MeansKeyDefinitions = []MeansKeyDef{ - {MeansKeyAny, "Any", "Any method available, no preference.", "1"}, // Instrument not defined - {MeansKeyCard, "Card", "Payment card.", "48"}, // Bank card - {MeansKeyCreditTransfer, "Credit Transfer", "Sender initiated bank or wire transfer.", "30"}, // credit transfer - {MeansKeyDebitTransfer, "Debit Transfer", "Receiver initiated bank or wire transfer.", "31"}, // debit transfer - {MeansKeyCash, "Cash", "Cash in hand.", "10"}, // in cash - {MeansKeyCheque, "Cheque", "Cheque from bank.", "20"}, // cheque - {MeansKeyBankDraft, "Draft", "Bankers Draft or Bank Cheque.", "21"}, // Banker's draft, - {MeansKeyDirectDebit, "Direct Debit", "Direct debit from the customers bank account.", "49"}, // direct debit - {MeansKeyOnline, "Online", "Online or web payment.", "68"}, // online payment service - {MeansKeyPromissoryNote, "Promissory Note", "Promissory note contract.", "60"}, // Promissory note - {MeansKeyNetting, "Netting", "Intercompany clearing or clearing between partners.", "97"}, // Netting - {MeansKeyOther, "Other", "Other or mutually defined means of payment.", "ZZZ"}, // Other +var MeansKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: MeansKeyAny, + Name: i18n.NewString("Any"), + Desc: i18n.NewString("Any method available, no preference."), + }, + { + Key: MeansKeyCard, + Name: i18n.NewString("Card"), + Desc: i18n.NewString("Payment card."), + }, + { + Key: MeansKeyCreditTransfer, + Name: i18n.NewString("Credit Transfer"), + Desc: i18n.NewString("Sender initiated bank or wire transfer."), + }, + { + Key: MeansKeyCreditTransfer.With(MeansKeySEPA), + Name: i18n.NewString("SEPA Credit Transfer"), + Desc: i18n.NewString("Sender initiated bank or wire transfer via SEPA."), + }, + { + Key: MeansKeyDebitTransfer, + Name: i18n.NewString("Debit Transfer"), + Desc: i18n.NewString("Receiver initiated bank or wire transfer."), + }, + { + Key: MeansKeyCash, + Name: i18n.NewString("Cash"), + Desc: i18n.NewString("Cash in hand."), + }, + { + Key: MeansKeyCheque, + Name: i18n.NewString("Cheque"), + Desc: i18n.NewString("Cheque from bank."), + }, + { + Key: MeansKeyBankDraft, + Name: i18n.NewString("Draft"), + Desc: i18n.NewString("Bankers Draft or Bank Cheque."), + }, + { + Key: MeansKeyDirectDebit, + Name: i18n.NewString("Direct Debit"), + Desc: i18n.NewString("Direct debit from the customers bank account."), + }, + { + Key: MeansKeyDirectDebit.With(MeansKeySEPA), + Name: i18n.NewString("SEPA Direct Debit"), + Desc: i18n.NewString("Direct debit from the customers bank account via SEPA."), + }, + { + Key: MeansKeyOnline, + Name: i18n.NewString("Online"), + Desc: i18n.NewString("Online or web payment."), + }, + { + Key: MeansKeyPromissoryNote, + Name: i18n.NewString("Promissory Note"), + Desc: i18n.NewString("Promissory note contract."), + }, + { + Key: MeansKeyNetting, + Name: i18n.NewString("Netting"), + Desc: i18n.NewString("Intercompany clearing or clearing between partners."), + }, + { + Key: MeansKeyOther, + Name: i18n.NewString("Other"), + Desc: i18n.NewString("Other or mutually defined means of payment."), + }, } // HasValidMeansKey provides a usable validator for the means key @@ -74,8 +120,8 @@ func extendJSONSchemaWithMeansKey(schema *jsonschema.Schema, property string) { for i, v := range MeansKeyDefinitions { anyOf[i] = &jsonschema.Schema{ Const: v.Key, - Title: v.Title, - Description: v.Description, + Title: v.Name.String(), + Description: v.Desc.String(), } } anyOf = append(anyOf, &jsonschema.Schema{ diff --git a/regimes/de/examples/invoice-de-de.yaml b/regimes/de/examples/invoice-de-de.yaml index 161857cf..5e9bf342 100644 --- a/regimes/de/examples/invoice-de-de.yaml +++ b/regimes/de/examples/invoice-de-de.yaml @@ -1,4 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/invoice" +$addons: + - "de-xrechnung-v3" uuid: "3aea7b56-59d8-4beb-90bd-f8f280d852a0" currency: "EUR" issue_date: "2022-02-01" @@ -45,3 +47,10 @@ lines: taxes: - cat: VAT rate: standard + +payment: + instructions: + key: "credit-transfer+sepa" + credit_transfer: + - iban: "DE89370400440532013000" + name: "Random Bank Co." diff --git a/regimes/de/examples/out/invoice-de-de.json b/regimes/de/examples/out/invoice-de-de.json index 236f7088..8ea1e308 100644 --- a/regimes/de/examples/out/invoice-de-de.json +++ b/regimes/de/examples/out/invoice-de-de.json @@ -4,18 +4,27 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "0938466f9583f14ef3c26a29ef8a70d6cf2df20d69346e51be8252d7e15eb063" + "val": "02225106bf1fb373bc48663ad9eec3d50861d313616b8b392e66cdafab047c66" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "DE", + "$addons": [ + "eu-en16931-v2017", + "de-xrechnung-v3" + ], "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", "type": "standard", "series": "SAMPLE", "code": "001", "issue_date": "2022-02-01", "currency": "EUR", + "tax": { + "ext": { + "untdid-document-type": "380" + } + }, "supplier": { "name": "Provide One GmbH", "tax_id": { @@ -78,12 +87,29 @@ { "cat": "VAT", "rate": "standard", - "percent": "19%" + "percent": "19%", + "ext": { + "untdid-tax-category": "S" + } } ], "total": "1620.00" } ], + "payment": { + "instructions": { + "key": "credit-transfer+sepa", + "credit_transfer": [ + { + "iban": "DE89370400440532013000", + "name": "Random Bank Co." + } + ], + "ext": { + "untdid-payment-means": "58" + } + } + }, "totals": { "sum": "1620.00", "total": "1620.00", @@ -94,6 +120,9 @@ "rates": [ { "key": "standard", + "ext": { + "untdid-tax-category": "S" + }, "base": "1620.00", "percent": "19%", "amount": "307.80" diff --git a/regimes/de/identities.go b/regimes/de/identities.go index caf32eaf..533613a8 100644 --- a/regimes/de/identities.go +++ b/regimes/de/identities.go @@ -19,7 +19,6 @@ const ( ) var taxNumberRegexPattern = regexp.MustCompile(`^\d{2,3}/\d{3}/\d{5}$`) -var badCharsRegexPattern = regexp.MustCompile(`[^\d]`) var identityKeyDefinitions = []*cbc.KeyDefinition{ { @@ -36,8 +35,7 @@ func normalizeTaxNumber(id *org.Identity) { if id == nil || id.Key != IdentityKeyTaxNumber { return } - code := id.Code.String() - code = badCharsRegexPattern.ReplaceAllString(code, "") + code := cbc.NormalizeNumericalCode(id.Code).String() if len(code) == 11 { // If 11 digits, return the format 123/456/78901 code = fmt.Sprintf("%s/%s/%s", code[:3], code[3:6], code[6:]) diff --git a/regimes/de/invoices.go b/regimes/de/invoices.go index 15faf7fe..d3dc0ce7 100644 --- a/regimes/de/invoices.go +++ b/regimes/de/invoices.go @@ -28,19 +28,19 @@ func validateInvoiceSupplier(value any) error { } return validation.ValidateStruct(p, validation.Field(&p.TaxID, - validation.Required, validation.When( - !hasTaxNumber(p), + !hasIdentityTaxNumber(p), + validation.Required, tax.RequireIdentityCode, ), - // validation.Skip, + validation.Skip, ), validation.Field(&p.Identities, validation.When( !hasTaxIDCode(p), org.RequireIdentityKey(IdentityKeyTaxNumber), ), - // validation.Skip, + validation.Skip, ), ) } @@ -53,8 +53,8 @@ func hasTaxIDCode(party *org.Party) bool { return party != nil && party.TaxID != nil && party.TaxID.Code != "" } -func hasTaxNumber(party *org.Party) bool { - if party == nil || party.TaxID == nil { +func hasIdentityTaxNumber(party *org.Party) bool { + if party == nil || len(party.Identities) == 0 { return false } return org.IdentityForKey(party.Identities, IdentityKeyTaxNumber) != nil diff --git a/regimes/de/invoices_test.go b/regimes/de/invoices_test.go index ed4bf8a0..83536bc5 100644 --- a/regimes/de/invoices_test.go +++ b/regimes/de/invoices_test.go @@ -83,4 +83,17 @@ func TestInvoiceValidation(t *testing.T) { require.NoError(t, inv.Calculate()) assert.NoError(t, inv.Validate()) }) + + t.Run("regular invoice - only tax number nil tax ID", func(t *testing.T) { + inv := validInvoice() + inv.Supplier.TaxID = nil + inv.Supplier.Identities = []*org.Identity{ + { + Key: "de-tax-number", + Code: "92/345/67894", + }, + } + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) + }) } diff --git a/regimes/de/tax_categories.go b/regimes/de/tax_categories.go index f373b4c4..617d961f 100644 --- a/regimes/de/tax_categories.go +++ b/regimes/de/tax_categories.go @@ -103,6 +103,18 @@ var taxCategories = []*tax.CategoryDef{ }, }, }, + { + Key: tax.RateExempt, + Name: i18n.String{ + i18n.EN: "Exempt", + i18n.DE: "Befreit", + }, + Exempt: true, + Description: i18n.String{ + i18n.EN: "Certain goods and services are exempt from VAT.", + i18n.DE: "Bestimmte Waren und Dienstleistungen sind von der Umsatzsteuer befreit.", + }, + }, }, }, } diff --git a/tax/addons.go b/tax/addons.go index fcd24f00..521f44be 100644 --- a/tax/addons.go +++ b/tax/addons.go @@ -23,6 +23,10 @@ type AddonDef struct { // Key that defines how to uniquely idenitfy the add-on. Key cbc.Key `json:"key" jsonschema:"title=Key"` + // Requires defines any additional addons that this one depends on to operate + // correctly. + Requires []cbc.Key `json:"requires,omitempty" jsonschema:"title=Requires"` + // Name of the add-on Name i18n.String `json:"name" jsonschema:"title=Name"` @@ -74,8 +78,9 @@ func (as *Addons) GetAddons() []cbc.Key { return as.List } -// GetAddonDefs provides a slice of Addon Definition instances. -func (as Addons) GetAddonDefs() []*AddonDef { +// AddonDefs provides a slice of Addon Definition instances including +// any dependencies. +func (as Addons) AddonDefs() []*AddonDef { list := make([]*AddonDef, 0, len(as.List)) for _, ak := range as.List { if a := AddonForKey(ak); a != nil { @@ -85,6 +90,19 @@ func (as Addons) GetAddonDefs() []*AddonDef { return list } +// normalizeAddons ensures that the list of addons is normalized and is normally +// performed internally when preparing the list of normalizers to use. +func (as *Addons) normalizeAddons() { + list := make([]cbc.Key, 0, len(as.List)) + for _, ak := range as.List { + if ad := AddonForKey(ak); ad != nil { + list = cbc.AppendUniqueKeys(list, ad.Requires...) + list = cbc.AppendUniqueKeys(list, ad.Key) + } + } + as.List = list +} + // Validate ensures that the list of addons is valid. This struct is designed to be // embedded, so we don't perform a regular validation on the struct itself. func (as Addons) Validate() error { diff --git a/tax/addons_test.go b/tax/addons_test.go index aa6e840e..c795bc9e 100644 --- a/tax/addons_test.go +++ b/tax/addons_test.go @@ -27,7 +27,7 @@ func TestEmbeddingAddons(t *testing.T) { assert.Equal(t, []cbc.Key{"mx-cfdi-v4"}, ts.GetAddons()) - defs := ts.GetAddonDefs() + defs := ts.AddonDefs() assert.Len(t, defs, 1) assert.Equal(t, "mx-cfdi-v4", defs[0].Key.String()) @@ -35,6 +35,12 @@ func TestEmbeddingAddons(t *testing.T) { err := ts.Addons.Validate() assert.ErrorContains(t, err, "1: addon 'invalid-addon' not registered") + + t.Run("test addon normalization", func(t *testing.T) { + ts.Addons.List = []cbc.Key{"mx-cfdi-v4", "mx-cfdi-v4", "de-xrechnung-v3"} + _ = tax.ExtractNormalizers(ts) + assert.Equal(t, []cbc.Key{"mx-cfdi-v4", "eu-en16931-v2017", "de-xrechnung-v3"}, ts.Addons.List) + }) } func TestAddonForKey(t *testing.T) { diff --git a/tax/catalogue.go b/tax/catalogue.go new file mode 100644 index 00000000..9d561481 --- /dev/null +++ b/tax/catalogue.go @@ -0,0 +1,62 @@ +package tax + +import ( + "sort" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" +) + +// A CatalogueDef contains a set of re-useable extensions, scenarios, and validators that +// can be used by addons or tax regimes. This structure is useful for serializing the +// data into JSON for use in external libraries. +type CatalogueDef struct { + // Key defines a unique identifier for the catalogue. + Key cbc.Key `json:"key"` + // Name is the name of the catalogue. + Name i18n.String `json:"name"` + // Description is a human readable description of the catalogue. + Description i18n.String `json:"description"` + // Extensions defines all the extensions offered by the catalogue. + Extensions []*cbc.KeyDefinition `json:"extensions"` +} + +// RegisterCatalogueDef will register the catalogue in the global list of catalogues +// and ensure the extensions it contains are available in GOBL. +func RegisterCatalogueDef(catalogue *CatalogueDef) { + for _, ext := range catalogue.Extensions { + RegisterExtension(ext) + } + catalogues.add(catalogue) +} + +// AllCatalogueDefs provides a slice of all the addons defined. +func AllCatalogueDefs() []*CatalogueDef { + all := make([]*CatalogueDef, len(catalogues.list)) + for i, ao := range catalogues.keys { + all[i] = catalogues.list[ao] + } + return all +} + +type catalogueCollection struct { + keys []cbc.Key // ordered list + list map[cbc.Key]*CatalogueDef +} + +var catalogues = newCatalogueCollection() + +func newCatalogueCollection() *catalogueCollection { + return &catalogueCollection{ + list: make(map[cbc.Key]*CatalogueDef), + } +} + +// add will register the catalogye in the collection +func (c *catalogueCollection) add(cd *CatalogueDef) { + c.keys = append(c.keys, cd.Key) + sort.Slice(c.keys, func(i, j int) bool { + return c.keys[i].String() < c.keys[j].String() + }) + c.list[cd.Key] = cd +} diff --git a/tax/catalogue_test.go b/tax/catalogue_test.go new file mode 100644 index 00000000..08ae05e5 --- /dev/null +++ b/tax/catalogue_test.go @@ -0,0 +1,22 @@ +package tax_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestAllCatalogueDefs(t *testing.T) { + cds := tax.AllCatalogueDefs() + assert.GreaterOrEqual(t, len(cds), 1) + match := true + for _, cd := range cds { + if cd.Key == "untdid" { + match = true + break + } + } + assert.True(t, match) +} diff --git a/tax/extensions.go b/tax/extensions.go index 8016d520..35c6b5de 100644 --- a/tax/extensions.go +++ b/tax/extensions.go @@ -202,24 +202,26 @@ func CleanExtensions(em Extensions) Extensions { // ExtensionsHas returns a validation rule that ensures the extension map's // keys match those provided. func ExtensionsHas(keys ...cbc.Key) validation.Rule { - return validateCodeMap{keys: keys} + return validateExtCodeMap{ + keys: keys, + } } // ExtensionsRequires returns a validation rule that ensures all the // extension map's keys match those provided in the list. func ExtensionsRequires(keys ...cbc.Key) validation.Rule { - return validateCodeMap{ + return validateExtCodeMap{ required: true, keys: keys, } } -type validateCodeMap struct { +type validateExtCodeMap struct { keys []cbc.Key required bool } -func (v validateCodeMap) Validate(value interface{}) error { +func (v validateExtCodeMap) Validate(value interface{}) error { em, ok := value.(Extensions) if !ok { return nil @@ -246,6 +248,45 @@ func (v validateCodeMap) Validate(value interface{}) error { return nil } +// ExtensionsHasValues +func ExtensionsHasValues(key cbc.Key, values ...ExtValue) validation.Rule { + return validateExtCodeValues{ + key: key, + values: values, + } +} + +type validateExtCodeValues struct { + key cbc.Key + values []ExtValue +} + +func (v validateExtCodeValues) Validate(value interface{}) error { + em, ok := value.(Extensions) + if !ok { + return nil + } + err := make(validation.Errors) + + if ev, ok := em[v.key]; ok { + match := false + for _, val := range v.values { + if ev == val { + match = true + break + } + } + if !match { + err[v.key.String()] = errors.New("invalid value") + } + } + + if len(err) > 0 { + return err + } + return nil +} + // JSONSchemaExtend provides extra details about the extension map which are // not automatically determined. In this case we add validation for the map's // keys. diff --git a/tax/extensions_test.go b/tax/extensions_test.go index 5e7e443d..a1881e3c 100644 --- a/tax/extensions_test.go +++ b/tax/extensions_test.go @@ -6,8 +6,11 @@ import ( "github.com/invopop/gobl/addons/es/tbai" "github.com/invopop/gobl/addons/gr/mydata" "github.com/invopop/gobl/addons/mx/cfdi" // this will also prepare registers + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/catalogues/untdid" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/tax" + "github.com/invopop/validation" "github.com/stretchr/testify/assert" ) @@ -148,6 +151,127 @@ func TestExtValidation(t *testing.T) { }) } +func TestExtensionsHasValidation(t *testing.T) { + t.Run("nil", func(t *testing.T) { + err := validation.Validate(nil, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("empty", func(t *testing.T) { + em := tax.Extensions{} + err := validation.Validate(em, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("correct", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + } + err := validation.Validate(em, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("missing", func(t *testing.T) { + em := tax.Extensions{ + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.ErrorContains(t, err, "iso-scheme-id: invalid") + }) +} + +func TestExtensionsRequiresValidation(t *testing.T) { + t.Run("nil", func(t *testing.T) { + err := validation.Validate(nil, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("empty", func(t *testing.T) { + em := tax.Extensions{} + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.ErrorContains(t, err, "untdid-document-type: required") + }) + t.Run("correct", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + } + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("correct with extras", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("missing", func(t *testing.T) { + em := tax.Extensions{ + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.ErrorContains(t, err, "untdid-document-type: required") + }) +} + +func TestExtensionsHasValues(t *testing.T) { + t.Run("nil", func(t *testing.T) { + err := validation.Validate(nil, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("empty", func(t *testing.T) { + em := tax.Extensions{} + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("different extensions", func(t *testing.T) { + em := tax.Extensions{ + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("has codes", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + } + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("invalid code", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "102", + } + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.ErrorContains(t, err, "untdid-document-type: invalid value") + }) +} + func TestExtensionsHas(t *testing.T) { em := tax.Extensions{ "key": "value", diff --git a/tax/identity.go b/tax/identity.go index ddcba831..c6385266 100644 --- a/tax/identity.go +++ b/tax/identity.go @@ -139,7 +139,7 @@ func (id *Identity) Validate() error { func (v validateTaxID) Validate(value interface{}) error { id, ok := value.(*Identity) - if !ok { + if id == nil || !ok { return nil } rules := []*validation.FieldRules{ diff --git a/tax/regime_def.go b/tax/regime_def.go index 90273dbe..eec39f12 100644 --- a/tax/regime_def.go +++ b/tax/regime_def.go @@ -320,11 +320,11 @@ func (r *RegimeDef) TimeLocation() *time.Location { // ensure a rate key is defined inside a category. func (r *RegimeDef) InCategoryRates(cat cbc.Code) validation.Rule { if r == nil { - return validation.Empty + return validation.Empty.Error("must be blank when regime is undefined") } c := r.CategoryDef(cat) if c == nil { - return validation.Empty + return validation.Empty.Error("must be blank when category is undefined") } keys := make([]cbc.Key, len(c.Rates)) for i, x := range c.Rates { diff --git a/tax/tax.go b/tax/tax.go index 03cbb63e..53437b59 100644 --- a/tax/tax.go +++ b/tax/tax.go @@ -11,12 +11,13 @@ import ( func init() { schema.Register(schema.GOBL.Add("tax"), + Identity{}, Set{}, Extensions{}, Total{}, RegimeDef{}, AddonDef{}, - Identity{}, + CatalogueDef{}, ) } @@ -60,6 +61,36 @@ func Validators(ctx context.Context) []Validator { return list } +// ExtractNormalizers will extract the normalizers from the provided object +// that is using either the regime or addons. +func ExtractNormalizers(obj any) Normalizers { + if obj == nil { + return nil + } + normalizers := make(Normalizers, 0) + if n, ok := obj.(regimeImpl); ok { + if r := n.RegimeDef(); r != nil { + normalizers = normalizers.Append(r.Normalizer) + } + } + if n, ok := obj.(addonsImpl); ok { + n.normalizeAddons() + for _, a := range n.AddonDefs() { + normalizers = normalizers.Append(a.Normalizer) + } + } + return normalizers +} + +type regimeImpl interface { + RegimeDef() *RegimeDef +} + +type addonsImpl interface { + normalizeAddons() + AddonDefs() []*AddonDef +} + type normalizeImpl interface { Normalize(Normalizers) }