diff --git a/CHANGELOG.md b/CHANGELOG.md index 2731e2ca..7be3c657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,31 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Changed - `mx` – deprecated the `mx-cfdi-post-code` extension in favor of the customer address post code. +- New "tax catalogues" used for defining extensions for specific standards. +- `tax`: New "tax catalogues" used for defining extensions for specific standards. +- `iso`: catalogue created with `iso-schema-id` extensions. +- `untdid`: catalogue created with extensions: `untdid-document-type`, `untdid-payment-means`, `untdid-tax-category`, `untdid-allowance`, and `untdid-charge`. +- `eu-en16931-v2017`: addon for underlying support of the EN16931 semantic specifications. +- `de-xrechnung-v3`: addon with extra normalization for XRechnung specification in Germany. +- `pay`: Added `sepa` payment means key extension in main definition to be used with Credit Transfers and Direct Debit. +- `org`: `Identity` and `Inbox` support for extensions. +- `tax`: tags for `export` and `eea` (european economic area) for use with rates. +- `bill`: support for extensions in `Discount`, `Charge`, `LineDiscount`, and `LineCharge`. +- `bill`: specifically defined keys for Discounts and Charges. + +### Changed + +- `tax`: rate keys can now be extended, so `exempt+reverse-charge` will be accepted and may be used by addons to included additional codes. +- `tax`: Addons can now depend on other addons, whose keys will be automatically added during normalization. +- `cbc`: Code now allows `:` separator. + +### Removed + +- `pay`: UNTDID 4461 mappings from payment means table, now provided by catalogues +- `bill`: `Outlay` has been removed in favour of Charges, we've also not seen any evidence this field has been used. +- `bill`: `ref` field from discounts and charges in favour of `code`. +- `tax`: Regime `ChargeKeys` removed. Keys now provided in `bill` package. +- `it`: Charge keys no longer defined, no migration required, already supported. ## [v0.203.0] diff --git a/addons/addons.go b/addons/addons.go index 7eeac335..c632e9b3 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -11,8 +11,10 @@ import ( // Import all the addons to ensure they're ready to use. _ "github.com/invopop/gobl/addons/br/nfse" _ "github.com/invopop/gobl/addons/co/dian" + _ "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/co/dian/invoices.go b/addons/co/dian/invoices.go index 6016e828..6e6bd4ec 100644 --- a/addons/co/dian/invoices.go +++ b/addons/co/dian/invoices.go @@ -56,10 +56,6 @@ func validateInvoice(inv *bill.Invoice) error { validation.Each(validation.By(validateInvoicePreceding(inv.Type))), validation.Skip, ), - validation.Field(&inv.Outlays, - validation.Empty, - validation.Skip, - ), ) } diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go new file mode 100644 index 00000000..d09fb6b8 --- /dev/null +++ b/addons/de/xrechnung/instructions.go @@ -0,0 +1,79 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/pay" + "github.com/invopop/validation" +) + +// ValidatePaymentInstructions validates the payment instructions according to the XRechnung standard +func validatePaymentInstructions(value interface{}) error { + instr, ok := value.(*pay.Instructions) + if !ok || instr == nil { + return nil + } + return validation.ValidateStruct(instr, + // BR-DE-23 + validation.Field(&instr.CreditTransfer, + validation.When( + instr.Key.Has(pay.MeansKeyCreditTransfer), + validation.Required, + validation.Each(validation.By(validateCreditTransfer)), + ), + validation.Skip, + ), + // BR-DE-24 + validation.Field(&instr.Card, + validation.When( + instr.Key.Has(pay.MeansKeyCard), + validation.Required, + ), + validation.Skip, + ), + // BR-DE-25 + validation.Field(&instr.DirectDebit, + validation.When( + instr.Key.Has(pay.MeansKeyDirectDebit), + validation.Required, + validation.By(validateInstructionsDirectDebit), + validation.Skip, + ), + ), + ) +} + +func validateInstructionsDirectDebit(value interface{}) error { + dd, ok := value.(*pay.DirectDebit) + if !ok || dd == nil { + return nil + } + return validation.ValidateStruct(dd, + // BR-DE-29 - Changed to Peppol-EN16931-R061 + validation.Field(&dd.Ref, + validation.Required, + ), + // BR-DE-30 + validation.Field(&dd.Creditor, + validation.Required, + ), + // BR-DE-31 + validation.Field(&dd.Account, + validation.Required, + ), + ) +} + +// BR-DE-19 +func validateCreditTransfer(value interface{}) error { + ct, ok := value.(*pay.CreditTransfer) + if ct == nil || !ok { + return nil + } + 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 new file mode 100644 index 00000000..2f3cdf05 --- /dev/null +++ b/addons/de/xrechnung/instructions_test.go @@ -0,0 +1,110 @@ +package xrechnung_test + +import ( + "testing" + + "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 { + t.Helper() + inv := testInvoiceStandard(t) + inv.Payment = nil + return inv +} + +func TestValidateInvoice(t *testing.T) { + t.Run("valid invoice with SEPA credit transfer", func(t *testing.T) { + inv := invoiceTemplate(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: "credit-transfer+sepa", + CreditTransfer: []*pay.CreditTransfer{ + { + IBAN: "DE89370400440532013000", + BIC: "DEUTDEFF", + }, + }, + }, + } + 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: pay.MeansKeyCreditTransfer.With(pay.MeansKeySEPA), + CreditTransfer: []*pay.CreditTransfer{ + { + BIC: "DEUTDEFF", + }, + }, + }, + } + 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) { + inv := invoiceTemplate(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: pay.MeansKeyCard, + Card: &pay.Card{}, + }, + } + 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: "direct-debit+sepa", + DirectDebit: &pay.DirectDebit{ + Ref: "MANDATE123", + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", + }, + }, + } + 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: "direct-debit+sepa", + DirectDebit: &pay.DirectDebit{ + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", + }, + }, + } + 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) { + inv := invoiceTemplate(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: cbc.Key("invalid-key"), + }, + } + 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 new file mode 100644 index 00000000..c00a98c0 --- /dev/null +++ b/addons/de/xrechnung/invoices.go @@ -0,0 +1,54 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// 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 { + return validation.ValidateStruct(inv, + // BR-DE-17 + validation.Field(&inv.Tax, + validation.By(validateInvoiceTax), + validation.Skip, + ), + validation.Field(&inv.Preceding, + validation.When( + inv.Type.In( + bill.InvoiceTypeCorrective, + bill.InvoiceTypeCreditNote, + ), + validation.Required, + ), + validation.Skip, + ), + ) +} + +func validateInvoiceTax(value any) error { + tx, ok := value.(*bill.Tax) + if !ok || tx == nil { + return nil + } + return validation.ValidateStruct(tx, + validation.Field(&tx.Ext, + tax.ExtensionsHasValues(untdid.ExtKeyTaxCategory, validInvoiceUNTDIDDocumentTypeValues...), + validation.Skip, + ), + ) +} diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go new file mode 100644 index 00000000..6c07ed39 --- /dev/null +++ b/addons/de/xrechnung/invoices_test.go @@ -0,0 +1,152 @@ +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" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/tax" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testInvoiceStandard(t *testing.T) *bill.Invoice { + t.Helper() + inv := &bill.Invoice{ + Regime: tax.WithRegime("DE"), + Addons: tax.WithAddons(xrechnung.V3), + IssueDate: cal.MakeDate(2024, 1, 1), + 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", + }, + }, + Emails: []*org.Email{ + { + Address: "billing@cursor.com", + }, + }, + Telephones: []*org.Telephone{ + { + Number: "+49100200300", + }, + }, + }, + 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: "VAT", + Rate: "standard", + }, + }, + Discounts: []*bill.LineDiscount{ + { + Reason: "Testing", + Percent: num.NewPercentage(10, 2), + }, + }, + }, + }, + Ordering: &bill.Ordering{ + Code: "1234567890", + }, + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: "credit-transfer", + CreditTransfer: []*pay.CreditTransfer{ + { + IBAN: "DE89370400440532013000", + }, + }, + }, + }, + } + return inv +} + +func TestInvoiceValidation(t *testing.T) { + t.Run("standard invoice", func(t *testing.T) { + inv := testInvoiceStandard(t) + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + }) + t.Run("missing supplier tax ID", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.TaxID = nil + require.NoError(t, inv.Calculate()) + 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{ + { + Key: "de-tax-number", + Code: "123/456/7890", + }, + } + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.NoError(t, err) + }) + +} diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go new file mode 100644 index 00000000..bb918fce --- /dev/null +++ b/addons/de/xrechnung/xrechnung.go @@ -0,0 +1,54 @@ +// Package xrechnung provides extensions and validations for the German XRechnung standard version 3.0.2 for electronic invoicing. +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" +) + +const ( + // V3 is the key for the XRechnung version 3.x + V3 cbc.Key = "de-xrechnung-v3" +) + +func init() { + tax.RegisterAddonDef(newAddon()) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V3, + Name: i18n.String{ + i18n.EN: "German XRechnung 3.X", + }, + Requires: []cbc.Key{ + en16931.V2017, + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + 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 [www.xrechnung.de](https://www.xrechnung.de/). + `), + }, + Validator: validate, + } +} + +func validate(doc any) error { + switch obj := doc.(type) { + case *bill.Invoice: + 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..1a81a54b --- /dev/null +++ b/addons/eu/en16931/bill.go @@ -0,0 +1,96 @@ +package en16931 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +var discountKeyMap = tax.Extensions{ + bill.DiscountKeyEarlyCompletion: "41", + bill.DiscountKeyMilitary: "62", + bill.DiscountKeyWorkAccident: "63", + bill.DiscountKeySpecialAgreement: "64", + bill.DiscountKeyProductionError: "65", + bill.DiscountKeyNewOutlet: "66", + bill.DiscountKeySample: "67", + bill.DiscountKeyEndOfRange: "68", + bill.DiscountKeyIncoterm: "70", + bill.DiscountKeyPOSThreshold: "71", + bill.DiscountKeySpecialRebate: "100", + bill.DiscountKeyTemporary: "103", + bill.DiscountKeyStandard: "104", + bill.DiscountKeyYarlyTurnover: "105", +} + +// The following map is useful to get started, but for most users it will make +// sense to use the UNTDID codes directly in the extensions. +var chargeKeyMap = tax.Extensions{ + bill.ChargeKeyStampDuty: "ST", + bill.ChargeKeyOutlay: "AAE", + bill.ChargeKeyTax: "TX", + bill.ChargeKeyCustoms: "ABW", + bill.ChargeKeyDelivery: "DL", + bill.ChargeKeyPacking: "PC", + bill.ChargeKeyHandling: "HD", + bill.ChargeKeyInsurance: "IN", + bill.ChargeKeyStorage: "ABA", + bill.ChargeKeyAdmin: "AEM", + bill.ChargeKeyCleaning: "CG", +} + +func normalizeBillDiscount(m *bill.Discount) { + if val, ok := discountKeyMap[m.Key]; ok { + m.Ext = m.Ext.Merge(tax.Extensions{ + untdid.ExtKeyAllowance: val, + }) + } +} + +func normalizeBillLineDiscount(m *bill.LineDiscount) { + if val, ok := discountKeyMap[m.Key]; ok { + m.Ext = m.Ext.Merge(tax.Extensions{ + untdid.ExtKeyAllowance: val, + }) + } +} + +func normalizeBillCharge(m *bill.Charge) { + if val, ok := chargeKeyMap[m.Key]; ok { + m.Ext = m.Ext.Merge(tax.Extensions{ + untdid.ExtKeyCharge: val, + }) + } +} + +func normalizeBillLineCharge(m *bill.LineCharge) { + if val, ok := chargeKeyMap[m.Key]; ok { + m.Ext = m.Ext.Merge(tax.Extensions{ + untdid.ExtKeyCharge: val, + }) + } +} + +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..1d819671 --- /dev/null +++ b/addons/eu/en16931/bill_test.go @@ -0,0 +1,200 @@ +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 +} + +func TestNormalizeBillLineDiscount(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("with key", func(t *testing.T) { + l := &bill.LineDiscount{ + Key: "sample", + Reason: "Product sample", + Amount: num.MakeAmount(100, 2), + } + ad.Normalizer(l) + assert.Equal(t, "67", l.Ext[untdid.ExtKeyAllowance].String()) + }) + t.Run("without key", func(t *testing.T) { + l := &bill.LineDiscount{ + Reason: "Product sample", + Amount: num.MakeAmount(100, 2), + } + ad.Normalizer(l) + assert.Nil(t, l.Ext) + }) +} + +func TestNormalizeBillDiscount(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("with key", func(t *testing.T) { + l := &bill.Discount{ + Key: "sample", + Reason: "Product sample", + Amount: num.MakeAmount(100, 2), + } + ad.Normalizer(l) + assert.Equal(t, "67", l.Ext[untdid.ExtKeyAllowance].String()) + }) + t.Run("without key", func(t *testing.T) { + l := &bill.Discount{ + Reason: "Product sample", + Amount: num.MakeAmount(100, 2), + } + ad.Normalizer(l) + assert.Nil(t, l.Ext) + }) +} + +func TestNormalizeBillLineCharge(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("with key", func(t *testing.T) { + l := &bill.LineCharge{ + Key: "outlay", + Reason: "Notary costs", + Amount: num.MakeAmount(1000, 2), + } + ad.Normalizer(l) + assert.Equal(t, "AAE", l.Ext[untdid.ExtKeyCharge].String()) + }) + t.Run("without key", func(t *testing.T) { + l := &bill.LineCharge{ + Reason: "Additional costs", + Amount: num.MakeAmount(3000, 2), + } + ad.Normalizer(l) + assert.Nil(t, l.Ext) + }) +} + +func TestNormalizeBillCharge(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("with key", func(t *testing.T) { + l := &bill.Charge{ + Key: "outlay", + Reason: "Notary costs", + Amount: num.MakeAmount(1000, 2), + } + ad.Normalizer(l) + assert.Equal(t, "AAE", l.Ext[untdid.ExtKeyCharge].String()) + }) + t.Run("without key", func(t *testing.T) { + l := &bill.Charge{ + Reason: "Additional costs", + Amount: num.MakeAmount(3000, 2), + } + ad.Normalizer(l) + assert.Nil(t, l.Ext) + }) +} diff --git a/addons/eu/en16931/en16931.go b/addons/eu/en16931/en16931.go new file mode 100644 index 00000000..2c150e05 --- /dev/null +++ b/addons/eu/en16931/en16931.go @@ -0,0 +1,74 @@ +// 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) + case *bill.Discount: + normalizeBillDiscount(obj) + case *bill.LineDiscount: + normalizeBillLineDiscount(obj) + case *bill.Charge: + normalizeBillCharge(obj) + case *bill.LineCharge: + normalizeBillLineCharge(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..c30ce493 --- /dev/null +++ b/addons/eu/en16931/tax_combo.go @@ -0,0 +1,60 @@ +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", + tax.RateExempt.With(tax.TagExport).With(tax.TagEEA): "K", + tax.RateExempt.With(tax.TagExport): "G", +} + +// acceptedTaxCategories as defined by the EN 16931 code list values data. +var acceptedTaxCategories = []tax.ExtValue{ + "S", "Z", "E", "AE", "K", "G", "O", "L", "M", +} + +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), + tax.ExtensionsHasValues(untdid.ExtKeyTaxCategory, acceptedTaxCategories...), + 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..e18b2053 --- /dev/null +++ b/addons/eu/en16931/tax_combo_test.go @@ -0,0 +1,130 @@ +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) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + Percent: num.NewPercentage(19, 2), + } + 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("exempt reverse charge", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateExempt.With(tax.TagReverseCharge), + } + ad.Normalizer(c) + assert.NoError(t, ad.Validator(c)) + assert.Equal(t, "AE", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Nil(t, c.Percent) + }) + + t.Run("exempt export EEA", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateExempt.With(tax.TagExport).With(tax.TagEEA), + } + ad.Normalizer(c) + assert.NoError(t, ad.Validator(c)) + assert.Equal(t, "K", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Nil(t, c.Percent) + }) + + t.Run("exempt export", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateExempt.With(tax.TagExport), + } + ad.Normalizer(c) + assert.NoError(t, ad.Validator(c)) + assert.Equal(t, "G", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Nil(t, c.Percent) + }) + + 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/charges.go b/bill/charges.go index 3c13789d..65f715a2 100644 --- a/bill/charges.go +++ b/bill/charges.go @@ -5,57 +5,102 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/num" "github.com/invopop/gobl/tax" "github.com/invopop/gobl/uuid" + "github.com/invopop/jsonschema" "github.com/invopop/validation" ) -// LineCharge represents an amount added to the line, and will be -// applied before taxes. -// TODO: use UNTDID 7161 code list -type LineCharge struct { - // Percentage if fixed amount not applied - Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"` - // Fixed or resulting charge amount to apply (calculated if percent present). - Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"` - // Reference code. - Code string `json:"code,omitempty" jsonschema:"title=Code"` - // Text description as to why the charge was applied - Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` -} +// Charge keys for identifying the type of charge being applied. +// These are based on a subset of the UN/CEFACT UNTDID 7161 codes, +// and are intentionally kept lean. +const ( + ChargeKeyStampDuty cbc.Key = "stamp-duty" + ChargeKeyOutlay cbc.Key = "outlay" + ChargeKeyTax cbc.Key = "tax" + ChargeKeyCustoms cbc.Key = "customs" + ChargeKeyDelivery cbc.Key = "delivery" + ChargeKeyPacking cbc.Key = "packing" + ChargeKeyHandling cbc.Key = "handling" + ChargeKeyInsurance cbc.Key = "insurance" + ChargeKeyStorage cbc.Key = "storage" + ChargeKeyAdmin cbc.Key = "admin" // administration + ChargeKeyCleaning cbc.Key = "cleaning" +) -// Validate checks the line charge's fields. -func (lc *LineCharge) Validate() error { - return validation.ValidateStruct(lc, - validation.Field(&lc.Percent), - validation.Field(&lc.Amount, validation.Required), - ) +var chargeKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: ChargeKeyStampDuty, + Name: i18n.NewString("Stamp Duty"), + }, + { + Key: ChargeKeyOutlay, + Name: i18n.NewString("Outlay"), + }, + { + Key: ChargeKeyTax, + Name: i18n.NewString("Tax"), + }, + { + Key: ChargeKeyCustoms, + Name: i18n.NewString("Customs"), + }, + { + Key: ChargeKeyDelivery, + Name: i18n.NewString("Delivery"), + }, + { + Key: ChargeKeyPacking, + Name: i18n.NewString("Packing"), + }, + { + Key: ChargeKeyHandling, + Name: i18n.NewString("Handling"), + }, + { + Key: ChargeKeyInsurance, + Name: i18n.NewString("Insurance"), + }, + { + Key: ChargeKeyStorage, + Name: i18n.NewString("Storage"), + }, + { + Key: ChargeKeyAdmin, + Name: i18n.NewString("Administration"), + }, + { + Key: ChargeKeyCleaning, + Name: i18n.NewString("Cleaning"), + }, } // Charge represents a surchange applied to the complete document // independent from the individual lines. type Charge struct { uuid.Identify - // Key for grouping or identifying charges for tax purposes. - Key cbc.Key `json:"key,omitempty" jsonschema:"title=Key"` // Line number inside the list of charges (calculated). Index int `json:"i" jsonschema:"title=Index" jsonschema_extras:"calculated=true"` - // Code to used to refer to the this charge - Ref string `json:"ref,omitempty" jsonschema:"title=Reference"` + // Key for grouping or identifying charges for tax purposes. A suggested list of + // keys is provided, but these may be extended by the issuer. + Key cbc.Key `json:"key,omitempty" jsonschema:"title=Key"` + // Code to used to refer to the this charge by the issuer + Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"` + // Text description as to why the charge was applied + Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` // Base represents the value used as a base for percent calculations instead // of the invoice's sum of lines. Base *num.Amount `json:"base,omitempty" jsonschema:"title=Base"` - // Percentage to apply to the Base or Invoice Sum + // Percentage to apply to the sum of all lines Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"` // Amount to apply (calculated if percent present) Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"` // List of taxes to apply to the charge Taxes tax.Set `json:"taxes,omitempty" jsonschema:"title=Taxes"` - // Code for why was this charge applied? - Code string `json:"code,omitempty" jsonschema:"title=Reason Code"` - // Text description as to why the charge was applied - Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` + // Extension codes that apply to the charge + Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` // Additional semi-structured information. Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"` @@ -66,7 +111,9 @@ type Charge struct { // Normalize performs normalization on the line and embedded objects using the // provided list of normalizers. func (m *Charge) Normalize(normalizers tax.Normalizers) { + m.Code = cbc.NormalizeCode(m.Code) m.Taxes = tax.CleanSet(m.Taxes) + m.Ext = tax.CleanExtensions(m.Ext) normalizers.Each(m) tax.Normalize(normalizers, m.Taxes) } @@ -75,6 +122,8 @@ func (m *Charge) Normalize(normalizers tax.Normalizers) { func (m *Charge) ValidateWithContext(ctx context.Context) error { return tax.ValidateStructWithContext(ctx, m, validation.Field(&m.UUID), + validation.Field(&m.Key), + validation.Field(&m.Code), validation.Field(&m.Base), validation.Field(&m.Percent, validation.When( @@ -84,6 +133,7 @@ func (m *Charge) ValidateWithContext(ctx context.Context) error { ), validation.Field(&m.Amount, validation.Required), validation.Field(&m.Taxes), + validation.Field(&m.Ext), validation.Field(&m.Meta), ) } @@ -116,6 +166,11 @@ func (m *Charge) convertInto(ex *currency.ExchangeRate) *Charge { return &m2 } +// JSONSchemaExtend adds the charge key definitions to the schema. +func (Charge) JSONSchemaExtend(schema *jsonschema.Schema) { + extendJSONSchemaWithChargeKey(schema) +} + func calculateCharges(lines []*Charge, sum, zero num.Amount) { // COPIED FROM discount.go if len(lines) == 0 { @@ -151,3 +206,21 @@ func calculateChargeSum(charges []*Charge, zero num.Amount) *num.Amount { } return &total } + +func extendJSONSchemaWithChargeKey(schema *jsonschema.Schema) { + prop, ok := schema.Properties.Get("key") + if !ok { + return + } + prop.AnyOf = make([]*jsonschema.Schema, len(chargeKeyDefinitions)) + for i, v := range chargeKeyDefinitions { + prop.AnyOf[i] = &jsonschema.Schema{ + Const: v.Key, + Title: v.Name.String(), + } + } + prop.AnyOf = append(prop.AnyOf, &jsonschema.Schema{ + Title: "Other", + Pattern: cbc.KeyPattern, + }) +} diff --git a/bill/discounts.go b/bill/discounts.go index 31ff5d42..fadc7361 100644 --- a/bill/discounts.go +++ b/bill/discounts.go @@ -5,33 +5,91 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/num" "github.com/invopop/gobl/tax" "github.com/invopop/gobl/uuid" + "github.com/invopop/jsonschema" "github.com/invopop/validation" ) -// LineDiscount represents an amount deducted from the line, and will be -// applied before taxes. -type LineDiscount struct { - // Percentage if fixed amount not applied - Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"` - // Fixed discount amount to apply (calculated if percent present). - Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"` - // Reason code. - Code string `json:"code,omitempty" jsonschema:"title=Code"` - // Text description as to why the discount was applied - Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` - - // TODO: support UNTDID 5189 codes -} +// Discount keys for identifying the type of discount being applied. +// These are based on the UN/CEFACT UNTDID 5189 code list subset defined +// in the EN16931 code lists and are mean as suggestions. +const ( + DiscountKeyEarlyCompletion cbc.Key = "early-completion" + DiscountKeyMilitary cbc.Key = "military" + DiscountKeyWorkAccident cbc.Key = "work-accident" + DiscountKeySpecialAgreement cbc.Key = "special-agreement" + DiscountKeyProductionError cbc.Key = "production-error" + DiscountKeyNewOutlet cbc.Key = "new-outlet" + DiscountKeySample cbc.Key = "sample" + DiscountKeyEndOfRange cbc.Key = "end-of-range" + DiscountKeyIncoterm cbc.Key = "incoterm" + DiscountKeyPOSThreshold cbc.Key = "pos-threshold" + DiscountKeySpecialRebate cbc.Key = "special-rebate" + DiscountKeyTemporary cbc.Key = "temporary" + DiscountKeyStandard cbc.Key = "standard" + DiscountKeyYarlyTurnover cbc.Key = "yearly-turnover" +) -// Validate checks the line discount's fields. -func (ld *LineDiscount) Validate() error { - return validation.ValidateStruct(ld, - validation.Field(&ld.Percent), - validation.Field(&ld.Amount, validation.Required), - ) +var discountKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: DiscountKeyEarlyCompletion, + Name: i18n.NewString("Bonus for works ahead of schedule"), + }, + { + Key: DiscountKeyMilitary, + Name: i18n.NewString("Military Discount"), + }, + { + Key: DiscountKeyWorkAccident, + Name: i18n.NewString("Work Accident Discount"), + }, + { + Key: DiscountKeySpecialAgreement, + Name: i18n.NewString("Special Agreement Discount"), + }, + { + Key: DiscountKeyProductionError, + Name: i18n.NewString("Production Error Discount"), + }, + { + Key: DiscountKeyNewOutlet, + Name: i18n.NewString("New Outlet Discount"), + }, + { + Key: DiscountKeySample, + Name: i18n.NewString("Sample Discount"), + }, + { + Key: DiscountKeyEndOfRange, + Name: i18n.NewString("End of Range Discount"), + }, + { + Key: DiscountKeyIncoterm, + Name: i18n.NewString("Incoterm Discount"), + }, + { + Key: DiscountKeyPOSThreshold, + Name: i18n.NewString("Point of Sale Threshold Discount"), + }, + { + Key: DiscountKeySpecialRebate, + Name: i18n.NewString("Special Rebate"), + }, + { + Key: DiscountKeyTemporary, + Name: i18n.NewString("Temporary"), + }, + { + Key: DiscountKeyStandard, + Name: i18n.NewString("Standard"), + }, + { + Key: DiscountKeyYarlyTurnover, + Name: i18n.NewString("Yearly Turnover"), + }, } // Discount represents an allowance applied to the complete document @@ -42,8 +100,12 @@ type Discount struct { uuid.Identify // Line number inside the list of discounts (calculated) Index int `json:"i" jsonschema:"title=Index" jsonschema_extras:"calculated=true"` - // Reference or ID for this Discount - Ref string `json:"ref,omitempty" jsonschema:"title=Reference"` + // Key for identifying the type of discount being applied. + Key cbc.Key `json:"key,omitempty" jsonschema:"title=Key"` + // Code to used to refer to the this discount by the issuer + Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"` + // Text description as to why the discount was applied + Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` // Base represents the value used as a base for percent calculations instead // of the invoice's sum of lines. Base *num.Amount `json:"base,omitempty" jsonschema:"title=Base"` @@ -53,10 +115,8 @@ type Discount struct { Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"` // List of taxes to apply to the discount Taxes tax.Set `json:"taxes,omitempty" jsonschema:"title=Taxes"` - // Code for the reason this discount applied - Code string `json:"code,omitempty" jsonschema:"title=Reason Code"` - // Text description as to why the discount was applied - Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` + // Extension codes that apply to the discount + Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` // Additional semi-structured information. Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"` @@ -67,7 +127,9 @@ type Discount struct { // Normalize performs normalization on the line and embedded objects using the // provided list of normalizers. func (m *Discount) Normalize(normalizers tax.Normalizers) { + m.Code = cbc.NormalizeCode(m.Code) m.Taxes = tax.CleanSet(m.Taxes) + m.Ext = tax.CleanExtensions(m.Ext) normalizers.Each(m) tax.Normalize(normalizers, m.Taxes) } @@ -76,6 +138,7 @@ func (m *Discount) Normalize(normalizers tax.Normalizers) { func (m *Discount) ValidateWithContext(ctx context.Context) error { return tax.ValidateStructWithContext(ctx, m, validation.Field(&m.UUID), + validation.Field(&m.Code), validation.Field(&m.Base), validation.Field(&m.Percent, validation.When( @@ -85,6 +148,7 @@ func (m *Discount) ValidateWithContext(ctx context.Context) error { ), validation.Field(&m.Amount, validation.Required), validation.Field(&m.Taxes), + validation.Field(&m.Ext), validation.Field(&m.Meta), ) } @@ -118,6 +182,11 @@ func (m *Discount) convertInto(ex *currency.ExchangeRate) *Discount { return &m2 } +// JSONSchemaExtend adds the discount key definitions to the schema. +func (Discount) JSONSchemaExtend(schema *jsonschema.Schema) { + extendJSONSchemaWithDiscountKey(schema) +} + func calculateDiscounts(lines []*Discount, sum, zero num.Amount) { if len(lines) == 0 { return @@ -152,3 +221,21 @@ func calculateDiscountSum(discounts []*Discount, zero num.Amount) *num.Amount { } return &total } + +func extendJSONSchemaWithDiscountKey(schema *jsonschema.Schema) { + prop, ok := schema.Properties.Get("key") + if !ok { + return + } + prop.AnyOf = make([]*jsonschema.Schema, len(discountKeyDefinitions)) + for i, v := range discountKeyDefinitions { + prop.AnyOf[i] = &jsonschema.Schema{ + Const: v.Key, + Title: v.Name.String(), + } + } + prop.AnyOf = append(prop.AnyOf, &jsonschema.Schema{ + Title: "Other", + Pattern: cbc.KeyPattern, + }) +} diff --git a/bill/invoice.go b/bill/invoice.go index b915d8fe..9a0bff9f 100644 --- a/bill/invoice.go +++ b/bill/invoice.go @@ -65,7 +65,7 @@ type Invoice struct { // Special tax configuration for billing. Tax *Tax `json:"tax,omitempty" jsonschema:"title=Tax"` - // The taxable entity supplying the goods or services. + // The entity supplying the goods or services and usually responsible for paying taxes. Supplier *org.Party `json:"supplier" jsonschema:"title=Supplier"` // Legal entity receiving the goods or services, may be nil in certain circumstances such as simplified invoices. Customer *org.Party `json:"customer,omitempty" jsonschema:"title=Customer"` @@ -76,8 +76,6 @@ type Invoice struct { Discounts []*Discount `json:"discounts,omitempty" jsonschema:"title=Discounts"` // Charges or surcharges applied to the complete invoice Charges []*Charge `json:"charges,omitempty" jsonschema:"title=Charges"` - // Expenses paid for by the supplier but invoiced directly to the customer. - Outlays []*Outlay `json:"outlays,omitempty" jsonschema:"title=Outlays"` // Ordering details including document references and buyer or seller parties. Ordering *Ordering `json:"ordering,omitempty" jsonschema:"title=Ordering Details"` @@ -157,7 +155,6 @@ func (inv *Invoice) ValidateWithContext(ctx context.Context) error { ), validation.Field(&inv.Discounts), validation.Field(&inv.Charges), - validation.Field(&inv.Outlays), validation.Field(&inv.Ordering), validation.Field(&inv.Payment), validation.Field(&inv.Delivery), @@ -223,9 +220,6 @@ func (inv *Invoice) Invert() error { for _, row := range inv.Discounts { row.Amount = row.Amount.Invert() } - for _, row := range inv.Outlays { - row.Amount = row.Amount.Invert() - } if inv.Payment != nil { for _, row := range inv.Payment.Advances { row.Amount = row.Amount.Invert() @@ -252,7 +246,6 @@ func (inv *Invoice) Empty() { inv.Lines = make([]*Line, 0) inv.Charges = make([]*Charge, 0) inv.Discounts = make([]*Discount, 0) - inv.Outlays = make([]*Outlay, 0) inv.Totals = nil inv.Payment.ResetAdvances() } @@ -266,7 +259,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 +294,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 +311,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 @@ -513,12 +495,6 @@ func (inv *Invoice) calculate() error { t.Taxes = nil } - // Outlays - t.Outlays = calculateOutlays(zero, inv.Outlays) - if t.Outlays != nil { - t.Payable = t.Payable.Add(*t.Outlays) - } - if inv.Payment != nil { inv.Payment.calculateAdvances(zero, t.TotalWithTax) diff --git a/bill/invoice_convert.go b/bill/invoice_convert.go index 084802fc..23868449 100644 --- a/bill/invoice_convert.go +++ b/bill/invoice_convert.go @@ -40,7 +40,6 @@ func (inv *Invoice) ConvertInto(cur currency.Code) (*Invoice, error) { i2.Lines = inv.convertLines(ex) i2.Discounts = inv.convertDiscounts(ex) i2.Charges = inv.convertCharges(ex) - i2.Outlays = inv.convertOutlays(ex) i2.Payment = inv.convertPayment(ex) i2.Currency = cur @@ -136,22 +135,6 @@ func (inv *Invoice) convertCharges(ex *currency.ExchangeRate) []*Charge { return charges } -func (inv *Invoice) convertOutlays(ex *currency.ExchangeRate) []*Outlay { - if len(inv.Outlays) == 0 { - return nil - } - outlays := make([]*Outlay, len(inv.Outlays)) - for i, o := range inv.Outlays { - o2 := *o - o2.Amount = o2.Amount. - Upscale(defaultCurrencyConversionAccuracy). - Multiply(ex.Amount). - Downscale(defaultCurrencyConversionAccuracy) - outlays[i] = &o2 - } - return outlays -} - func (inv *Invoice) convertPayment(ex *currency.ExchangeRate) *Payment { if inv.Payment == nil { return nil diff --git a/bill/invoice_convert_test.go b/bill/invoice_convert_test.go index d7fd9da5..5c502a85 100644 --- a/bill/invoice_convert_test.go +++ b/bill/invoice_convert_test.go @@ -148,12 +148,6 @@ func TestInvoiceConvertInto(t *testing.T) { }, }, }, - Outlays: []*bill.Outlay{ - { - Description: "Something paid in advance", - Amount: num.MakeAmount(1000, 2), - }, - }, Payment: &bill.Payment{ Advances: []*pay.Advance{ { @@ -166,10 +160,9 @@ func TestInvoiceConvertInto(t *testing.T) { i2, err := i.ConvertInto(currency.USD) assert.NoError(t, err) require.NotNil(t, i2) - assert.Equal(t, "11.20", i2.Outlays[0].Amount.String()) assert.Equal(t, "643.72", i2.Payment.Advances[0].Amount.String()) assert.Equal(t, "1064.00", i2.Totals.Sum.String()) - assert.Equal(t, "1298.64", i2.Totals.Payable.String()) - assert.Equal(t, "654.92", i2.Totals.Due.String()) + assert.Equal(t, "1287.44", i2.Totals.Payable.String()) + assert.Equal(t, "643.72", i2.Totals.Due.String()) }) } 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/bill/invoice_test.go b/bill/invoice_test.go index a57c9907..a963843d 100644 --- a/bill/invoice_test.go +++ b/bill/invoice_test.go @@ -936,12 +936,6 @@ func TestCalculate(t *testing.T) { }, }, }, - Outlays: []*bill.Outlay{ - { - Description: "Something paid in advance", - Amount: num.MakeAmount(1000, 2), - }, - }, Payment: &bill.Payment{ Advances: []*pay.Advance{ { @@ -959,8 +953,8 @@ func TestCalculate(t *testing.T) { assert.Equal(t, i.Totals.TotalWithTax.String(), "950.00") assert.Equal(t, i.Payment.Advances[0].Amount.String(), "285.00") assert.Equal(t, i.Totals.Advances.String(), "285.00") - assert.Equal(t, i.Totals.Payable.String(), "960.00") - assert.Equal(t, i.Totals.Due.String(), "675.00") + assert.Equal(t, i.Totals.Payable.String(), "950.00") + assert.Equal(t, i.Totals.Due.String(), "665.00") assert.False(t, i.Totals.Paid()) } @@ -1010,12 +1004,6 @@ func TestCalculateInverted(t *testing.T) { }, }, }, - Outlays: []*bill.Outlay{ - { - Description: "Something paid in advance", - Amount: num.MakeAmount(1000, 2), - }, - }, Payment: &bill.Payment{ Advances: []*pay.Advance{ { @@ -1028,11 +1016,11 @@ func TestCalculateInverted(t *testing.T) { require.NoError(t, i.Calculate()) assert.Equal(t, i.Totals.Sum.String(), "950.00") - assert.Equal(t, i.Totals.Due.String(), "710.00") + assert.Equal(t, i.Totals.Due.String(), "700.00") require.NoError(t, i.Invert()) assert.Equal(t, i.Totals.Sum.String(), "-950.00") - assert.Equal(t, i.Totals.Due.String(), "-710.00") + assert.Equal(t, i.Totals.Due.String(), "-700.00") } func TestInvoiceForUnknownRegime(t *testing.T) { diff --git a/bill/line.go b/bill/line.go index e1997280..f6a6f89e 100644 --- a/bill/line.go +++ b/bill/line.go @@ -75,6 +75,8 @@ func (l *Line) Normalize(normalizers tax.Normalizers) { normalizers.Each(l) tax.Normalize(normalizers, l.Taxes) tax.Normalize(normalizers, l.Item) + tax.Normalize(normalizers, l.Discounts) + tax.Normalize(normalizers, l.Charges) } // calculate figures out the totals according to quantity and discounts. diff --git a/bill/line_charge.go b/bill/line_charge.go new file mode 100644 index 00000000..beb3bef1 --- /dev/null +++ b/bill/line_charge.go @@ -0,0 +1,52 @@ +package bill + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" + "github.com/invopop/jsonschema" + "github.com/invopop/validation" +) + +// LineCharge represents an amount added to the line, and will be +// applied before taxes. +type LineCharge struct { + // Key for grouping or identifying charges for tax purposes. A suggested list of + // keys is provided, but these are for reference only and may be extended by + // the issuer. + Key cbc.Key `json:"key,omitempty" jsonschema:"title=Key"` + // Reference or ID for this charge defined by the issuer + Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"` + // Text description as to why the charge was applied + Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` + // Percentage if fixed amount not applied + Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"` + // Fixed or resulting charge amount to apply (calculated if percent present). + Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"` + // Extension codes that apply to the charge + Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` +} + +// Normalize performs normalization on the charge and embedded objects using the +// provided list of normalizers. +func (lc *LineCharge) Normalize(normalizers tax.Normalizers) { + lc.Code = cbc.NormalizeCode(lc.Code) + lc.Ext = tax.CleanExtensions(lc.Ext) + normalizers.Each(lc) +} + +// Validate checks the line charge's fields. +func (lc *LineCharge) Validate() error { + return validation.ValidateStruct(lc, + validation.Field(&lc.Key), + validation.Field(&lc.Code), + validation.Field(&lc.Percent), + validation.Field(&lc.Amount, validation.Required, num.NotZero), + validation.Field(&lc.Ext), + ) +} + +// JSONSchemaExtend adds the charge key definitions to the schema. +func (LineCharge) JSONSchemaExtend(schema *jsonschema.Schema) { + extendJSONSchemaWithChargeKey(schema) +} diff --git a/bill/line_charge_test.go b/bill/line_charge_test.go new file mode 100644 index 00000000..8943ef1d --- /dev/null +++ b/bill/line_charge_test.go @@ -0,0 +1,59 @@ +package bill_test + +import ( + "encoding/json" + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLineChargeNormalize(t *testing.T) { + l := &bill.LineCharge{ + Code: " FOO--BAR ", + Percent: num.NewPercentage(200, 3), + Ext: tax.Extensions{}, + } + l.Normalize(nil) + assert.Equal(t, "20.0%", l.Percent.String()) + assert.Equal(t, "FOO-BAR", l.Code.String()) + assert.Nil(t, l.Ext) +} + +func TestLineChargeValidation(t *testing.T) { + l := &bill.LineCharge{ + Key: "foo", + Code: "BAR", + Amount: num.MakeAmount(100, 2), + } + err := l.Validate() + assert.NoError(t, err) + + l.Amount = num.MakeAmount(0, 2) + err = l.Validate() + assert.ErrorContains(t, err, "amount: must not be zero") +} + +func TestLineChargeJSONSchema(t *testing.T) { + eg := `{ + "type": "object", + "properties": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key" + } + } + }` + js := new(jsonschema.Schema) + require.NoError(t, json.Unmarshal([]byte(eg), js)) + schema := bill.LineCharge{} + schema.JSONSchemaExtend(js) + + props, ok := js.Properties.Get("key") + assert.True(t, ok) + assert.NotNil(t, props) + assert.Equal(t, 12, len(props.AnyOf)) +} diff --git a/bill/line_discount.go b/bill/line_discount.go new file mode 100644 index 00000000..0a13bd5c --- /dev/null +++ b/bill/line_discount.go @@ -0,0 +1,50 @@ +package bill + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" + "github.com/invopop/jsonschema" + "github.com/invopop/validation" +) + +// LineDiscount represents an amount deducted from the line, and will be +// applied before taxes. +type LineDiscount struct { + // Key for identifying the type of discount being applied. + Key cbc.Key `json:"key,omitempty" jsonschema:"title=Key"` + // Code or reference for this discount defined by the issuer + Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"` + // Text description as to why the discount was applied + Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` + // Percentage to apply to the line total to calcaulte the discount amount + Percent *num.Percentage `json:"percent,omitempty" jsonschema:"title=Percent"` + // Fixed discount amount to apply (calculated if percent present) + Amount num.Amount `json:"amount" jsonschema:"title=Amount" jsonschema_extras:"calculated=true"` + // Extension codes that apply to the discount + Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` +} + +// Normalize performs normalization on the discount and embedded objects using the +// provided list of normalizers. +func (ld *LineDiscount) Normalize(normalizers tax.Normalizers) { + ld.Code = cbc.NormalizeCode(ld.Code) + ld.Ext = tax.CleanExtensions(ld.Ext) + normalizers.Each(ld) +} + +// Validate checks the line discount's fields. +func (ld *LineDiscount) Validate() error { + return validation.ValidateStruct(ld, + validation.Field(&ld.Key), + validation.Field(&ld.Code), + validation.Field(&ld.Percent), + validation.Field(&ld.Amount, validation.Required, num.NotZero), + validation.Field(&ld.Ext), + ) +} + +// JSONSchemaExtend adds the discount key definitions to the schema. +func (LineDiscount) JSONSchemaExtend(schema *jsonschema.Schema) { + extendJSONSchemaWithDiscountKey(schema) +} diff --git a/bill/line_discount_test.go b/bill/line_discount_test.go new file mode 100644 index 00000000..df09cc04 --- /dev/null +++ b/bill/line_discount_test.go @@ -0,0 +1,59 @@ +package bill_test + +import ( + "encoding/json" + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" + "github.com/invopop/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLineDiscountNormalize(t *testing.T) { + l := &bill.LineDiscount{ + Code: " FOO--BAR ", + Percent: num.NewPercentage(200, 3), + Ext: tax.Extensions{}, + } + l.Normalize(nil) + assert.Equal(t, "20.0%", l.Percent.String()) + assert.Equal(t, "FOO-BAR", l.Code.String()) + assert.Nil(t, l.Ext) +} + +func TestLineDiscountValidation(t *testing.T) { + l := &bill.LineDiscount{ + Key: "foo", + Code: "BAR", + Amount: num.MakeAmount(100, 2), + } + err := l.Validate() + assert.NoError(t, err) + + l.Amount = num.MakeAmount(0, 2) + err = l.Validate() + assert.ErrorContains(t, err, "amount: must not be zero") +} + +func TestLineDiscountJSONSchema(t *testing.T) { + eg := `{ + "type": "object", + "properties": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key" + } + } + }` + js := new(jsonschema.Schema) + require.NoError(t, json.Unmarshal([]byte(eg), js)) + schema := bill.LineDiscount{} + schema.JSONSchemaExtend(js) + + props, ok := js.Properties.Get("key") + assert.True(t, ok) + assert.NotNil(t, props) + assert.True(t, len(props.AnyOf) > 10) +} diff --git a/bill/ordering.go b/bill/ordering.go index 558c0620..5c5a6ac5 100644 --- a/bill/ordering.go +++ b/bill/ordering.go @@ -19,11 +19,9 @@ type Ordering struct { // Period of time that the invoice document refers to often used in addition to the details // provided in the individual line items. Period *cal.Period `json:"period,omitempty" jsonschema:"title=Period"` - // Party who is responsible for making the purchase, but is not responsible - // for handling taxes. + // Party who is responsible for issuing payment, if not the same as the customer. Buyer *org.Party `json:"buyer,omitempty" jsonschema:"title=Buyer"` - // Party who is selling the goods but is not responsible for taxes like the - // supplier. + // Seller is the party liable to pay taxes on the transaction if not the same as the supplier. Seller *org.Party `json:"seller,omitempty" jsonschema:"title=Seller"` // Projects this invoice refers to. Projects []*org.DocumentRef `json:"projects,omitempty" jsonschema:"title=Projects"` diff --git a/bill/outlay.go b/bill/outlay.go deleted file mode 100644 index 7f238d6d..00000000 --- a/bill/outlay.go +++ /dev/null @@ -1,78 +0,0 @@ -package bill - -import ( - "encoding/json" - - "github.com/invopop/gobl/cal" - "github.com/invopop/gobl/num" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/uuid" - "github.com/invopop/validation" -) - -// Outlay represents a reimbursable expense that was paid for by the supplier and invoiced separately -// by the third party directly to the customer. -// Most suppliers will want to include the expenses of their providers as part of their -// own operational costs. However, outlays are common in countries like Spain where it is typical -// for an accountant or lawyer to pay for notary fees, but forward the invoice to the -// customer. -type Outlay struct { - uuid.Identify - // Outlay number index inside the invoice for ordering (calculated). - Index int `json:"i" jsonschema:"title=Index" jsonschema_extras:"calculated=true"` - // When was the outlay made. - Date *cal.Date `json:"date,omitempty" jsonschema:"title=Date"` - // Invoice number or other reference detail used to identify the outlay. - Code string `json:"code,omitempty" jsonschema:"title=Code"` - // Series of the outlay invoice. - Series string `json:"series,omitempty" jsonschema:"title=Series"` - // Details on what the outlay was. - Description string `json:"description" jsonschema:"title=Description"` - // Who was the supplier of the outlay - Supplier *org.Party `json:"supplier,omitempty" jsonschema:"title=Supplier"` - // Amount paid by the supplier. - Amount num.Amount `json:"amount" jsonschema:"title=Amount"` -} - -// Validate ensures the outlay contains everything required. -func (o *Outlay) Validate() error { - return validation.ValidateStruct(o, - validation.Field(&o.UUID), - validation.Field(&o.Index, validation.Required), - validation.Field(&o.Date), - validation.Field(&o.Description, validation.Required), - validation.Field(&o.Supplier), - validation.Field(&o.Amount, validation.Required), - ) -} - -// UnmarshalJSON helps migrate the desc field to description. -func (o *Outlay) UnmarshalJSON(data []byte) error { - type Alias Outlay - aux := struct { - Desc string `json:"desc"` - *Alias - }{ - Alias: (*Alias)(o), - } - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - if aux.Desc != "" { - o.Description = aux.Desc - } - return nil -} - -func calculateOutlays(zero num.Amount, outlays []*Outlay) *num.Amount { - if len(outlays) == 0 { - return nil - } - total := zero - for i, o := range outlays { - o.Amount = o.Amount.MatchPrecision(zero) - o.Index = i + 1 - total = total.Add(o.Amount) - } - return &total -} diff --git a/bill/outlay_test.go b/bill/outlay_test.go deleted file mode 100644 index 1120771a..00000000 --- a/bill/outlay_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package bill - -import ( - "encoding/json" - "testing" - - "github.com/invopop/gobl/num" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestOutlayUnmarshal(t *testing.T) { - o := new(Outlay) - err := json.Unmarshal([]byte(`{"desc":"foo"}`), o) - require.NoError(t, err) - assert.Equal(t, "foo", o.Description) - err = json.Unmarshal([]byte(`{"description":"foo"}`), o) - require.NoError(t, err) - assert.Equal(t, "foo", o.Description) -} - -func TestOutlayTotals(t *testing.T) { - os := []*Outlay{ - { - Description: "First outlay", - Amount: num.MakeAmount(10000, 2), - }, - { - Description: "Second outlay", - Amount: num.MakeAmount(200, 0), - }, - } - zero := num.MakeAmount(0, 2) - sum := calculateOutlays(zero, os) - require.NotNil(t, sum) - assert.Equal(t, 1, os[0].Index) - assert.Equal(t, 2, os[1].Index) - assert.Equal(t, "300.00", sum.String()) - assert.Equal(t, "200.00", os[1].Amount.String()) - - os = []*Outlay{} - sum = calculateOutlays(zero, os) - assert.Nil(t, sum) -} 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/iso.go b/catalogues/iso/iso.go new file mode 100644 index 00000000..d6e0210c --- /dev/null +++ b/catalogues/iso/iso.go @@ -0,0 +1,23 @@ +// Package iso is used to define ISO/IEC extensions and codes that may be used +// in documents. +package iso + +import ( + "github.com/invopop/gobl/cbc" + "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: []*cbc.KeyDefinition{ + extSchemeID, + }, + } +} 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/iso/scheme_id.go b/catalogues/iso/scheme_id.go new file mode 100644 index 00000000..24aa96c5 --- /dev/null +++ b/catalogues/iso/scheme_id.go @@ -0,0 +1,26 @@ +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 extSchemeID = &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/untdid/allowance.go b/catalogues/untdid/allowance.go new file mode 100644 index 00000000..0fe92589 --- /dev/null +++ b/catalogues/untdid/allowance.go @@ -0,0 +1,105 @@ +package untdid + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeyAllowance is used to identify the UNTDID 5189 allownce codes + // used in discounts. + ExtKeyAllowance cbc.Key = "untdid-allowance" +) + +var extAllowance = &cbc.KeyDefinition{ + Key: ExtKeyAllowance, + Name: i18n.String{ + i18n.EN: "UNTDID 5189 Allowance", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 5189 code used to describe the allowance type. 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: "41", + Name: i18n.NewString("Bonus for works ahead of schedule"), + }, + { + Value: "42", + Name: i18n.NewString("Other bonus"), + }, + { + Value: "60", + Name: i18n.NewString("Manufacturer’s consumer discount"), + }, + { + Value: "62", + Name: i18n.NewString("Due to military status"), + }, + { + Value: "63", + Name: i18n.NewString("Due to work accident"), + }, + { + Value: "64", + Name: i18n.NewString("Special agreement"), + }, + { + Value: "65", + Name: i18n.NewString("Production error discount"), + }, + { + Value: "66", + Name: i18n.NewString("New outlet discount"), + }, + { + Value: "67", + Name: i18n.NewString("Sample discount"), + }, + { + Value: "68", + Name: i18n.NewString("End-of-range discount"), + }, + { + Value: "70", + Name: i18n.NewString("Incoterm discount"), + }, + { + Value: "71", + Name: i18n.NewString("Point of sales threshold allowance"), + }, + { + Value: "88", + Name: i18n.NewString("Material surcharge/deduction"), + }, + { + Value: "95", + Name: i18n.NewString("Discount"), + }, + { + Value: "100", + Name: i18n.NewString("Special rebate"), + }, + { + Value: "102", + Name: i18n.NewString("Fixed long term"), + }, + { + Value: "103", + Name: i18n.NewString("Temporary"), + }, + { + Value: "104", + Name: i18n.NewString("Standard"), + }, + { + Value: "105", + Name: i18n.NewString("Yearly turnover"), + }, + }, +} diff --git a/catalogues/untdid/charge.go b/catalogues/untdid/charge.go new file mode 100644 index 00000000..2ba7bf81 --- /dev/null +++ b/catalogues/untdid/charge.go @@ -0,0 +1,753 @@ +package untdid + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeyCharge is used to identify the UNTDID 7161 charge codes. + ExtKeyCharge cbc.Key = "untdid-charge" +) + +var extCharge = &cbc.KeyDefinition{ + Key: ExtKeyCharge, + Name: i18n.NewString("UNTDID 7161 Charge"), + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 7161 code used to describe the charge. List is based on the + EN16931 code lists with extensions for taxes and duties. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "AA", + Name: i18n.NewString("Advertising"), + }, + { + Value: "AAA", + Name: i18n.NewString("Telecommunication"), + }, + { + Value: "AAC", + Name: i18n.NewString("Technical modification"), + }, + { + Value: "AAD", + Name: i18n.NewString("Job-order production"), + }, + { + Value: "AAE", + Name: i18n.NewString("Outlays"), + }, + { + Value: "AAF", + Name: i18n.NewString("Off-premises"), + }, + { + Value: "AAH", + Name: i18n.NewString("Additional processing"), + }, + { + Value: "AAI", + Name: i18n.NewString("Attesting"), + }, + { + Value: "AAS", + Name: i18n.NewString("Acceptance"), + }, + { + Value: "AAT", + Name: i18n.NewString("Rush delivery"), + }, + { + Value: "AAV", + Name: i18n.NewString("Special construction"), + }, + { + Value: "AAY", + Name: i18n.NewString("Airport facilities"), + }, + { + Value: "AAZ", + Name: i18n.NewString("Concession"), + }, + { + Value: "ABA", + Name: i18n.NewString("Compulsory storage"), + }, + { + Value: "ABB", + Name: i18n.NewString("Fuel removal"), + }, + { + Value: "ABC", + Name: i18n.NewString("Into plane"), + }, + { + Value: "ABD", + Name: i18n.NewString("Overtime"), + }, + { + Value: "ABF", + Name: i18n.NewString("Tooling"), + }, + { + Value: "ABK", + Name: i18n.NewString("Miscellaneous"), + }, + { + Value: "ABL", + Name: i18n.NewString("Additional packaging"), + }, + { + Value: "ABN", + Name: i18n.NewString("Dunnage"), + }, + { + Value: "ABR", + Name: i18n.NewString("Containerisation"), + }, + { + Value: "ABS", + Name: i18n.NewString("Carton packing"), + }, + { + Value: "ABT", + Name: i18n.NewString("Hessian wrapped"), + }, + { + Value: "ABU", + Name: i18n.NewString("Polyethylene wrap packing"), + }, + { + Value: "ABW", // not in EN16931 + Name: i18n.NewString("Customs duty charge"), + }, + { + Value: "ACF", + Name: i18n.NewString("Miscellaneous treatment"), + }, + { + Value: "ACG", + Name: i18n.NewString("Enamelling treatment"), + }, + { + Value: "ACH", + Name: i18n.NewString("Heat treatment"), + }, + { + Value: "ACI", + Name: i18n.NewString("Plating treatment"), + }, + { + Value: "ACJ", + Name: i18n.NewString("Painting"), + }, + { + Value: "ACK", + Name: i18n.NewString("Polishing"), + }, + { + Value: "ACL", + Name: i18n.NewString("Priming"), + }, + { + Value: "ACM", + Name: i18n.NewString("Preservation treatment"), + }, + { + Value: "ACS", + Name: i18n.NewString("Fitting"), + }, + { + Value: "ADC", + Name: i18n.NewString("Consolidation"), + }, + { + Value: "ADE", + Name: i18n.NewString("Bill of lading"), + }, + { + Value: "ADJ", + Name: i18n.NewString("Airbag"), + }, + { + Value: "ADK", + Name: i18n.NewString("Transfer"), + }, + { + Value: "ADL", + Name: i18n.NewString("Slipsheet"), + }, + { + Value: "ADM", + Name: i18n.NewString("Binding"), + }, + { + Value: "ADN", + Name: i18n.NewString("Repair or replacement of broken returnable package"), + }, + { + Value: "ADO", + Name: i18n.NewString("Efficient logistics"), + }, + { + Value: "ADP", + Name: i18n.NewString("Merchandising"), + }, + { + Value: "ADQ", + Name: i18n.NewString("Product mix"), + }, + { + Value: "ADR", + Name: i18n.NewString("Other services"), + }, + { + Value: "ADT", + Name: i18n.NewString("Pick-up"), + }, + { + Value: "ADW", + Name: i18n.NewString("Chronic illness"), + }, + { + Value: "ADY", + Name: i18n.NewString("New product introduction"), + }, + { + Value: "ADZ", + Name: i18n.NewString("Direct delivery"), + }, + { + Value: "AEA", + Name: i18n.NewString("Diversion"), + }, + { + Value: "AEB", + Name: i18n.NewString("Disconnect"), + }, + { + Value: "AEC", + Name: i18n.NewString("Distribution"), + }, + { + Value: "AED", + Name: i18n.NewString("Handling of hazardous cargo"), + }, + { + Value: "AEF", + Name: i18n.NewString("Rents and leases"), + }, + { + Value: "AEH", + Name: i18n.NewString("Location differential"), + }, + { + Value: "AEI", + Name: i18n.NewString("Aircraft refueling"), + }, + { + Value: "AEJ", + Name: i18n.NewString("Fuel shipped into storage"), + }, + { + Value: "AEK", + Name: i18n.NewString("Cash on delivery"), + }, + { + Value: "AEL", + Name: i18n.NewString("Small order processing service"), + }, + { + Value: "AEM", + Name: i18n.NewString("Clerical or administrative services"), + }, + { + Value: "AEN", + Name: i18n.NewString("Guarantee"), + }, + { + Value: "AEO", + Name: i18n.NewString("Collection and recycling"), + }, + { + Value: "AEP", + Name: i18n.NewString("Copyright fee collection"), + }, + { + Value: "AES", + Name: i18n.NewString("Veterinary inspection service"), + }, + { + Value: "AET", + Name: i18n.NewString("Pensioner service"), + }, + { + Value: "AEU", + Name: i18n.NewString("Medicine free pass holder"), + }, + { + Value: "AEV", + Name: i18n.NewString("Environmental protection service"), + }, + { + Value: "AEW", + Name: i18n.NewString("Environmental clean-up service"), + }, + { + Value: "AEX", + Name: i18n.NewString("National cheque processing service outside account area"), + }, + { + Value: "AEY", + Name: i18n.NewString("National payment service outside account area"), + }, + { + Value: "AEZ", + Name: i18n.NewString("National payment service within account area"), + }, + { + Value: "AJ", + Name: i18n.NewString("Adjustments"), + }, + { + Value: "AU", + Name: i18n.NewString("Authentication"), + }, + { + Value: "CA", + Name: i18n.NewString("Cataloguing"), + }, + { + Value: "CAB", + Name: i18n.NewString("Cartage"), + }, + { + Value: "CAD", + Name: i18n.NewString("Certification"), + }, + { + Value: "CAE", + Name: i18n.NewString("Certificate of conformance"), + }, + { + Value: "CAF", + Name: i18n.NewString("Certificate of origin"), + }, + { + Value: "CAI", + Name: i18n.NewString("Cutting"), + }, + { + Value: "CAJ", + Name: i18n.NewString("Consular service"), + }, + { + Value: "CAK", + Name: i18n.NewString("Customer collection"), + }, + { + Value: "CAL", + Name: i18n.NewString("Payroll payment service"), + }, + { + Value: "CAM", + Name: i18n.NewString("Cash transportation"), + }, + { + Value: "CAN", + Name: i18n.NewString("Home banking service"), + }, + { + Value: "CAO", + Name: i18n.NewString("Bilateral agreement service"), + }, + { + Value: "CAP", + Name: i18n.NewString("Insurance brokerage service"), + }, + { + Value: "CAQ", + Name: i18n.NewString("Cheque generation"), + }, + { + Value: "CAR", + Name: i18n.NewString("Preferential merchandising location"), + }, + { + Value: "CAS", + Name: i18n.NewString("Crane"), + }, + { + Value: "CAT", + Name: i18n.NewString("Special colour service"), + }, + { + Value: "CAU", + Name: i18n.NewString("Sorting"), + }, + { + Value: "CAV", + Name: i18n.NewString("Battery collection and recycling"), + }, + { + Value: "CAW", + Name: i18n.NewString("Product take back fee"), + }, + { + Value: "CAX", + Name: i18n.NewString("Quality control released"), + }, + { + Value: "CAY", + Name: i18n.NewString("Quality control held"), + }, + { + Value: "CAZ", + Name: i18n.NewString("Quality control embargo"), + }, + { + Value: "CD", + Name: i18n.NewString("Car loading"), + }, + { + Value: "CG", + Name: i18n.NewString("Cleaning"), + }, + { + Value: "CS", + Name: i18n.NewString("Cigarette stamping"), + }, + { + Value: "CT", + Name: i18n.NewString("Count and recount"), + }, + { + Value: "DAB", + Name: i18n.NewString("Layout/design"), + }, + { + Value: "DAC", + Name: i18n.NewString("Assortment allowance"), + }, + { + Value: "DAD", + Name: i18n.NewString("Driver assigned unloading"), + }, + { + Value: "DAF", + Name: i18n.NewString("Debtor bound"), + }, + { + Value: "DAG", + Name: i18n.NewString("Dealer allowance"), + }, + { + Value: "DAH", + Name: i18n.NewString("Allowance transferable to the consumer"), + }, + { + Value: "DAI", + Name: i18n.NewString("Growth of business"), + }, + { + Value: "DAJ", + Name: i18n.NewString("Introduction allowance"), + }, + { + Value: "DAK", + Name: i18n.NewString("Multi-buy promotion"), + }, + { + Value: "DAL", + Name: i18n.NewString("Partnership"), + }, + { + Value: "DAM", + Name: i18n.NewString("Return handling"), + }, + { + Value: "DAN", + Name: i18n.NewString("Minimum order not fulfilled charge"), + }, + { + Value: "DAO", + Name: i18n.NewString("Point of sales threshold allowance"), + }, + { + Value: "DAP", + Name: i18n.NewString("Wholesaling discount"), + }, + { + Value: "DAQ", + Name: i18n.NewString("Documentary credits transfer commission"), + }, + { + Value: "DL", + Name: i18n.NewString("Delivery"), + }, + { + Value: "EG", + Name: i18n.NewString("Engraving"), + }, + { + Value: "EP", + Name: i18n.NewString("Expediting"), + }, + { + Value: "ER", + Name: i18n.NewString("Exchange rate guarantee"), + }, + { + Value: "FAA", + Name: i18n.NewString("Fabrication"), + }, + { + Value: "FAB", + Name: i18n.NewString("Freight equalization"), + }, + { + Value: "FAC", + Name: i18n.NewString("Freight extraordinary handling"), + }, + { + Value: "FC", + Name: i18n.NewString("Freight service"), + }, + { + Value: "FH", + Name: i18n.NewString("Filling/handling"), + }, + { + Value: "FI", + Name: i18n.NewString("Financing"), + }, + { + Value: "GAA", + Name: i18n.NewString("Grinding"), + }, + { + Value: "HAA", + Name: i18n.NewString("Hose"), + }, + { + Value: "HD", + Name: i18n.NewString("Handling"), + }, + { + Value: "HH", + Name: i18n.NewString("Hoisting and hauling"), + }, + { + Value: "IAA", + Name: i18n.NewString("Installation"), + }, + { + Value: "IAB", + Name: i18n.NewString("Installation and warranty"), + }, + { + Value: "ID", + Name: i18n.NewString("Inside delivery"), + }, + { + Value: "IF", + Name: i18n.NewString("Inspection"), + }, + { + Value: "IN", // not in EN16931 + Name: i18n.NewString("Insurance"), + }, + { + Value: "IR", + Name: i18n.NewString("Installation and training"), + }, + { + Value: "IS", + Name: i18n.NewString("Invoicing"), + }, + { + Value: "KO", + Name: i18n.NewString("Koshering"), + }, + { + Value: "L1", + Name: i18n.NewString("Carrier count"), + }, + { + Value: "LA", + Name: i18n.NewString("Labelling"), + }, + { + Value: "LAA", + Name: i18n.NewString("Labour"), + }, + { + Value: "LAB", + Name: i18n.NewString("Repair and return"), + }, + { + Value: "LF", + Name: i18n.NewString("Legalisation"), + }, + { + Value: "MAE", + Name: i18n.NewString("Mounting"), + }, + { + Value: "MI", + Name: i18n.NewString("Mail invoice"), + }, + { + Value: "ML", + Name: i18n.NewString("Mail invoice to each location"), + }, + { + Value: "NAA", + Name: i18n.NewString("Non-returnable containers"), + }, + { + Value: "OA", + Name: i18n.NewString("Outside cable connectors"), + }, + { + Value: "PA", + Name: i18n.NewString("Invoice with shipment"), + }, + { + Value: "PAA", + Name: i18n.NewString("Phosphatizing (steel treatment)"), + }, + { + Value: "PC", + Name: i18n.NewString("Packing"), + }, + { + Value: "PL", + Name: i18n.NewString("Palletizing"), + }, + { + Value: "PRV", + Name: i18n.NewString("Price variation"), + }, + { + Value: "RAB", + Name: i18n.NewString("Repacking"), + }, + { + Value: "RAC", + Name: i18n.NewString("Repair"), + }, + { + Value: "RAD", + Name: i18n.NewString("Returnable container"), + }, + { + Value: "RAF", + Name: i18n.NewString("Restocking"), + }, + { + Value: "RE", + Name: i18n.NewString("Re-delivery"), + }, + { + Value: "RF", + Name: i18n.NewString("Refurbishing"), + }, + { + Value: "RH", + Name: i18n.NewString("Rail wagon hire"), + }, + { + Value: "RV", + Name: i18n.NewString("Loading"), + }, + { + Value: "SA", + Name: i18n.NewString("Salvaging"), + }, + { + Value: "SAA", + Name: i18n.NewString("Shipping and handling"), + }, + { + Value: "SAD", + Name: i18n.NewString("Special packaging"), + }, + { + Value: "SAE", + Name: i18n.NewString("Stamping"), + }, + { + Value: "SAI", + Name: i18n.NewString("Consignee unload"), + }, + { + Value: "SG", + Name: i18n.NewString("Shrink-wrap"), + }, + { + Value: "SH", + Name: i18n.NewString("Special handling"), + }, + { + Value: "SM", + Name: i18n.NewString("Special finish"), + }, + { + Value: "ST", // not in EN16931 + Name: i18n.NewString("Stamp duties"), + }, + { + Value: "SU", + Name: i18n.NewString("Set-up"), + }, + { + Value: "TAB", + Name: i18n.NewString("Tank renting"), + }, + { + Value: "TAC", + Name: i18n.NewString("Testing"), + }, + { + Value: "TT", + Name: i18n.NewString("Transportation - third party billing"), + }, + { + Value: "TV", + Name: i18n.NewString("Transportation by vendor"), + }, + { + Value: "TX", // not in EN16931 + Name: i18n.NewString("Tax"), + }, + { + Value: "V1", + Name: i18n.NewString("Drop yard"), + }, + { + Value: "V2", + Name: i18n.NewString("Drop dock"), + }, + { + Value: "WH", + Name: i18n.NewString("Warehousing"), + }, + { + Value: "XAA", + Name: i18n.NewString("Combine all same day shipment"), + }, + { + Value: "YY", + Name: i18n.NewString("Split pick-up"), + }, + { + Value: "ZZZ", + Name: i18n.NewString("Mutually defined"), + }, + }, +} diff --git a/catalogues/untdid/document_type.go b/catalogues/untdid/document_type.go new file mode 100644 index 00000000..c09742d6 --- /dev/null +++ b/catalogues/untdid/document_type.go @@ -0,0 +1,246 @@ +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" +) + +var extDocumentTypes = &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"), + }, + }, +} diff --git a/catalogues/untdid/payment_means.go b/catalogues/untdid/payment_means.go new file mode 100644 index 00000000..76b5e2b8 --- /dev/null +++ b/catalogues/untdid/payment_means.go @@ -0,0 +1,364 @@ +package untdid + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeyPaymentMeans is used to identify the UNTDID 4461 payment means code. + ExtKeyPaymentMeans cbc.Key = "untdid-payment-means" +) + +var extPaymentMeans = &cbc.KeyDefinition{ + 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"), + }, + }, +} diff --git a/catalogues/untdid/tax_category.go b/catalogues/untdid/tax_category.go new file mode 100644 index 00000000..392d4885 --- /dev/null +++ b/catalogues/untdid/tax_category.go @@ -0,0 +1,156 @@ +package untdid + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeyTaxCategory is used to identify the UNTDID 5305 duty/tax/fee category code. + ExtKeyTaxCategory cbc.Key = "untdid-tax-category" +) + +var extTaxCategory = &cbc.KeyDefinition{ + 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..1f79c807 --- /dev/null +++ b/catalogues/untdid/untdid.go @@ -0,0 +1,26 @@ +// 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/cbc" + "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: []*cbc.KeyDefinition{ + extDocumentTypes, // 1001 + extPaymentMeans, // 4461 + extAllowance, // 5189 + extTaxCategory, // 5305 + extCharge, // 7161 + }, + } +} 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..8cb8aebd 100644 --- a/cbc/code.go +++ b/cbc/code.go @@ -20,9 +20,9 @@ const ( // be more easily set and used by humans within definitions than IDs or UUIDs. // Codes are standardised so that when validated they must contain between // 1 and 32 inclusive english alphabet letters or numbers with optional -// periods (`.`), dashes (`-`), underscores (`_`), forward slashes (`/`), or -// spaces (` `) to separate blocks. Each block must only be separated by a -// single symbol. +// periods (`.`), dashes (`-`), underscores (`_`), forward slashes (`/`), +// colons (`:`) or spaces (` `) to separate blocks. +// Each block must only be separated by a single symbol. // // The objective is to have a code that is easy to read and understand, while // still being unique and easy to validate. @@ -34,15 +34,17 @@ type CodeMap map[Key]Code // Basic code constants. var ( - CodePattern = `^[A-Za-z0-9]+([\.\-\/ _]?[A-Za-z0-9]+)*$` + CodePattern = `^[A-Za-z0-9]+([\.\-\/ _\:]?[A-Za-z0-9]+)*$` CodePatternRegexp = regexp.MustCompile(CodePattern) CodeMinLength uint64 = 1 CodeMaxLength uint64 = 32 ) 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..ab4100c9 100644 --- a/cbc/code_test.go +++ b/cbc/code_test.go @@ -103,13 +103,145 @@ func TestNormalizeCode(t *testing.T) { code: cbc.Code("FOO BAR--DOME"), want: cbc.Code("FOO BAR-DOME"), }, + { + name: "colons", + code: cbc.Code("0088:1234567891234"), // peppol example + want: cbc.Code("0088:1234567891234"), + }, } for _, tt := range tests { t.Run(tt.name, func(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) { @@ -154,6 +286,10 @@ func TestCode_Validate(t *testing.T) { name: "valid with space", code: cbc.Code("FR 12/BX"), }, + { + name: "valid with colon", + code: cbc.Code("FR:12/BX"), + }, { name: "empty", code: cbc.Code(""), 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/cmd/gobl/testdata/Test_build_do_not_envelop b/cmd/gobl/testdata/Test_build_do_not_envelop index f7aa9196..ac4967b9 100644 --- a/cmd/gobl/testdata/Test_build_do_not_envelop +++ b/cmd/gobl/testdata/Test_build_do_not_envelop @@ -133,13 +133,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -195,7 +188,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } } diff --git a/cmd/gobl/testdata/Test_build_envelop b/cmd/gobl/testdata/Test_build_envelop index 568ced8e..dd121548 100644 --- a/cmd/gobl/testdata/Test_build_envelop +++ b/cmd/gobl/testdata/Test_build_envelop @@ -4,7 +4,7 @@ "uuid": "00000000-0000-0000-0000-000000000000", "dig": { "alg": "sha256", - "val": "38fd37f6c05f2d0fe2f52e1c4bf8bb7f6c6fe289e03e07f9e0948d54794efd3d" + "val": "bb73b39a60e535d5cc02e5b4661b8e8029731e4613aab98389c87f6b9681c699" } }, "doc": { @@ -142,13 +142,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -204,7 +197,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } } diff --git a/cmd/gobl/testdata/Test_build_explicit_stdout b/cmd/gobl/testdata/Test_build_explicit_stdout index 564a2bfe..157f1a98 100644 --- a/cmd/gobl/testdata/Test_build_explicit_stdout +++ b/cmd/gobl/testdata/Test_build_explicit_stdout @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_build_input_file b/cmd/gobl/testdata/Test_build_input_file index 564a2bfe..157f1a98 100644 --- a/cmd/gobl/testdata/Test_build_input_file +++ b/cmd/gobl/testdata/Test_build_input_file @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_build_merge_values b/cmd/gobl/testdata/Test_build_merge_values index 89068d91..dd9ec1a7 100644 --- a/cmd/gobl/testdata/Test_build_merge_values +++ b/cmd/gobl/testdata/Test_build_merge_values @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_build_output_file_outfile b/cmd/gobl/testdata/Test_build_output_file_outfile index 564a2bfe..157f1a98 100644 --- a/cmd/gobl/testdata/Test_build_output_file_outfile +++ b/cmd/gobl/testdata/Test_build_output_file_outfile @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_build_overwrite_input_file_outfile b/cmd/gobl/testdata/Test_build_overwrite_input_file_outfile index 9a177a46..18aaffe1 100644 --- a/cmd/gobl/testdata/Test_build_overwrite_input_file_outfile +++ b/cmd/gobl/testdata/Test_build_overwrite_input_file_outfile @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "38fd37f6c05f2d0fe2f52e1c4bf8bb7f6c6fe289e03e07f9e0948d54794efd3d" + "val": "bb73b39a60e535d5cc02e5b4661b8e8029731e4613aab98389c87f6b9681c699" } }, "doc": { @@ -142,13 +142,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -204,7 +197,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } } diff --git a/cmd/gobl/testdata/Test_build_overwrite_output_file_outfile b/cmd/gobl/testdata/Test_build_overwrite_output_file_outfile index 564a2bfe..157f1a98 100644 --- a/cmd/gobl/testdata/Test_build_overwrite_output_file_outfile +++ b/cmd/gobl/testdata/Test_build_overwrite_output_file_outfile @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_build_recalculate b/cmd/gobl/testdata/Test_build_recalculate index a266975a..0367f2cb 100644 --- a/cmd/gobl/testdata/Test_build_recalculate +++ b/cmd/gobl/testdata/Test_build_recalculate @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "e47164849e23ea243e5a76d9503e861d6af4a715d9211f70b7606feaf6f5de40" + "val": "7ea57c3e785626d1c68b32a83d47299a70794799b5bb10be96c942171e09873c" } }, "doc": { @@ -139,13 +139,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -199,7 +192,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } } diff --git a/cmd/gobl/testdata/Test_build_success b/cmd/gobl/testdata/Test_build_success index a266975a..0367f2cb 100644 --- a/cmd/gobl/testdata/Test_build_success +++ b/cmd/gobl/testdata/Test_build_success @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "e47164849e23ea243e5a76d9503e861d6af4a715d9211f70b7606feaf6f5de40" + "val": "7ea57c3e785626d1c68b32a83d47299a70794799b5bb10be96c942171e09873c" } }, "doc": { @@ -139,13 +139,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -199,7 +192,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } } diff --git a/cmd/gobl/testdata/Test_build_valid_file b/cmd/gobl/testdata/Test_build_valid_file index 12e5b331..c21e65a8 100644 --- a/cmd/gobl/testdata/Test_build_valid_file +++ b/cmd/gobl/testdata/Test_build_valid_file @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "cd45a3373b70f8cbf1247dc25698baba8462dd6c47781e66b790490f129a87ce" + "val": "68715e28422644b7d2ce4fc0f88fd47febcb581cfc8d3f1e8980a849e3235cfc" } }, "doc": { @@ -139,13 +139,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -199,7 +192,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } } diff --git a/cmd/gobl/testdata/Test_sign_explicit_stdout b/cmd/gobl/testdata/Test_sign_explicit_stdout index cdc9d1d2..c7370d63 100644 --- a/cmd/gobl/testdata/Test_sign_explicit_stdout +++ b/cmd/gobl/testdata/Test_sign_explicit_stdout @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_sign_input_file b/cmd/gobl/testdata/Test_sign_input_file index cdc9d1d2..c7370d63 100644 --- a/cmd/gobl/testdata/Test_sign_input_file +++ b/cmd/gobl/testdata/Test_sign_input_file @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_sign_merge_values b/cmd/gobl/testdata/Test_sign_merge_values index ec97d3cf..abf20708 100644 --- a/cmd/gobl/testdata/Test_sign_merge_values +++ b/cmd/gobl/testdata/Test_sign_merge_values @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_sign_output_file_outfile b/cmd/gobl/testdata/Test_sign_output_file_outfile index cdc9d1d2..c7370d63 100644 --- a/cmd/gobl/testdata/Test_sign_output_file_outfile +++ b/cmd/gobl/testdata/Test_sign_output_file_outfile @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_sign_overwrite_input_file_outfile b/cmd/gobl/testdata/Test_sign_overwrite_input_file_outfile index b2cd9564..386f80a7 100644 --- a/cmd/gobl/testdata/Test_sign_overwrite_input_file_outfile +++ b/cmd/gobl/testdata/Test_sign_overwrite_input_file_outfile @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "38fd37f6c05f2d0fe2f52e1c4bf8bb7f6c6fe289e03e07f9e0948d54794efd3d" + "val": "bb73b39a60e535d5cc02e5b4661b8e8029731e4613aab98389c87f6b9681c699" } }, "doc": { @@ -142,13 +142,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -204,7 +197,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } }, diff --git a/cmd/gobl/testdata/Test_sign_overwrite_output_file_outfile b/cmd/gobl/testdata/Test_sign_overwrite_output_file_outfile index cdc9d1d2..c7370d63 100644 --- a/cmd/gobl/testdata/Test_sign_overwrite_output_file_outfile +++ b/cmd/gobl/testdata/Test_sign_overwrite_output_file_outfile @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/cmd/gobl/testdata/Test_sign_recalculate b/cmd/gobl/testdata/Test_sign_recalculate index 7f532d19..876d2078 100644 --- a/cmd/gobl/testdata/Test_sign_recalculate +++ b/cmd/gobl/testdata/Test_sign_recalculate @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "e47164849e23ea243e5a76d9503e861d6af4a715d9211f70b7606feaf6f5de40" + "val": "7ea57c3e785626d1c68b32a83d47299a70794799b5bb10be96c942171e09873c" } }, "doc": { @@ -139,13 +139,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -199,7 +192,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } }, diff --git a/cmd/gobl/testdata/Test_sign_success b/cmd/gobl/testdata/Test_sign_success index 7f532d19..876d2078 100644 --- a/cmd/gobl/testdata/Test_sign_success +++ b/cmd/gobl/testdata/Test_sign_success @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "e47164849e23ea243e5a76d9503e861d6af4a715d9211f70b7606feaf6f5de40" + "val": "7ea57c3e785626d1c68b32a83d47299a70794799b5bb10be96c942171e09873c" } }, "doc": { @@ -139,13 +139,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -199,7 +192,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } }, diff --git a/cmd/gobl/testdata/Test_sign_valid_file b/cmd/gobl/testdata/Test_sign_valid_file index bdd090ba..3025131f 100644 --- a/cmd/gobl/testdata/Test_sign_valid_file +++ b/cmd/gobl/testdata/Test_sign_valid_file @@ -4,7 +4,7 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "cd45a3373b70f8cbf1247dc25698baba8462dd6c47781e66b790490f129a87ce" + "val": "68715e28422644b7d2ce4fc0f88fd47febcb581cfc8d3f1e8980a849e3235cfc" } }, "doc": { @@ -139,13 +139,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -199,7 +192,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } }, 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/addons/eu-en16931-v2017.json b/data/addons/eu-en16931-v2017.json new file mode 100644 index 00000000..6a13eeb5 --- /dev/null +++ b/data/addons/eu-en16931-v2017.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/addon-def", + "key": "eu-en16931-v2017", + "name": { + "en": "EN 16931-1:2017" + }, + "description": { + "en": "Support for the European Norm (EN) 16931-1:2017 standard for electronic invoicing.\n\nThis addon ensures the basic rules and mappings are applied to the GOBL document\nensure that it is compliant and easily convertible to other formats." + }, + "extensions": null, + "scenarios": [ + { + "schema": "bill/invoice", + "list": [ + { + "type": [ + "standard" + ], + "ext": { + "untdid-document-type": "380" + } + }, + { + "type": [ + "credit-note" + ], + "ext": { + "untdid-document-type": "381" + } + }, + { + "type": [ + "debit-note" + ], + "ext": { + "untdid-document-type": "383" + } + }, + { + "type": [ + "corrective" + ], + "ext": { + "untdid-document-type": "384" + } + }, + { + "type": [ + "proforma" + ], + "ext": { + "untdid-document-type": "325" + } + }, + { + "type": [ + "standard" + ], + "tags": [ + "partial" + ], + "ext": { + "untdid-document-type": "326" + } + }, + { + "type": [ + "standard" + ], + "tags": [ + "self-billed" + ], + "ext": { + "untdid-document-type": "389" + } + } + ] + } + ], + "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..677a46c7 --- /dev/null +++ b/data/catalogues/untdid.json @@ -0,0 +1,2225 @@ +{ + "$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-allowance", + "name": { + "en": "UNTDID 5189 Allowance" + }, + "desc": { + "en": "UNTDID 5189 code used to describe the allowance type. 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": "41", + "name": { + "en": "Bonus for works ahead of schedule" + } + }, + { + "value": "42", + "name": { + "en": "Other bonus" + } + }, + { + "value": "60", + "name": { + "en": "Manufacturer’s consumer discount" + } + }, + { + "value": "62", + "name": { + "en": "Due to military status" + } + }, + { + "value": "63", + "name": { + "en": "Due to work accident" + } + }, + { + "value": "64", + "name": { + "en": "Special agreement" + } + }, + { + "value": "65", + "name": { + "en": "Production error discount" + } + }, + { + "value": "66", + "name": { + "en": "New outlet discount" + } + }, + { + "value": "67", + "name": { + "en": "Sample discount" + } + }, + { + "value": "68", + "name": { + "en": "End-of-range discount" + } + }, + { + "value": "70", + "name": { + "en": "Incoterm discount" + } + }, + { + "value": "71", + "name": { + "en": "Point of sales threshold allowance" + } + }, + { + "value": "88", + "name": { + "en": "Material surcharge/deduction" + } + }, + { + "value": "95", + "name": { + "en": "Discount" + } + }, + { + "value": "100", + "name": { + "en": "Special rebate" + } + }, + { + "value": "102", + "name": { + "en": "Fixed long term" + } + }, + { + "value": "103", + "name": { + "en": "Temporary" + } + }, + { + "value": "104", + "name": { + "en": "Standard" + } + }, + { + "value": "105", + "name": { + "en": "Yearly turnover" + } + } + ] + }, + { + "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" + } + } + ] + }, + { + "key": "untdid-charge", + "name": { + "en": "UNTDID 7161 Charge" + }, + "desc": { + "en": "UNTDID 7161 code used to describe the charge. List is based on the\nEN16931 code lists with extensions for taxes and duties." + }, + "values": [ + { + "value": "AA", + "name": { + "en": "Advertising" + } + }, + { + "value": "AAA", + "name": { + "en": "Telecommunication" + } + }, + { + "value": "AAC", + "name": { + "en": "Technical modification" + } + }, + { + "value": "AAD", + "name": { + "en": "Job-order production" + } + }, + { + "value": "AAE", + "name": { + "en": "Outlays" + } + }, + { + "value": "AAF", + "name": { + "en": "Off-premises" + } + }, + { + "value": "AAH", + "name": { + "en": "Additional processing" + } + }, + { + "value": "AAI", + "name": { + "en": "Attesting" + } + }, + { + "value": "AAS", + "name": { + "en": "Acceptance" + } + }, + { + "value": "AAT", + "name": { + "en": "Rush delivery" + } + }, + { + "value": "AAV", + "name": { + "en": "Special construction" + } + }, + { + "value": "AAY", + "name": { + "en": "Airport facilities" + } + }, + { + "value": "AAZ", + "name": { + "en": "Concession" + } + }, + { + "value": "ABA", + "name": { + "en": "Compulsory storage" + } + }, + { + "value": "ABB", + "name": { + "en": "Fuel removal" + } + }, + { + "value": "ABC", + "name": { + "en": "Into plane" + } + }, + { + "value": "ABD", + "name": { + "en": "Overtime" + } + }, + { + "value": "ABF", + "name": { + "en": "Tooling" + } + }, + { + "value": "ABK", + "name": { + "en": "Miscellaneous" + } + }, + { + "value": "ABL", + "name": { + "en": "Additional packaging" + } + }, + { + "value": "ABN", + "name": { + "en": "Dunnage" + } + }, + { + "value": "ABR", + "name": { + "en": "Containerisation" + } + }, + { + "value": "ABS", + "name": { + "en": "Carton packing" + } + }, + { + "value": "ABT", + "name": { + "en": "Hessian wrapped" + } + }, + { + "value": "ABU", + "name": { + "en": "Polyethylene wrap packing" + } + }, + { + "value": "ABW", + "name": { + "en": "Customs duty charge" + } + }, + { + "value": "ACF", + "name": { + "en": "Miscellaneous treatment" + } + }, + { + "value": "ACG", + "name": { + "en": "Enamelling treatment" + } + }, + { + "value": "ACH", + "name": { + "en": "Heat treatment" + } + }, + { + "value": "ACI", + "name": { + "en": "Plating treatment" + } + }, + { + "value": "ACJ", + "name": { + "en": "Painting" + } + }, + { + "value": "ACK", + "name": { + "en": "Polishing" + } + }, + { + "value": "ACL", + "name": { + "en": "Priming" + } + }, + { + "value": "ACM", + "name": { + "en": "Preservation treatment" + } + }, + { + "value": "ACS", + "name": { + "en": "Fitting" + } + }, + { + "value": "ADC", + "name": { + "en": "Consolidation" + } + }, + { + "value": "ADE", + "name": { + "en": "Bill of lading" + } + }, + { + "value": "ADJ", + "name": { + "en": "Airbag" + } + }, + { + "value": "ADK", + "name": { + "en": "Transfer" + } + }, + { + "value": "ADL", + "name": { + "en": "Slipsheet" + } + }, + { + "value": "ADM", + "name": { + "en": "Binding" + } + }, + { + "value": "ADN", + "name": { + "en": "Repair or replacement of broken returnable package" + } + }, + { + "value": "ADO", + "name": { + "en": "Efficient logistics" + } + }, + { + "value": "ADP", + "name": { + "en": "Merchandising" + } + }, + { + "value": "ADQ", + "name": { + "en": "Product mix" + } + }, + { + "value": "ADR", + "name": { + "en": "Other services" + } + }, + { + "value": "ADT", + "name": { + "en": "Pick-up" + } + }, + { + "value": "ADW", + "name": { + "en": "Chronic illness" + } + }, + { + "value": "ADY", + "name": { + "en": "New product introduction" + } + }, + { + "value": "ADZ", + "name": { + "en": "Direct delivery" + } + }, + { + "value": "AEA", + "name": { + "en": "Diversion" + } + }, + { + "value": "AEB", + "name": { + "en": "Disconnect" + } + }, + { + "value": "AEC", + "name": { + "en": "Distribution" + } + }, + { + "value": "AED", + "name": { + "en": "Handling of hazardous cargo" + } + }, + { + "value": "AEF", + "name": { + "en": "Rents and leases" + } + }, + { + "value": "AEH", + "name": { + "en": "Location differential" + } + }, + { + "value": "AEI", + "name": { + "en": "Aircraft refueling" + } + }, + { + "value": "AEJ", + "name": { + "en": "Fuel shipped into storage" + } + }, + { + "value": "AEK", + "name": { + "en": "Cash on delivery" + } + }, + { + "value": "AEL", + "name": { + "en": "Small order processing service" + } + }, + { + "value": "AEM", + "name": { + "en": "Clerical or administrative services" + } + }, + { + "value": "AEN", + "name": { + "en": "Guarantee" + } + }, + { + "value": "AEO", + "name": { + "en": "Collection and recycling" + } + }, + { + "value": "AEP", + "name": { + "en": "Copyright fee collection" + } + }, + { + "value": "AES", + "name": { + "en": "Veterinary inspection service" + } + }, + { + "value": "AET", + "name": { + "en": "Pensioner service" + } + }, + { + "value": "AEU", + "name": { + "en": "Medicine free pass holder" + } + }, + { + "value": "AEV", + "name": { + "en": "Environmental protection service" + } + }, + { + "value": "AEW", + "name": { + "en": "Environmental clean-up service" + } + }, + { + "value": "AEX", + "name": { + "en": "National cheque processing service outside account area" + } + }, + { + "value": "AEY", + "name": { + "en": "National payment service outside account area" + } + }, + { + "value": "AEZ", + "name": { + "en": "National payment service within account area" + } + }, + { + "value": "AJ", + "name": { + "en": "Adjustments" + } + }, + { + "value": "AU", + "name": { + "en": "Authentication" + } + }, + { + "value": "CA", + "name": { + "en": "Cataloguing" + } + }, + { + "value": "CAB", + "name": { + "en": "Cartage" + } + }, + { + "value": "CAD", + "name": { + "en": "Certification" + } + }, + { + "value": "CAE", + "name": { + "en": "Certificate of conformance" + } + }, + { + "value": "CAF", + "name": { + "en": "Certificate of origin" + } + }, + { + "value": "CAI", + "name": { + "en": "Cutting" + } + }, + { + "value": "CAJ", + "name": { + "en": "Consular service" + } + }, + { + "value": "CAK", + "name": { + "en": "Customer collection" + } + }, + { + "value": "CAL", + "name": { + "en": "Payroll payment service" + } + }, + { + "value": "CAM", + "name": { + "en": "Cash transportation" + } + }, + { + "value": "CAN", + "name": { + "en": "Home banking service" + } + }, + { + "value": "CAO", + "name": { + "en": "Bilateral agreement service" + } + }, + { + "value": "CAP", + "name": { + "en": "Insurance brokerage service" + } + }, + { + "value": "CAQ", + "name": { + "en": "Cheque generation" + } + }, + { + "value": "CAR", + "name": { + "en": "Preferential merchandising location" + } + }, + { + "value": "CAS", + "name": { + "en": "Crane" + } + }, + { + "value": "CAT", + "name": { + "en": "Special colour service" + } + }, + { + "value": "CAU", + "name": { + "en": "Sorting" + } + }, + { + "value": "CAV", + "name": { + "en": "Battery collection and recycling" + } + }, + { + "value": "CAW", + "name": { + "en": "Product take back fee" + } + }, + { + "value": "CAX", + "name": { + "en": "Quality control released" + } + }, + { + "value": "CAY", + "name": { + "en": "Quality control held" + } + }, + { + "value": "CAZ", + "name": { + "en": "Quality control embargo" + } + }, + { + "value": "CD", + "name": { + "en": "Car loading" + } + }, + { + "value": "CG", + "name": { + "en": "Cleaning" + } + }, + { + "value": "CS", + "name": { + "en": "Cigarette stamping" + } + }, + { + "value": "CT", + "name": { + "en": "Count and recount" + } + }, + { + "value": "DAB", + "name": { + "en": "Layout/design" + } + }, + { + "value": "DAC", + "name": { + "en": "Assortment allowance" + } + }, + { + "value": "DAD", + "name": { + "en": "Driver assigned unloading" + } + }, + { + "value": "DAF", + "name": { + "en": "Debtor bound" + } + }, + { + "value": "DAG", + "name": { + "en": "Dealer allowance" + } + }, + { + "value": "DAH", + "name": { + "en": "Allowance transferable to the consumer" + } + }, + { + "value": "DAI", + "name": { + "en": "Growth of business" + } + }, + { + "value": "DAJ", + "name": { + "en": "Introduction allowance" + } + }, + { + "value": "DAK", + "name": { + "en": "Multi-buy promotion" + } + }, + { + "value": "DAL", + "name": { + "en": "Partnership" + } + }, + { + "value": "DAM", + "name": { + "en": "Return handling" + } + }, + { + "value": "DAN", + "name": { + "en": "Minimum order not fulfilled charge" + } + }, + { + "value": "DAO", + "name": { + "en": "Point of sales threshold allowance" + } + }, + { + "value": "DAP", + "name": { + "en": "Wholesaling discount" + } + }, + { + "value": "DAQ", + "name": { + "en": "Documentary credits transfer commission" + } + }, + { + "value": "DL", + "name": { + "en": "Delivery" + } + }, + { + "value": "EG", + "name": { + "en": "Engraving" + } + }, + { + "value": "EP", + "name": { + "en": "Expediting" + } + }, + { + "value": "ER", + "name": { + "en": "Exchange rate guarantee" + } + }, + { + "value": "FAA", + "name": { + "en": "Fabrication" + } + }, + { + "value": "FAB", + "name": { + "en": "Freight equalization" + } + }, + { + "value": "FAC", + "name": { + "en": "Freight extraordinary handling" + } + }, + { + "value": "FC", + "name": { + "en": "Freight service" + } + }, + { + "value": "FH", + "name": { + "en": "Filling/handling" + } + }, + { + "value": "FI", + "name": { + "en": "Financing" + } + }, + { + "value": "GAA", + "name": { + "en": "Grinding" + } + }, + { + "value": "HAA", + "name": { + "en": "Hose" + } + }, + { + "value": "HD", + "name": { + "en": "Handling" + } + }, + { + "value": "HH", + "name": { + "en": "Hoisting and hauling" + } + }, + { + "value": "IAA", + "name": { + "en": "Installation" + } + }, + { + "value": "IAB", + "name": { + "en": "Installation and warranty" + } + }, + { + "value": "ID", + "name": { + "en": "Inside delivery" + } + }, + { + "value": "IF", + "name": { + "en": "Inspection" + } + }, + { + "value": "IN", + "name": { + "en": "Insurance" + } + }, + { + "value": "IR", + "name": { + "en": "Installation and training" + } + }, + { + "value": "IS", + "name": { + "en": "Invoicing" + } + }, + { + "value": "KO", + "name": { + "en": "Koshering" + } + }, + { + "value": "L1", + "name": { + "en": "Carrier count" + } + }, + { + "value": "LA", + "name": { + "en": "Labelling" + } + }, + { + "value": "LAA", + "name": { + "en": "Labour" + } + }, + { + "value": "LAB", + "name": { + "en": "Repair and return" + } + }, + { + "value": "LF", + "name": { + "en": "Legalisation" + } + }, + { + "value": "MAE", + "name": { + "en": "Mounting" + } + }, + { + "value": "MI", + "name": { + "en": "Mail invoice" + } + }, + { + "value": "ML", + "name": { + "en": "Mail invoice to each location" + } + }, + { + "value": "NAA", + "name": { + "en": "Non-returnable containers" + } + }, + { + "value": "OA", + "name": { + "en": "Outside cable connectors" + } + }, + { + "value": "PA", + "name": { + "en": "Invoice with shipment" + } + }, + { + "value": "PAA", + "name": { + "en": "Phosphatizing (steel treatment)" + } + }, + { + "value": "PC", + "name": { + "en": "Packing" + } + }, + { + "value": "PL", + "name": { + "en": "Palletizing" + } + }, + { + "value": "PRV", + "name": { + "en": "Price variation" + } + }, + { + "value": "RAB", + "name": { + "en": "Repacking" + } + }, + { + "value": "RAC", + "name": { + "en": "Repair" + } + }, + { + "value": "RAD", + "name": { + "en": "Returnable container" + } + }, + { + "value": "RAF", + "name": { + "en": "Restocking" + } + }, + { + "value": "RE", + "name": { + "en": "Re-delivery" + } + }, + { + "value": "RF", + "name": { + "en": "Refurbishing" + } + }, + { + "value": "RH", + "name": { + "en": "Rail wagon hire" + } + }, + { + "value": "RV", + "name": { + "en": "Loading" + } + }, + { + "value": "SA", + "name": { + "en": "Salvaging" + } + }, + { + "value": "SAA", + "name": { + "en": "Shipping and handling" + } + }, + { + "value": "SAD", + "name": { + "en": "Special packaging" + } + }, + { + "value": "SAE", + "name": { + "en": "Stamping" + } + }, + { + "value": "SAI", + "name": { + "en": "Consignee unload" + } + }, + { + "value": "SG", + "name": { + "en": "Shrink-wrap" + } + }, + { + "value": "SH", + "name": { + "en": "Special handling" + } + }, + { + "value": "SM", + "name": { + "en": "Special finish" + } + }, + { + "value": "ST", + "name": { + "en": "Stamp duties" + } + }, + { + "value": "SU", + "name": { + "en": "Set-up" + } + }, + { + "value": "TAB", + "name": { + "en": "Tank renting" + } + }, + { + "value": "TAC", + "name": { + "en": "Testing" + } + }, + { + "value": "TT", + "name": { + "en": "Transportation - third party billing" + } + }, + { + "value": "TV", + "name": { + "en": "Transportation by vendor" + } + }, + { + "value": "TX", + "name": { + "en": "Tax" + } + }, + { + "value": "V1", + "name": { + "en": "Drop yard" + } + }, + { + "value": "V2", + "name": { + "en": "Drop dock" + } + }, + { + "value": "WH", + "name": { + "en": "Warehousing" + } + }, + { + "value": "XAA", + "name": { + "en": "Combine all same day shipment" + } + }, + { + "value": "YY", + "name": { + "en": "Split pick-up" + } + }, + { + "value": "ZZZ", + "name": { + "en": "Mutually defined" + } + } + ] + } + ] +} \ 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/regimes/it.json b/data/regimes/it.json index a12c65ec..ded4f53d 100644 --- a/data/regimes/it.json +++ b/data/regimes/it.json @@ -74,19 +74,6 @@ } } ], - "charge_keys": [ - { - "key": "stamp-duty", - "name": { - "en": "Stamp Duty", - "it": "Imposta di bollo" - }, - "desc": { - "en": "A fixed-price tax applied to the production, request or presentation of certain documents: civil, commercial, judicial and extrajudicial documents, on notices, on posters.", - "it": "Un'imposta applicata alla produzione, richiesta o presentazione di determinati documenti: atti civili, commerciali, giudiziali ed extragiudiziali, sugli avvisi, sui manifesti." - } - } - ], "scenarios": [ { "schema": "bill/invoice", diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index 3aafad4c..54023948 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -11,21 +11,76 @@ "title": "UUID", "description": "Universally Unique Identifier." }, - "key": { - "$ref": "https://gobl.org/draft-0/cbc/key", - "title": "Key", - "description": "Key for grouping or identifying charges for tax purposes." - }, "i": { "type": "integer", "title": "Index", "description": "Line number inside the list of charges (calculated).", "calculated": true }, - "ref": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "anyOf": [ + { + "const": "stamp-duty", + "title": "Stamp Duty" + }, + { + "const": "outlay", + "title": "Outlay" + }, + { + "const": "tax", + "title": "Tax" + }, + { + "const": "customs", + "title": "Customs" + }, + { + "const": "delivery", + "title": "Delivery" + }, + { + "const": "packing", + "title": "Packing" + }, + { + "const": "handling", + "title": "Handling" + }, + { + "const": "insurance", + "title": "Insurance" + }, + { + "const": "storage", + "title": "Storage" + }, + { + "const": "admin", + "title": "Administration" + }, + { + "const": "cleaning", + "title": "Cleaning" + }, + { + "pattern": "^(?:[a-z]|[a-z0-9][a-z0-9-+]*[a-z0-9])$", + "title": "Other" + } + ], + "title": "Key", + "description": "Key for grouping or identifying charges for tax purposes. A suggested list of\nkeys is provided, but these may be extended by the issuer." + }, + "code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Code", + "description": "Code to used to refer to the this charge by the issuer" + }, + "reason": { "type": "string", - "title": "Reference", - "description": "Code to used to refer to the this charge" + "title": "Reason", + "description": "Text description as to why the charge was applied" }, "base": { "$ref": "https://gobl.org/draft-0/num/amount", @@ -35,7 +90,7 @@ "percent": { "$ref": "https://gobl.org/draft-0/num/percentage", "title": "Percent", - "description": "Percentage to apply to the Base or Invoice Sum" + "description": "Percentage to apply to the sum of all lines" }, "amount": { "$ref": "https://gobl.org/draft-0/num/amount", @@ -48,15 +103,10 @@ "title": "Taxes", "description": "List of taxes to apply to the charge" }, - "code": { - "type": "string", - "title": "Reason Code", - "description": "Code for why was this charge applied?" - }, - "reason": { - "type": "string", - "title": "Reason", - "description": "Text description as to why the charge was applied" + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "title": "Extensions", + "description": "Extension codes that apply to the charge" }, "meta": { "$ref": "https://gobl.org/draft-0/cbc/meta", @@ -119,10 +169,82 @@ "description": "Line number inside the list of discounts (calculated)", "calculated": true }, - "ref": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "anyOf": [ + { + "const": "early-completion", + "title": "Bonus for works ahead of schedule" + }, + { + "const": "military", + "title": "Military Discount" + }, + { + "const": "work-accident", + "title": "Work Accident Discount" + }, + { + "const": "special-agreement", + "title": "Special Agreement Discount" + }, + { + "const": "production-error", + "title": "Production Error Discount" + }, + { + "const": "new-outlet", + "title": "New Outlet Discount" + }, + { + "const": "sample", + "title": "Sample Discount" + }, + { + "const": "end-of-range", + "title": "End of Range Discount" + }, + { + "const": "incoterm", + "title": "Incoterm Discount" + }, + { + "const": "pos-threshold", + "title": "Point of Sale Threshold Discount" + }, + { + "const": "special-rebate", + "title": "Special Rebate" + }, + { + "const": "temporary", + "title": "Temporary" + }, + { + "const": "standard", + "title": "Standard" + }, + { + "const": "yearly-turnover", + "title": "Yearly Turnover" + }, + { + "pattern": "^(?:[a-z]|[a-z0-9][a-z0-9-+]*[a-z0-9])$", + "title": "Other" + } + ], + "title": "Key", + "description": "Key for identifying the type of discount being applied." + }, + "code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Code", + "description": "Code to used to refer to the this discount by the issuer" + }, + "reason": { "type": "string", - "title": "Reference", - "description": "Reference or ID for this Discount" + "title": "Reason", + "description": "Text description as to why the discount was applied" }, "base": { "$ref": "https://gobl.org/draft-0/num/amount", @@ -145,15 +267,10 @@ "title": "Taxes", "description": "List of taxes to apply to the discount" }, - "code": { - "type": "string", - "title": "Reason Code", - "description": "Code for the reason this discount applied" - }, - "reason": { - "type": "string", - "title": "Reason", - "description": "Text description as to why the discount was applied" + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "title": "Extensions", + "description": "Extension codes that apply to the discount" }, "meta": { "$ref": "https://gobl.org/draft-0/cbc/meta", @@ -256,6 +373,10 @@ "const": "co-dian-v2", "title": "Colombia DIAN UBL 2.X" }, + { + "const": "de-xrechnung-v3", + "title": "German XRechnung 3.X" + }, { "const": "es-facturae-v3", "title": "Spain FacturaE" @@ -264,6 +385,10 @@ "const": "es-tbai-v1", "title": "Spain TicketBAI" }, + { + "const": "eu-en16931-v2017", + "title": "EN 16931-1:2017" + }, { "const": "gr-mydata-v1", "title": "Greece MyData v1.x" @@ -394,7 +519,7 @@ "supplier": { "$ref": "https://gobl.org/draft-0/org/party", "title": "Supplier", - "description": "The taxable entity supplying the goods or services." + "description": "The entity supplying the goods or services and usually responsible for paying taxes." }, "customer": { "$ref": "https://gobl.org/draft-0/org/party", @@ -425,14 +550,6 @@ "title": "Charges", "description": "Charges or surcharges applied to the complete invoice" }, - "outlays": { - "items": { - "$ref": "#/$defs/Outlay" - }, - "type": "array", - "title": "Outlays", - "description": "Expenses paid for by the supplier but invoiced directly to the customer." - }, "ordering": { "$ref": "#/$defs/Ordering", "title": "Ordering Details", @@ -569,6 +686,71 @@ }, "LineCharge": { "properties": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "anyOf": [ + { + "const": "stamp-duty", + "title": "Stamp Duty" + }, + { + "const": "outlay", + "title": "Outlay" + }, + { + "const": "tax", + "title": "Tax" + }, + { + "const": "customs", + "title": "Customs" + }, + { + "const": "delivery", + "title": "Delivery" + }, + { + "const": "packing", + "title": "Packing" + }, + { + "const": "handling", + "title": "Handling" + }, + { + "const": "insurance", + "title": "Insurance" + }, + { + "const": "storage", + "title": "Storage" + }, + { + "const": "admin", + "title": "Administration" + }, + { + "const": "cleaning", + "title": "Cleaning" + }, + { + "pattern": "^(?:[a-z]|[a-z0-9][a-z0-9-+]*[a-z0-9])$", + "title": "Other" + } + ], + "title": "Key", + "description": "Key for grouping or identifying charges for tax purposes. A suggested list of\nkeys is provided, but these are for reference only and may be extended by\nthe issuer." + }, + "code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Code", + "description": "Reference or ID for this charge defined by the issuer" + }, + "reason": { + "type": "string", + "title": "Reason", + "description": "Text description as to why the charge was applied" + }, "percent": { "$ref": "https://gobl.org/draft-0/num/percentage", "title": "Percent", @@ -580,15 +762,10 @@ "description": "Fixed or resulting charge amount to apply (calculated if percent present).", "calculated": true }, - "code": { - "type": "string", - "title": "Code", - "description": "Reference code." - }, - "reason": { - "type": "string", - "title": "Reason", - "description": "Text description as to why the charge was applied" + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "title": "Extensions", + "description": "Extension codes that apply to the charge" } }, "type": "object", @@ -599,26 +776,98 @@ }, "LineDiscount": { "properties": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "anyOf": [ + { + "const": "early-completion", + "title": "Bonus for works ahead of schedule" + }, + { + "const": "military", + "title": "Military Discount" + }, + { + "const": "work-accident", + "title": "Work Accident Discount" + }, + { + "const": "special-agreement", + "title": "Special Agreement Discount" + }, + { + "const": "production-error", + "title": "Production Error Discount" + }, + { + "const": "new-outlet", + "title": "New Outlet Discount" + }, + { + "const": "sample", + "title": "Sample Discount" + }, + { + "const": "end-of-range", + "title": "End of Range Discount" + }, + { + "const": "incoterm", + "title": "Incoterm Discount" + }, + { + "const": "pos-threshold", + "title": "Point of Sale Threshold Discount" + }, + { + "const": "special-rebate", + "title": "Special Rebate" + }, + { + "const": "temporary", + "title": "Temporary" + }, + { + "const": "standard", + "title": "Standard" + }, + { + "const": "yearly-turnover", + "title": "Yearly Turnover" + }, + { + "pattern": "^(?:[a-z]|[a-z0-9][a-z0-9-+]*[a-z0-9])$", + "title": "Other" + } + ], + "title": "Key", + "description": "Key for identifying the type of discount being applied." + }, + "code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Code", + "description": "Code or reference for this discount defined by the issuer" + }, + "reason": { + "type": "string", + "title": "Reason", + "description": "Text description as to why the discount was applied" + }, "percent": { "$ref": "https://gobl.org/draft-0/num/percentage", "title": "Percent", - "description": "Percentage if fixed amount not applied" + "description": "Percentage to apply to the line total to calcaulte the discount amount" }, "amount": { "$ref": "https://gobl.org/draft-0/num/amount", "title": "Amount", - "description": "Fixed discount amount to apply (calculated if percent present).", + "description": "Fixed discount amount to apply (calculated if percent present)", "calculated": true }, - "code": { - "type": "string", - "title": "Code", - "description": "Reason code." - }, - "reason": { - "type": "string", - "title": "Reason", - "description": "Text description as to why the discount was applied" + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "title": "Extensions", + "description": "Extension codes that apply to the discount" } }, "type": "object", @@ -650,12 +899,12 @@ "buyer": { "$ref": "https://gobl.org/draft-0/org/party", "title": "Buyer", - "description": "Party who is responsible for making the purchase, but is not responsible\nfor handling taxes." + "description": "Party who is responsible for issuing payment, if not the same as the customer." }, "seller": { "$ref": "https://gobl.org/draft-0/org/party", "title": "Seller", - "description": "Party who is selling the goods but is not responsible for taxes like the\nsupplier." + "description": "Seller is the party liable to pay taxes on the transaction if not the same as the supplier." }, "projects": { "items": { @@ -717,59 +966,6 @@ "type": "object", "description": "Ordering provides additional information about the ordering process including references to other documents and alternative parties involved in the order-to-delivery process." }, - "Outlay": { - "properties": { - "uuid": { - "type": "string", - "format": "uuid", - "title": "UUID", - "description": "Universally Unique Identifier." - }, - "i": { - "type": "integer", - "title": "Index", - "description": "Outlay number index inside the invoice for ordering (calculated).", - "calculated": true - }, - "date": { - "$ref": "https://gobl.org/draft-0/cal/date", - "title": "Date", - "description": "When was the outlay made." - }, - "code": { - "type": "string", - "title": "Code", - "description": "Invoice number or other reference detail used to identify the outlay." - }, - "series": { - "type": "string", - "title": "Series", - "description": "Series of the outlay invoice." - }, - "description": { - "type": "string", - "title": "Description", - "description": "Details on what the outlay was." - }, - "supplier": { - "$ref": "https://gobl.org/draft-0/org/party", - "title": "Supplier", - "description": "Who was the supplier of the outlay" - }, - "amount": { - "$ref": "https://gobl.org/draft-0/num/amount", - "title": "Amount", - "description": "Amount paid by the supplier." - } - }, - "type": "object", - "required": [ - "i", - "description", - "amount" - ], - "description": "Outlay represents a reimbursable expense that was paid for by the supplier and invoiced separately by the third party directly to the customer." - }, "Payment": { "properties": { "payee": { diff --git a/data/schemas/cbc/code.json b/data/schemas/cbc/code.json index 23b65f04..a11f94e2 100644 --- a/data/schemas/cbc/code.json +++ b/data/schemas/cbc/code.json @@ -7,7 +7,7 @@ "type": "string", "maxLength": 32, "minLength": 1, - "pattern": "^[A-Za-z0-9]+([\\.\\-\\/ _]?[A-Za-z0-9]+)*$", + "pattern": "^[A-Za-z0-9]+([\\.\\-\\/ _\\:]?[A-Za-z0-9]+)*$", "title": "Code", "description": "Alphanumerical text identifier with upper-case letters and limits on using\nspecial characters or whitespace to separate blocks." } 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/inbox.json b/data/schemas/org/inbox.json index ef290809..ac1ff438 100644 --- a/data/schemas/org/inbox.json +++ b/data/schemas/org/inbox.json @@ -11,6 +11,11 @@ "title": "UUID", "description": "Universally Unique Identifier." }, + "label": { + "type": "string", + "title": "Label", + "description": "Label for the inbox." + }, "key": { "$ref": "https://gobl.org/draft-0/cbc/key", "title": "Key", @@ -21,21 +26,23 @@ "title": "Role", "description": "Role assigned to this inbox that may be relevant for the consumer." }, - "name": { - "type": "string", - "title": "Name", - "description": "Human name for the inbox." - }, "code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "title": "Code", + "description": "Code or ID that identifies the Inbox." + }, + "url": { "type": "string", - "description": "Actual Code or ID that identifies the Inbox." + "title": "URL", + "description": "URL of the inbox that includes the protocol, server, and path. May\nbe used instead of the Code to identify the inbox." + }, + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "title": "Extensions", + "description": "Extension code map for any additional regime or addon specific codes that may be required." } }, "type": "object", - "required": [ - "key", - "code" - ], "description": "Inbox is used to store data about a connection with a service that is responsible for potentially receiving copies of GOBL envelopes or other document formats defined locally." } } 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..b3e92362 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", @@ -147,6 +157,11 @@ }, "Card": { "properties": { + "first6": { + "type": "string", + "title": "First 6", + "description": "First 6 digits of the card's Primary Account Number (PAN)." + }, "last4": { "type": "string", "title": "Last 4", @@ -160,6 +175,7 @@ }, "type": "object", "required": [ + "first6", "last4", "holder" ], diff --git a/data/schemas/pay/instructions.json b/data/schemas/pay/instructions.json index 5a98a10d..c84c9be0 100644 --- a/data/schemas/pay/instructions.json +++ b/data/schemas/pay/instructions.json @@ -5,6 +5,11 @@ "$defs": { "Card": { "properties": { + "first6": { + "type": "string", + "title": "First 6", + "description": "First 6 digits of the card's Primary Account Number (PAN)." + }, "last4": { "type": "string", "title": "Last 4", @@ -18,6 +23,7 @@ }, "type": "object", "required": [ + "first6", "last4", "holder" ], @@ -95,6 +101,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 +131,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", @@ -154,9 +170,9 @@ "description": "Optional text description of the payment method" }, "ref": { - "type": "string", + "$ref": "https://gobl.org/draft-0/cbc/code", "title": "Reference", - "description": "Remittance information or concept, a text value used to link the payment with the invoice." + "description": "Remittance information or concept, a code value used to link the payment with the invoice." }, "credit_transfer": { "items": { 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/data/schemas/tax/regime-def.json b/data/schemas/tax/regime-def.json index 6c05cf62..0f7ba3d0 100644 --- a/data/schemas/tax/regime-def.json +++ b/data/schemas/tax/regime-def.json @@ -36,7 +36,7 @@ }, "type": "array", "title": "Rates", - "description": "Specific tax definitions inside this category." + "description": "Specific tax definitions inside this category. Order is important." }, "extensions": { "items": { @@ -294,14 +294,6 @@ "title": "Identity Keys", "description": "Identity keys used in addition to regular tax identities and specific for the\nregime that may be validated against." }, - "charge_keys": { - "items": { - "$ref": "https://gobl.org/draft-0/cbc/key-definition" - }, - "type": "array", - "title": "Charge Keys", - "description": "Charge keys specific for the regime and may be validated or used in the UI as suggestions" - }, "payment_means_keys": { "items": { "$ref": "https://gobl.org/draft-0/cbc/key-definition" 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/internal/cli/testdata/TestBuild_draft b/internal/cli/testdata/TestBuild_draft index 91ae81eb..8ba9ddbd 100644 --- a/internal/cli/testdata/TestBuild_draft +++ b/internal/cli/testdata/TestBuild_draft @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5637.50", "sum": "5187.50", "tax": "450.00", @@ -204,7 +196,7 @@ "head": { "dig": { "alg": "sha256", - "val": "c8efedb81922d41509b6df8f94194a877677d4c3a4062ac8c1fb1b1896ab28c9" + "val": "c793f04d5ee66340bb1a8042a6ba80e71271d50325ca24942d3df50350170d1c" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" } diff --git a/internal/cli/testdata/TestBuild_explicit_type b/internal/cli/testdata/TestBuild_explicit_type index 4dba69f1..7e778ddb 100644 --- a/internal/cli/testdata/TestBuild_explicit_type +++ b/internal/cli/testdata/TestBuild_explicit_type @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5654.75", "sum": "5187.50", "tax": "467.25", @@ -204,7 +196,7 @@ "head": { "dig": { "alg": "sha256", - "val": "9e39d6434df855fc708682cf705719285ad9b384846e513fb7debe999e16454a" + "val": "a432f74e898b155348a74f90a3abefc2f7bde9a9dbbe069c2a3a09b0bd5edb4a" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" } diff --git a/internal/cli/testdata/TestBuild_merge_YAML b/internal/cli/testdata/TestBuild_merge_YAML index aee6cc6d..7ae45f71 100644 --- a/internal/cli/testdata/TestBuild_merge_YAML +++ b/internal/cli/testdata/TestBuild_merge_YAML @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5637.50", "sum": "5187.50", "tax": "450.00", @@ -204,7 +196,7 @@ "head": { "dig": { "alg": "sha256", - "val": "e78495e85c5d6935e4d91d0d43a410c958f69f7a78944fd6104756217ca0d254" + "val": "950eddcd07547556e5ae7bb77d1f92ac54bca98d77266a9a8b64db66262e3eec" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" } diff --git a/internal/cli/testdata/TestBuild_success b/internal/cli/testdata/TestBuild_success index 380a74ef..89783f43 100644 --- a/internal/cli/testdata/TestBuild_success +++ b/internal/cli/testdata/TestBuild_success @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5637.50", "sum": "5187.50", "tax": "450.00", @@ -204,7 +196,7 @@ "head": { "dig": { "alg": "sha256", - "val": "2d4d4d81d49dc453f338a27b13fb8e54373f3a412bb7ff16c3d0b28d4a4a8a46" + "val": "02b5bfcb333c82b4f9ba02bcf2128b5649a964820cade97f45d926829fb811fa" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" } diff --git a/internal/cli/testdata/TestBuild_template_with_empty_input b/internal/cli/testdata/TestBuild_template_with_empty_input index 9226cfde..d31a7e1f 100644 --- a/internal/cli/testdata/TestBuild_template_with_empty_input +++ b/internal/cli/testdata/TestBuild_template_with_empty_input @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5637.50", "sum": "5187.50", "tax": "450.00", @@ -204,7 +196,7 @@ "head": { "dig": { "alg": "sha256", - "val": "38fd37f6c05f2d0fe2f52e1c4bf8bb7f6c6fe289e03e07f9e0948d54794efd3d" + "val": "bb73b39a60e535d5cc02e5b4661b8e8029731e4613aab98389c87f6b9681c699" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" } diff --git a/internal/cli/testdata/TestBuild_with_template b/internal/cli/testdata/TestBuild_with_template index 9dd0d2b6..46e6dc56 100644 --- a/internal/cli/testdata/TestBuild_with_template +++ b/internal/cli/testdata/TestBuild_with_template @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5637.50", "sum": "5187.50", "tax": "450.00", @@ -204,7 +196,7 @@ "head": { "dig": { "alg": "sha256", - "val": "1bc85f70750755ec5a49fb9ebacd3824c4fbe0f4286d5fef7712aa157a973ae0" + "val": "8a61751c35789a37712837e66f09581c9dd9f0b2f218dec4364e90f859f4ae22" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" } diff --git a/internal/cli/testdata/TestSign_draft_envelope b/internal/cli/testdata/TestSign_draft_envelope index 44adbc7b..b23a26d7 100644 --- a/internal/cli/testdata/TestSign_draft_envelope +++ b/internal/cli/testdata/TestSign_draft_envelope @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5637.50", "sum": "5187.50", "tax": "450.00", @@ -204,11 +196,11 @@ "head": { "dig": { "alg": "sha256", - "val": "c8efedb81922d41509b6df8f94194a877677d4c3a4062ac8c1fb1b1896ab28c9" + "val": "c793f04d5ee66340bb1a8042a6ba80e71271d50325ca24942d3df50350170d1c" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" }, "sigs": [ - "eyJhbGciOiJFUzI1NiIsImtpZCI6ImI3Y2VlNjBmLTIwNGUtNDM4Yi1hODhmLTAyMWQyOGFmNjk5MSJ9.eyJ1dWlkIjoiOWQ4ZWFmZDUtNzdiZS0xMWVjLWI0ODUtNTQwNWRiOWEzZTQ5IiwiZGlnIjp7ImFsZyI6InNoYTI1NiIsInZhbCI6ImM4ZWZlZGI4MTkyMmQ0MTUwOWI2ZGY4Zjk0MTk0YTg3NzY3N2Q0YzNhNDA2MmFjOGMxZmIxYjE4OTZhYjI4YzkifX0.uhIm58KfFHPYnqEzpsdfGXCkjRvM5cKSB1o1q2jdL5KaAvpqCsDKmBsMz7K7qsBbFFWleBFrlWNpJ5FVdMw-Gg" + "eyJhbGciOiJFUzI1NiIsImtpZCI6ImI3Y2VlNjBmLTIwNGUtNDM4Yi1hODhmLTAyMWQyOGFmNjk5MSJ9.eyJ1dWlkIjoiOWQ4ZWFmZDUtNzdiZS0xMWVjLWI0ODUtNTQwNWRiOWEzZTQ5IiwiZGlnIjp7ImFsZyI6InNoYTI1NiIsInZhbCI6ImM3OTNmMDRkNWVlNjYzNDBiYjFhODA0MmE2YmE4MGU3MTI3MWQ1MDMyNWNhMjQ5NDJkM2RmNTAzNTAxNzBkMWMifX0.III-TN4WWYsyPupPdDUBXjgfAOH7wPU8UrMMcBw6NJ1tAEGYWJ30f_nIEkHPIHz9_g72aeq3obht4teR8RgiRA" ] } \ No newline at end of file diff --git a/internal/cli/testdata/TestSign_success b/internal/cli/testdata/TestSign_success index ff1a589d..1abc1a62 100644 --- a/internal/cli/testdata/TestSign_success +++ b/internal/cli/testdata/TestSign_success @@ -96,13 +96,6 @@ "total": "15.00" } ], - "outlays": [ - { - "amount": "0.00", - "description": "Something paid for by us", - "i": 1 - } - ], "payment": { "instructions": { "credit_transfer": [ @@ -155,7 +148,6 @@ ] }, "totals": { - "outlays": "0.00", "payable": "5637.50", "sum": "5187.50", "tax": "450.00", @@ -204,11 +196,11 @@ "head": { "dig": { "alg": "sha256", - "val": "2d4d4d81d49dc453f338a27b13fb8e54373f3a412bb7ff16c3d0b28d4a4a8a46" + "val": "02b5bfcb333c82b4f9ba02bcf2128b5649a964820cade97f45d926829fb811fa" }, "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49" }, "sigs": [ - "eyJhbGciOiJFUzI1NiIsImtpZCI6ImI3Y2VlNjBmLTIwNGUtNDM4Yi1hODhmLTAyMWQyOGFmNjk5MSJ9.eyJ1dWlkIjoiOWQ4ZWFmZDUtNzdiZS0xMWVjLWI0ODUtNTQwNWRiOWEzZTQ5IiwiZGlnIjp7ImFsZyI6InNoYTI1NiIsInZhbCI6IjJkNGQ0ZDgxZDQ5ZGM0NTNmMzM4YTI3YjEzZmI4ZTU0MzczZjNhNDEyYmI3ZmYxNmMzZDBiMjhkNGE0YThhNDYifX0.jziDu6YrXRm0pk30OfvA4TKf7BzOb_CK8aSbHJfHLPOI8thoZdQ_DxS0AyTu3OdXaUuva7mb9ebK99twAESMYA" + "eyJhbGciOiJFUzI1NiIsImtpZCI6ImI3Y2VlNjBmLTIwNGUtNDM4Yi1hODhmLTAyMWQyOGFmNjk5MSJ9.eyJ1dWlkIjoiOWQ4ZWFmZDUtNzdiZS0xMWVjLWI0ODUtNTQwNWRiOWEzZTQ5IiwiZGlnIjp7ImFsZyI6InNoYTI1NiIsInZhbCI6IjAyYjViZmNiMzMzYzgyYjRmOWJhMDJiY2YyMTI4YjU2NDlhOTY0ODIwY2FkZTk3ZjQ1ZDkyNjgyOWZiODExZmEifX0.jjzi2ci3FpZUHb-TeGqfr5YbwNcVRvsmvbtOXTxZFdRTT1g52gB-nE05_39rkOeb8suaix0i5DtbQAIS1Wz00w" ] } \ No newline at end of file diff --git a/internal/cli/testdata/draft.json b/internal/cli/testdata/draft.json index d164beb4..460157b9 100644 --- a/internal/cli/testdata/draft.json +++ b/internal/cli/testdata/draft.json @@ -4,9 +4,8 @@ "uuid": "9d8eafd5-77be-11ec-b485-5405db9a3e49", "dig": { "alg": "sha256", - "val": "c8efedb81922d41509b6df8f94194a877677d4c3a4062ac8c1fb1b1896ab28c9" - }, - "draft": true + "val": "c793f04d5ee66340bb1a8042a6ba80e71271d50325ca24942d3df50350170d1c" + } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", @@ -143,13 +142,6 @@ "total": "15.00" } ], - "outlays": [ - { - "i": 1, - "description": "Something paid for by us", - "amount": "0.00" - } - ], "payment": { "terms": { "key": "instant" @@ -205,7 +197,6 @@ }, "tax": "450.00", "total_with_tax": "5637.50", - "outlays": "0.00", "payable": "5637.50" } } 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/inbox.go b/org/inbox.go index 0ce423b3..8dffb22a 100644 --- a/org/inbox.go +++ b/org/inbox.go @@ -7,6 +7,7 @@ import ( "github.com/invopop/gobl/tax" "github.com/invopop/gobl/uuid" "github.com/invopop/validation" + "github.com/invopop/validation/is" ) // Inbox is used to store data about a connection with a service that is responsible @@ -14,14 +15,30 @@ import ( // defined locally. type Inbox struct { uuid.Identify + // Label for the inbox. + Label string `json:"label,omitempty" jsonschema:"title=Label"` // Type of inbox being defined. - Key cbc.Key `json:"key" jsonschema:"title=Key"` + Key cbc.Key `json:"key,omitempty" jsonschema:"title=Key"` // Role assigned to this inbox that may be relevant for the consumer. Role cbc.Key `json:"role,omitempty" jsonschema:"title=Role"` - // Human name for the inbox. - Name string `json:"name,omitempty" jsonschema:"title=Name"` - // Actual Code or ID that identifies the Inbox. - Code string `json:"code"` + // Code or ID that identifies the Inbox. + Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"` + // URL of the inbox that includes the protocol, server, and path. May + // be used instead of the Code to identify the inbox. + URL string `json:"url,omitempty" jsonschema:"title=URL"` + // Extension code map for any additional regime or addon specific codes that may be required. + Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` +} + +// Normalize will try to clean the inbox's data. +func (i *Inbox) Normalize(normalizers tax.Normalizers) { + if i == nil { + return + } + uuid.Normalize(&i.UUID) + i.Code = cbc.NormalizeCode(i.Code) + i.Ext = tax.CleanExtensions(i.Ext) + normalizers.Each(i) } // Validate ensures the inbox's fields look good. @@ -33,8 +50,36 @@ func (i *Inbox) Validate() error { func (i *Inbox) ValidateWithContext(ctx context.Context) error { return tax.ValidateStructWithContext(ctx, i, validation.Field(&i.UUID), - validation.Field(&i.Key, validation.Required), + validation.Field(&i.Key), validation.Field(&i.Role), - validation.Field(&i.Code, validation.Required), + validation.Field(&i.Code, + validation.When( + i.URL == "", + validation.Required.Error("cannot be blank without url"), + ), + ), + validation.Field(&i.URL, + is.URL, + validation.When( + i.Code != "", + validation.Empty.Error("mutually exclusive with code"), + ), + ), + validation.Field(&i.Ext), ) } + +// AddInbox makes it easier to add a new inbox to a list and replace an +// existing value with a matching key. +func AddInbox(in []*Inbox, i *Inbox) []*Inbox { + if in == nil { + return []*Inbox{i} + } + for _, v := range in { + if v.Key == i.Key { + *v = *i // copy in place + return in + } + } + return append(in, i) +} diff --git a/org/inbox_test.go b/org/inbox_test.go new file mode 100644 index 00000000..f769c381 --- /dev/null +++ b/org/inbox_test.go @@ -0,0 +1,104 @@ +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" +) + +func TestAddInbox(t *testing.T) { + key := cbc.Key("test-inbox") + st := struct { + Inboxes []*org.Inbox + }{ + Inboxes: []*org.Inbox{ + { + Key: key, + Code: "BAR", + }, + }, + } + st.Inboxes = org.AddInbox(st.Inboxes, &org.Inbox{ + Key: key, + Code: "BARDOM", + }) + assert.Len(t, st.Inboxes, 1) + assert.Equal(t, "BARDOM", st.Inboxes[0].Code.String()) +} + +func TestInboxNormalize(t *testing.T) { + t.Run("with nil", func(t *testing.T) { + var id *org.Inbox + assert.NotPanics(t, func() { + id.Normalize(nil) + }) + }) + t.Run("missing extensions", func(t *testing.T) { + id := &org.Inbox{ + Key: cbc.Key("inbox"), + Code: "BAR", + Ext: tax.Extensions{}, + } + id.Normalize(nil) + assert.Equal(t, "inbox", id.Key.String()) + assert.Nil(t, id.Ext) + }) + t.Run("with extension", func(t *testing.T) { + id := &org.Inbox{ + 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 TestInboxValidate(t *testing.T) { + t.Run("with basics", func(t *testing.T) { + id := &org.Inbox{ + Code: "BAR", + Ext: tax.Extensions{ + iso.ExtKeySchemeID: "0004", + }, + } + err := id.Validate() + assert.NoError(t, err) + }) + t.Run("with both key", func(t *testing.T) { + id := &org.Inbox{ + Key: "fiscal-code", + Code: "1234567890", + } + err := id.Validate() + assert.NoError(t, err) + }) + t.Run("missing code", func(t *testing.T) { + id := &org.Inbox{ + Key: "fiscal-code", + } + err := id.Validate() + assert.ErrorContains(t, err, "code: cannot be blank without url") + }) + t.Run("with URL", func(t *testing.T) { + id := &org.Inbox{ + URL: "https://inbox.example.com", + } + err := id.Validate() + assert.NoError(t, err) + }) + t.Run("with code and URL", func(t *testing.T) { + id := &org.Inbox{ + Code: "FOOO", + URL: "https://inbox.example.com", + } + err := id.Validate() + assert.ErrorContains(t, err, "url: mutually exclusive with code") + }) +} diff --git a/org/party.go b/org/party.go index a9188aba..867b7af1 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 } @@ -71,6 +80,7 @@ func (p *Party) Normalize(normalizers tax.Normalizers) { normalizers.Each(p) tax.Normalize(normalizers, p.Identities) + tax.Normalize(normalizers, p.Inboxes) tax.Normalize(normalizers, p.Addresses) tax.Normalize(normalizers, p.Emails) } 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..6567f8e0 100644 --- a/pay/instructions.go +++ b/pay/instructions.go @@ -20,8 +20,8 @@ type Instructions struct { Key cbc.Key `json:"key" jsonschema:"title=Key"` // Optional text description of the payment method Detail string `json:"detail,omitempty" jsonschema:"title=Detail"` - // Remittance information or concept, a text value used to link the payment with the invoice. - Ref string `json:"ref,omitempty" jsonschema:"title=Reference"` + // Remittance information or concept, a code value used to link the payment with the invoice. + Ref cbc.Code `json:"ref,omitempty" jsonschema:"title=Reference"` // Instructions for sending payment via a bank transfer. CreditTransfer []*CreditTransfer `json:"credit_transfer,omitempty" jsonschema:"title=Credit Transfer"` // Details of the payment that will be made via a credit or debit card. @@ -39,7 +39,11 @@ type Instructions struct { } // Card contains simplified card holder data as a reference for the customer. +// PCI compliance requires only the first 6 and last 4 digits of the card number +// to be stored openly. type Card struct { + // First 6 digits of the card's Primary Account Number (PAN). + First6 string `json:"first6" jsonschema:"title=First 6"` // Last 4 digits of the card's Primary Account Number (PAN). Last4 string `json:"last4" jsonschema:"title=Last 4"` // Name of the person whom the card belongs to. @@ -86,20 +90,11 @@ func (i *Instructions) Normalize(normalizers tax.Normalizers) { if i == nil { return } + i.Ref = cbc.NormalizeCode(i.Ref) i.Ext = tax.CleanExtensions(i.Ext) 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, @@ -141,6 +136,7 @@ func (i *Instructions) Validate() error { func (i *Instructions) ValidateWithContext(ctx context.Context) error { return tax.ValidateStructWithContext(ctx, i, validation.Field(&i.Key, validation.Required, HasValidMeansKey), + validation.Field(&i.Ref), validation.Field(&i.CreditTransfer), validation.Field(&i.DirectDebit), validation.Field(&i.Online), diff --git a/pay/instructions_test.go b/pay/instructions_test.go index df4d78df..650f8f13 100644 --- a/pay/instructions_test.go +++ b/pay/instructions_test.go @@ -13,6 +13,7 @@ import ( func TestInstructionsNormalize(t *testing.T) { i := &pay.Instructions{ Key: "online", + Ref: " fooo ", Detail: "Some random payment", Ext: tax.Extensions{ "random": "", @@ -20,6 +21,7 @@ func TestInstructionsNormalize(t *testing.T) { } i.Normalize(nil) assert.Empty(t, i.Ext) + assert.Equal(t, "fooo", i.Ref.String()) i = nil assert.NotPanics(t, func() { 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/br/examples/out/invoice-services.json b/regimes/br/examples/out/invoice-services.json index 4045a3de..0a94da5b 100644 --- a/regimes/br/examples/out/invoice-services.json +++ b/regimes/br/examples/out/invoice-services.json @@ -60,9 +60,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/ca/examples/out/invoice-ca-ca.json b/regimes/ca/examples/out/invoice-ca-ca.json index 086d444c..f3a6abb0 100644 --- a/regimes/ca/examples/out/invoice-ca-ca.json +++ b/regimes/ca/examples/out/invoice-ca-ca.json @@ -57,9 +57,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ 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/invoice-de-es-b2b.yaml b/regimes/de/examples/invoice-de-es-b2b.yaml new file mode 100644 index 00000000..1eca6581 --- /dev/null +++ b/regimes/de/examples/invoice-de-es-b2b.yaml @@ -0,0 +1,59 @@ +$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" +series: "SAMPLE" +code: "001" +tax: + tags: + - reverse-charge +supplier: + tax_id: + country: "DE" + code: "111111125" # random + name: "Provide One GmbH" + emails: + - addr: "billing@example.com" + addresses: + - num: "16" + street: "Dietmar-Hopp-Allee" + locality: "Walldorf" + code: "69190" + country: "DE" + +customer: + tax_id: + country: "ES" + code: "B98602642" + name: "Provide One S.L." + emails: + - addr: "billing@example.com" + addresses: + - num: "42" + street: "Calle Pradillo" + locality: "Madrid" + region: "Madrid" + code: "28002" + country: "ES" + +lines: + - quantity: 20 + item: + name: "Development services" + price: "90.00" + unit: "h" + discounts: + - percent: "10%" + reason: "Special discount" + taxes: + - cat: VAT + rate: exempt+reverse-charge + +payment: + instructions: + key: "credit-transfer+sepa" + credit_transfer: + - iban: "DE89370400440532013000" + name: "Random Bank Co." diff --git a/regimes/de/examples/out/invoice-de-de-stnr.json b/regimes/de/examples/out/invoice-de-de-stnr.json index c83c3d89..2d99765f 100644 --- a/regimes/de/examples/out/invoice-de-de-stnr.json +++ b/regimes/de/examples/out/invoice-de-de-stnr.json @@ -74,9 +74,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/de/examples/out/invoice-de-de.json b/regimes/de/examples/out/invoice-de-de.json index 236f7088..83b22fc3 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": { @@ -69,21 +78,38 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ { "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/examples/out/invoice-de-es-b2b.json b/regimes/de/examples/out/invoice-de-es-b2b.json new file mode 100644 index 00000000..91c7c301 --- /dev/null +++ b/regimes/de/examples/out/invoice-de-es-b2b.json @@ -0,0 +1,151 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "8aaa0df9390444f233eaa74495a66b758df529a1a58513ce59fc6cded30d12e9" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "DE", + "$addons": [ + "eu-en16931-v2017", + "de-xrechnung-v3" + ], + "$tags": [ + "reverse-charge" + ], + "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": { + "country": "DE", + "code": "111111125" + }, + "addresses": [ + { + "num": "16", + "street": "Dietmar-Hopp-Allee", + "locality": "Walldorf", + "code": "69190", + "country": "DE" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "exempt+reverse-charge", + "ext": { + "untdid-tax-category": "AE" + } + } + ], + "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", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "exempt+reverse-charge", + "ext": { + "untdid-tax-category": "AE" + }, + "base": "1620.00", + "amount": "0.00" + } + ], + "amount": "0.00" + } + ], + "sum": "0.00" + }, + "tax": "0.00", + "total_with_tax": "1620.00", + "payable": "1620.00" + }, + "notes": [ + { + "key": "legal", + "src": "reverse-charge", + "text": "Reverse Charge / Umkehr der Steuerschuld." + } + ] + } +} \ No newline at end of file diff --git a/regimes/de/examples/out/invoice-de-simplified.json b/regimes/de/examples/out/invoice-de-simplified.json index 01603015..51e7fb8f 100644 --- a/regimes/de/examples/out/invoice-de-simplified.json +++ b/regimes/de/examples/out/invoice-de-simplified.json @@ -52,9 +52,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ 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 d6c7d709..d3dc0ce7 100644 --- a/regimes/de/invoices.go +++ b/regimes/de/invoices.go @@ -28,9 +28,9 @@ 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, @@ -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 aee7c561..83536bc5 100644 --- a/regimes/de/invoices_test.go +++ b/regimes/de/invoices_test.go @@ -56,9 +56,9 @@ func TestInvoiceValidation(t *testing.T) { assert.NoError(t, inv.Validate()) inv = validInvoice() - inv.Supplier.TaxID.Code = "" + inv.Supplier.TaxID = nil require.NoError(t, inv.Calculate()) - assert.ErrorContains(t, inv.Validate(), "supplier: (identities: missing key de-tax-number; tax_id: (code: cannot be blank.).)") + assert.ErrorContains(t, inv.Validate(), "supplier: (identities: missing key de-tax-number; tax_id: cannot be blank.).") }) t.Run("simplified invoice - no tax details", func(t *testing.T) { @@ -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/regimes/es/examples/out/credit-note-es-es-tbai.json b/regimes/es/examples/out/credit-note-es-es-tbai.json index e6ff8ca6..bd0e4db0 100644 --- a/regimes/es/examples/out/credit-note-es-es-tbai.json +++ b/regimes/es/examples/out/credit-note-es-es-tbai.json @@ -86,9 +86,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/es/examples/out/invoice-es-es-freelance.json b/regimes/es/examples/out/invoice-es-es-freelance.json index e15b7ecd..4fd2316a 100644 --- a/regimes/es/examples/out/invoice-es-es-freelance.json +++ b/regimes/es/examples/out/invoice-es-es-freelance.json @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/es/examples/out/invoice-es-es.json b/regimes/es/examples/out/invoice-es-es.json index 688cdefc..c4bf6416 100644 --- a/regimes/es/examples/out/invoice-es-es.json +++ b/regimes/es/examples/out/invoice-es-es.json @@ -65,9 +65,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/es/examples/out/invoice-es-nl-tbai-b2c.json b/regimes/es/examples/out/invoice-es-nl-tbai-b2c.json index 0244e14d..662b91f9 100644 --- a/regimes/es/examples/out/invoice-es-nl-tbai-b2c.json +++ b/regimes/es/examples/out/invoice-es-nl-tbai-b2c.json @@ -62,9 +62,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/es/examples/out/invoice-es-nl-tbai-exempt.json b/regimes/es/examples/out/invoice-es-nl-tbai-exempt.json index 8cc09d8c..b33a7dbc 100644 --- a/regimes/es/examples/out/invoice-es-nl-tbai-exempt.json +++ b/regimes/es/examples/out/invoice-es-nl-tbai-exempt.json @@ -60,9 +60,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/es/examples/out/invoice-es-simplified.json b/regimes/es/examples/out/invoice-es-simplified.json index d7aac7c3..d653c55b 100644 --- a/regimes/es/examples/out/invoice-es-simplified.json +++ b/regimes/es/examples/out/invoice-es-simplified.json @@ -52,9 +52,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/es/examples/out/invoice-es-usd.json b/regimes/es/examples/out/invoice-es-usd.json index 7b345256..499df5dd 100644 --- a/regimes/es/examples/out/invoice-es-usd.json +++ b/regimes/es/examples/out/invoice-es-usd.json @@ -75,9 +75,9 @@ "sum": "2000.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "200.00", - "reason": "Special discount" + "amount": "200.00" } ], "taxes": [ diff --git a/regimes/fr/examples/out/invoice-fr-fr.json b/regimes/fr/examples/out/invoice-fr-fr.json index 9c702639..d12b936b 100644 --- a/regimes/fr/examples/out/invoice-fr-fr.json +++ b/regimes/fr/examples/out/invoice-fr-fr.json @@ -70,9 +70,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/gb/examples/out/invoice-b2b.json b/regimes/gb/examples/out/invoice-b2b.json index b12ccd12..cb54b7f5 100644 --- a/regimes/gb/examples/out/invoice-b2b.json +++ b/regimes/gb/examples/out/invoice-b2b.json @@ -70,9 +70,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/gr/examples/out/invoice-el-el.json b/regimes/gr/examples/out/invoice-el-el.json index 47af5339..6cb69e6b 100644 --- a/regimes/gr/examples/out/invoice-el-el.json +++ b/regimes/gr/examples/out/invoice-el-el.json @@ -81,9 +81,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Ειδική Έκπτωση", "percent": "10%", - "amount": "180.00", - "reason": "Ειδική Έκπτωση" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/gr/examples/out/invoice-islands.json b/regimes/gr/examples/out/invoice-islands.json index b4167b62..11a3dcb0 100644 --- a/regimes/gr/examples/out/invoice-islands.json +++ b/regimes/gr/examples/out/invoice-islands.json @@ -81,9 +81,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Ειδική Έκπτωση", "percent": "10%", - "amount": "180.00", - "reason": "Ειδική Έκπτωση" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/it/charges.go b/regimes/it/charges.go deleted file mode 100644 index a01ffdea..00000000 --- a/regimes/it/charges.go +++ /dev/null @@ -1,25 +0,0 @@ -package it - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" -) - -// List of charge types specific to the italian regime. -const ( - ChargeKeyStampDuty cbc.Key = "stamp-duty" -) - -var chargeKeyDefinitions = []*cbc.KeyDefinition{ - { - Key: ChargeKeyStampDuty, - Name: i18n.String{ - i18n.EN: "Stamp Duty", - i18n.IT: "Imposta di bollo", - }, - Desc: i18n.String{ - i18n.EN: "A fixed-price tax applied to the production, request or presentation of certain documents: civil, commercial, judicial and extrajudicial documents, on notices, on posters.", - i18n.IT: "Un'imposta applicata alla produzione, richiesta o presentazione di determinati documenti: atti civili, commerciali, giudiziali ed extragiudiziali, sugli avvisi, sui manifesti.", - }, - }, -} diff --git a/regimes/it/examples/out/flat-rate.json b/regimes/it/examples/out/flat-rate.json index a0d6161f..ce26b6b9 100644 --- a/regimes/it/examples/out/flat-rate.json +++ b/regimes/it/examples/out/flat-rate.json @@ -91,10 +91,10 @@ ], "charges": [ { - "key": "stamp-duty", "i": 1, - "amount": "2.00", - "reason": "Imposta di bollo" + "key": "stamp-duty", + "reason": "Imposta di bollo", + "amount": "2.00" } ], "totals": { diff --git a/regimes/it/examples/out/freelance.json b/regimes/it/examples/out/freelance.json index 8dcd2779..612c3403 100644 --- a/regimes/it/examples/out/freelance.json +++ b/regimes/it/examples/out/freelance.json @@ -91,9 +91,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ diff --git a/regimes/it/it.go b/regimes/it/it.go index aefb9eeb..56a83aad 100644 --- a/regimes/it/it.go +++ b/regimes/it/it.go @@ -25,7 +25,6 @@ func New() *tax.RegimeDef { i18n.IT: "Italia", }, TimeZone: "Europe/Rome", - ChargeKeys: chargeKeyDefinitions, // charges.go IdentityKeys: identityKeyDefinitions, // identities.go Scenarios: scenarios, // scenarios.go Tags: []*tax.TagSet{ diff --git a/regimes/pt/migrations_test.go b/regimes/pt/migrations_test.go index a3931664..533a49bb 100644 --- a/regimes/pt/migrations_test.go +++ b/regimes/pt/migrations_test.go @@ -22,13 +22,6 @@ func TestTaxRateMigration(t *testing.T) { assert.Equal(t, tax.RateExempt, t0.Rate) assert.Equal(t, tax.ExtValue("M01"), t0.Ext[saft.ExtKeyExemption]) - // Invalid old rate - inv = validInvoice() - inv.Lines[0].Taxes[0].Rate = "exempt+invalid" - - err = inv.Calculate() - assert.ErrorContains(t, err, "invalid-rate: 'exempt+invalid'") - // Valid new rate inv = validInvoice() inv.Lines[0].Taxes[0].Rate = "exempt" diff --git a/regimes/us/examples/out/invoice-us-us.json b/regimes/us/examples/out/invoice-us-us.json index fb61ed8f..389435a7 100644 --- a/regimes/us/examples/out/invoice-us-us.json +++ b/regimes/us/examples/out/invoice-us-us.json @@ -60,9 +60,9 @@ "sum": "1800.00", "discounts": [ { + "reason": "Special discount", "percent": "10%", - "amount": "180.00", - "reason": "Special discount" + "amount": "180.00" } ], "taxes": [ 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/constants.go b/tax/constants.go index f77cb78a..88c8984d 100644 --- a/tax/constants.go +++ b/tax/constants.go @@ -30,4 +30,6 @@ const ( TagSelfBilled cbc.Key = "self-billed" TagPartial cbc.Key = "partial" TagB2G cbc.Key = "b2g" + TagExport cbc.Key = "export" + TagEEA cbc.Key = "eea" // European Economic Area, used with exports ) diff --git a/tax/extensions.go b/tax/extensions.go index 8016d520..3a2b4350 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,46 @@ func (v validateCodeMap) Validate(value interface{}) error { return nil } +// ExtensionsHasValues returns a validation rule that ensures the extension map's +// key has one of the provided **values**. +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 77753874..a7a718c9 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..94d68308 100644 --- a/tax/regime_def.go +++ b/tax/regime_def.go @@ -3,6 +3,7 @@ package tax import ( "context" "errors" + "fmt" "strings" "time" @@ -69,9 +70,6 @@ type RegimeDef struct { // regime that may be validated against. IdentityKeys []*cbc.KeyDefinition `json:"identity_keys,omitempty" jsonschema:"title=Identity Keys"` - // Charge keys specific for the regime and may be validated or used in the UI as suggestions - ChargeKeys []*cbc.KeyDefinition `json:"charge_keys,omitempty" jsonschema:"title=Charge Keys"` - // PaymentMeansKeys specific for the regime that extend the original // base payment means keys. PaymentMeansKeys []*cbc.KeyDefinition `json:"payment_means_keys,omitempty" jsonschema:"title=Payment Means Keys"` @@ -116,7 +114,7 @@ type CategoryDef struct { // income. Retained bool `json:"retained,omitempty" jsonschema:"title=Retained"` - // Specific tax definitions inside this category. + // Specific tax definitions inside this category. Order is important. Rates []*RateDef `json:"rates,omitempty" jsonschema:"title=Rates"` // Extensions defines a list of extension keys that may be used or required @@ -285,7 +283,6 @@ func (r *RegimeDef) ValidateWithContext(ctx context.Context) error { validation.Field(&r.TaxIdentityTypeKeys), validation.Field(&r.IdentityKeys), validation.Field(&r.Extensions), - validation.Field(&r.ChargeKeys), validation.Field(&r.PaymentMeansKeys), validation.Field(&r.InboxKeys), validation.Field(&r.Scenarios), @@ -316,21 +313,39 @@ func (r *RegimeDef) TimeLocation() *time.Location { return loc } +type inCategoryRatesRule struct { + cat cbc.Code + keys []cbc.Key +} + +func (r *inCategoryRatesRule) Validate(value any) error { + key, ok := value.(cbc.Key) + if !ok || key == cbc.KeyEmpty { + return nil + } + for _, k := range r.keys { + if key.Has(k) { + return nil + } + } + return fmt.Errorf("'%v' not defined in '%v' category", key, r.cat) +} + // InCategoryRates is used to provide a validation rule that will -// ensure a rate key is defined inside a category. +// ensure a rate key is represented 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 { keys[i] = x.Key } - return validation.In(keys...) + return &inCategoryRatesRule{cat: cat, keys: keys} } // InCategories returns a validation rule to ensure the category code @@ -453,13 +468,20 @@ func (r *RegimeDef) ExtensionDef(key cbc.Key) *cbc.KeyDefinition { } // RateDef provides the rate definition with a matching key for -// the category. +// the category. Key comparison is made using two loops. The first +// will find an exact match, while the second will see if the provided +// key has the rate key as a prefix. func (c *CategoryDef) RateDef(key cbc.Key) *RateDef { for _, r := range c.Rates { if r.Key == key { return r } } + for _, r := range c.Rates { + if key.Has(r.Key) { + return r + } + } return nil } diff --git a/tax/regime_def_test.go b/tax/regime_def_test.go index afd18b28..2c2fd6a5 100644 --- a/tax/regime_def_test.go +++ b/tax/regime_def_test.go @@ -4,7 +4,9 @@ import ( "testing" "time" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/tax" + "github.com/invopop/validation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,3 +23,10 @@ func TestRegimeTimeLocation(t *testing.T) { loc = r.TimeLocation() assert.Equal(t, loc, time.UTC) } + +func TestRegimeInCategoryRates(t *testing.T) { + var r *tax.RegimeDef // nil regime + rate := cbc.Key("standard") + err := validation.Validate(rate, r.InCategoryRates(tax.CategoryVAT)) + assert.ErrorContains(t, err, "must be blank when regime is undefine") +} diff --git a/tax/set_test.go b/tax/set_test.go index 450ed59c..a09dd46d 100644 --- a/tax/set_test.go +++ b/tax/set_test.go @@ -114,7 +114,18 @@ func TestSetValidation(t *testing.T) { Rate: cbc.Key("invalid-tag"), }, }, - err: "rate: must be a valid value.", + err: "rate: 'invalid-tag' not defined in 'VAT' category", + }, + { + desc: "rate with extension", + set: tax.Set{ + { + Category: "VAT", + Percent: num.NewPercentage(20, 3), + Rate: tax.RateExempt.With(tax.TagReverseCharge), + }, + }, + err: nil, }, { desc: "missing percent with surcharge", 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) }