From 5bc7008741d06be284379bb9016fd1924128f6ab Mon Sep 17 00:00:00 2001 From: apardods Date: Fri, 11 Oct 2024 16:37:31 +0000 Subject: [PATCH 01/27] Initial Draft for DE Addon --- addons/de/xrechnung/invoices.go | 185 +++++++++++++++++++++++++++++++ addons/de/xrechnung/xrechnung.go | 69 ++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 addons/de/xrechnung/invoices.go create mode 100644 addons/de/xrechnung/xrechnung.go diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go new file mode 100644 index 00000000..26c9fcaa --- /dev/null +++ b/addons/de/xrechnung/invoices.go @@ -0,0 +1,185 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +func normalizeInvoice(inv *bill.Invoice) { + // Ensure payment instructions are present + if inv.Payment == nil { + inv.Payment = &bill.Payment{} + } + if inv.Payment.Instructions == nil { + inv.Payment.Instructions = &pay.Instructions{} + } + + // Ensure invoice type is valid + if !isValidInvoiceType(inv.Type) { + inv.Type = bill.InvoiceTypeStandard + } +} + +func isValidInvoiceType(t cbc.Key) bool { + validTypes := []cbc.Key{ + bill.InvoiceTypeStandard, + bill.InvoiceTypeCreditNote, + bill.InvoiceTypeCorrective, + invoiceTypeSelfBilled, + invoiceTypePartial, + } + for _, validType := range validTypes { + if t == validType { + return true + } + } + return false +} + +func validateInvoice(inv *bill.Invoice) error { + return validation.ValidateStruct(inv, + validation.Field(&inv.Type, + validation.In(bill.InvoiceTypeStandard, bill.InvoiceTypeCreditNote, bill.InvoiceTypeCorrective, invoiceTypeSelfBilled, invoiceTypePartial), + ), + validation.Field(&inv.Payment.Instructions, + validation.Required, + ), + validation.Field(&inv.Supplier, + validation.By(validateParty), + ), + validation.Field(&inv.Customer, + validation.By(validateParty), + ), + validation.Field(&inv.Delivery, + validation.When(inv.Delivery != nil, + validation.By(validateDeliveryParty), + ), + ), + ) +} + +func validateParty(value interface{}) error { + party, _ := value.(*org.Party) + if party == nil { + return nil + } + return validation.ValidateStruct(party, + validation.Field(&party.Name, + validation.Required, + ), + validation.Field(&party.Addresses, + validation.Required, + validation.Length(1, 1), + validation.Each(validation.By(validateAddress)), + ), + validation.Field(&party.People, + validation.Required, + validation.Length(1, 1), + ), + validation.Field(&party.Telephones, + validation.Required, + validation.Length(1, 1), + ), + validation.Field(&party.Emails, + validation.Required, + validation.Length(1, 1), + ), + ) +} + +func validateAddress(value interface{}) error { + addr, _ := value.(*org.Address) + if addr == nil { + return nil + } + return validation.ValidateStruct(addr, + validation.Field(&addr.Locality, + validation.Required, + ), + validation.Field(&addr.Code, + validation.Required, + ), + ) +} + +func validateDeliveryParty(value interface{}) error { + party, _ := value.(*org.Party) + if party == nil { + return nil + } + return validation.ValidateStruct(party, + validation.Field(&party.Addresses, + validation.Required, + validation.Length(1, 1), + validation.Each(validation.By(validateGermanAddress)), + ), + ) +} + +func validateGermanAddress(value interface{}) error { + addr, _ := value.(*org.Address) + if addr == nil { + return nil + } + return validation.ValidateStruct(addr, + validation.Field(&addr.Locality, + validation.Required, + ), + validation.Field(&addr.Code, + validation.Required, + ), + validation.Field(&addr.Country, + validation.In("DE"), + ), + ) +} + +func validateTaxCombo(tc *tax.Combo) error { + if tc == nil { + return nil + } + return validation.ValidateStruct(tc, + validation.Field(&tc.Category, + validation.When(tc.Category == tax.CategoryVAT, + validation.By(validateVATRate), + ), + ), + ) +} + +func validateVATRate(value interface{}) error { + rate, _ := value.(cbc.Key) + if rate == "" { + return validation.NewError("required", "VAT category rate is required") + } + return nil +} + +func validatePayInstructions(instructions *pay.Instructions) error { + return validation.ValidateStruct(instructions, + validation.Field(&instructions.CreditTransfer, + validation.When(instructions.Key == pay.MeansKeyCreditTransfer, + validation.By(validateCreditTransfer), + ), + ), + ) +} + +func validateCreditTransfer(value interface{}) error { + credit, _ := value.(*pay.CreditTransfer) + if credit == nil { + return nil + } + return nil + // return validation.ValidateStruct(credit, + // validation.Field(&credit.IBAN, + // validation.When(credit.Key == pay.MeansKeyCreditTransfer, + // validation.Required, + // ), + // ), + // ) +} diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go new file mode 100644 index 00000000..23834f1e --- /dev/null +++ b/addons/de/xrechnung/xrechnung.go @@ -0,0 +1,69 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/tax" +) + +const ( + V3 cbc.Key = "de-xrechnung-3.0.2" +) + +const ( + invoiceTypeSelfBilled cbc.Key = "389" + invoiceTypePartial cbc.Key = "326" +) + +func init() { + tax.RegisterAddonDef(newAddon()) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V3, + Name: i18n.String{ + i18n.EN: "German XRechnung 3.0.2", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Extensions to support the German XRechnung standard version 3.0.2 for electronic invoicing. + XRechnung is based on the European Norm (EN) 16931 and is mandatory for business-to-government + (B2G) invoices in Germany. This addon provides the necessary structures and validations to + ensure compliance with the XRechnung format. + + For more information on XRechnung, visit: + https://www.xrechnung.de/ + `), + }, + // Extensions: extensions, + // Identities: identities, + Normalizer: normalize, + Validator: validate, + } +} + +func normalize(doc any) { + switch obj := doc.(type) { + case *bill.Invoice: + normalizeInvoice(obj) + } +} + +func validate(doc any) error { + switch obj := doc.(type) { + case *bill.Invoice: + return validateInvoice(obj) + case *pay.Instructions: + return validatePayInstructions(obj) + case *org.Party: + return validateParty(obj) + case *tax.Combo: + return validateTaxCombo(obj) + } + return nil +} From 49d930ec2a8161a9c7aaf6e1590f0d525238ca7f Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 14 Oct 2024 09:52:58 +0000 Subject: [PATCH 02/27] Supplier and Payment Means Validation --- addons/addons.go | 1 + addons/de/xrechnung/instructions.go | 31 ++++++ addons/de/xrechnung/invoices.go | 144 +++++++++++++++++++--------- addons/de/xrechnung/xrechnung.go | 8 +- 4 files changed, 132 insertions(+), 52 deletions(-) create mode 100644 addons/de/xrechnung/instructions.go diff --git a/addons/addons.go b/addons/addons.go index 8f8cc110..73c1bf34 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -10,6 +10,7 @@ package addons import ( // Import all the addons to ensure they're ready to use. _ "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/gr/mydata" diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go new file mode 100644 index 00000000..805331ad --- /dev/null +++ b/addons/de/xrechnung/instructions.go @@ -0,0 +1,31 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/pay" + "github.com/invopop/validation" +) + +func validatePayInstructions(instructions *pay.Instructions) error { + return validation.ValidateStruct(instructions, + validation.Field(&instructions.CreditTransfer, + validation.When(instructions.Key == pay.MeansKeyCreditTransfer, + validation.By(validateCreditTransfer), + ), + ), + ) +} + +func validateCreditTransfer(value interface{}) error { + credit, _ := value.(*pay.CreditTransfer) + if credit == nil { + return nil + } + return nil + // return validation.ValidateStruct(credit, + // validation.Field(&credit.IBAN, + // validation.When(credit.Key == pay.MeansKeyCreditTransfer, + // validation.Required, + // ), + // ), + // ) +} diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 26c9fcaa..d8086fa2 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -9,21 +9,6 @@ import ( "github.com/invopop/validation" ) -func normalizeInvoice(inv *bill.Invoice) { - // Ensure payment instructions are present - if inv.Payment == nil { - inv.Payment = &bill.Payment{} - } - if inv.Payment.Instructions == nil { - inv.Payment.Instructions = &pay.Instructions{} - } - - // Ensure invoice type is valid - if !isValidInvoiceType(inv.Type) { - inv.Type = bill.InvoiceTypeStandard - } -} - func isValidInvoiceType(t cbc.Key) bool { validTypes := []cbc.Key{ bill.InvoiceTypeStandard, @@ -45,14 +30,15 @@ func validateInvoice(inv *bill.Invoice) error { validation.Field(&inv.Type, validation.In(bill.InvoiceTypeStandard, bill.InvoiceTypeCreditNote, bill.InvoiceTypeCorrective, invoiceTypeSelfBilled, invoiceTypePartial), ), + // BR-DE-01 validation.Field(&inv.Payment.Instructions, validation.Required, ), validation.Field(&inv.Supplier, - validation.By(validateParty), + validation.By(validateSupplier), ), validation.Field(&inv.Customer, - validation.By(validateParty), + validation.By(validateCustomer), ), validation.Field(&inv.Delivery, validation.When(inv.Delivery != nil, @@ -62,36 +48,48 @@ func validateInvoice(inv *bill.Invoice) error { ) } -func validateParty(value interface{}) error { +func validateSupplier(value interface{}) error { party, _ := value.(*org.Party) if party == nil { return nil } return validation.ValidateStruct(party, + // BR-DE-05 validation.Field(&party.Name, validation.Required, ), + // BR-DE-02 validation.Field(&party.Addresses, validation.Required, - validation.Length(1, 1), - validation.Each(validation.By(validateAddress)), + validation.Each(validation.By(validatePartyAddress)), ), + // BR-DE-06 validation.Field(&party.People, validation.Required, - validation.Length(1, 1), ), validation.Field(&party.Telephones, validation.Required, - validation.Length(1, 1), ), validation.Field(&party.Emails, validation.Required, - validation.Length(1, 1), ), ) } -func validateAddress(value interface{}) error { +func validateCustomer(value interface{}) error { + party, _ := value.(*org.Party) + if party == nil { + return nil + } + return validation.ValidateStruct(party, + validation.Field(&party.Addresses, + validation.Required, + validation.Each(validation.By(validatePartyAddress)), + ), + ) +} + +func validatePartyAddress(value interface{}) error { addr, _ := value.(*org.Address) if addr == nil { return nil @@ -115,26 +113,25 @@ func validateDeliveryParty(value interface{}) error { validation.Field(&party.Addresses, validation.Required, validation.Length(1, 1), - validation.Each(validation.By(validateGermanAddress)), + validation.Each(validation.By(validateDeliveryAddress)), ), ) } -func validateGermanAddress(value interface{}) error { +func validateDeliveryAddress(value interface{}) error { addr, _ := value.(*org.Address) if addr == nil { return nil } return validation.ValidateStruct(addr, + // BR-DE-10 validation.Field(&addr.Locality, validation.Required, ), + // BR-DE-11 validation.Field(&addr.Code, validation.Required, ), - validation.Field(&addr.Country, - validation.In("DE"), - ), ) } @@ -159,27 +156,84 @@ func validateVATRate(value interface{}) error { return nil } -func validatePayInstructions(instructions *pay.Instructions) error { - return validation.ValidateStruct(instructions, - validation.Field(&instructions.CreditTransfer, - validation.When(instructions.Key == pay.MeansKeyCreditTransfer, - validation.By(validateCreditTransfer), +func validatePaymentMeans(inv *bill.Invoice) error { + if inv.Payment == nil || inv.Payment.Instructions == nil { + return nil + } + + instr := inv.Payment.Instructions + return validation.ValidateStruct(instr, + validation.Field(&instr.Key, validation.Required), + validation.Field(&instr.CreditTransfer, + validation.When(instr.Key == pay.MeansKeyCreditTransfer, + validation.Required.Error("Credit transfer details are required when payment means is credit transfer"), + validation.Length(1, 1).Error("Exactly one credit transfer detail must be provided"), + validation.Each(validation.By(func(ct interface{}) error { + creditTransfer, _ := ct.(*pay.CreditTransfer) + if creditTransfer.IBAN == "" && creditTransfer.Number == "" { + return validation.NewError("required", "Either IBAN or account number must be provided for credit transfer") + } + return nil + })), + ).Else(validation.Empty), + ), + validation.Field(&instr.Card, + validation.When(instr.Key == pay.MeansKeyCard, + validation.Required.Error("Card details are required when payment means is card"), + validation.By(func(card interface{}) error { + c, _ := card.(*pay.Card) + if c == nil || (c.Last4 == "" && c.Holder == "") { + return validation.NewError("required", "Card details must include either last 4 digits or holder name") + } + return nil + }), + ).Else(validation.Nil), + ), + validation.Field(&instr.DirectDebit, + validation.When(instr.Key == pay.MeansKeyDirectDebit, + validation.Required.Error("Direct debit details are required when payment means is direct debit"), + validation.By(validateDirectDebit), + ).Else(validation.Nil), + ), + validation.Field(&instr.Online, + validation.When(instr.Key != pay.MeansKeyCreditTransfer && instr.Key != pay.MeansKeyCard && instr.Key != pay.MeansKeyDirectDebit, + validation.Empty.Error("Online payment details should not be present for this payment means"), ), ), ) } -func validateCreditTransfer(value interface{}) error { - credit, _ := value.(*pay.CreditTransfer) - if credit == nil { - return nil +func validateCorrectiveInvoice(inv *bill.Invoice) error { + if inv.Type.In(bill.InvoiceTypeCorrective) { + if inv.Preceding == nil { + return validation.NewError("required", "Preceding invoice details are required for corrective invoices") + } } return nil - // return validation.ValidateStruct(credit, - // validation.Field(&credit.IBAN, - // validation.When(credit.Key == pay.MeansKeyCreditTransfer, - // validation.Required, - // ), - // ), - // ) +} + +func validateDirectDebit(value interface{}) error { + inv, ok := value.(*bill.Invoice) + if !ok || inv == nil { + return nil + } + if inv.Payment == nil || inv.Payment.Instructions == nil || inv.Payment.Instructions.Key != pay.MeansKeyDirectDebit { + return nil + } + + dd := inv.Payment.Instructions.DirectDebit + return validation.ValidateStruct(dd, + // BR-DE-29 - Changed to Peppol-EN16931-R061 + validation.Field(&dd.Ref, + validation.Required.Error("Mandate reference is mandatory for direct debit"), + ), + // BR-DE-30 + validation.Field(&dd.Creditor, + validation.Required.Error("Creditor identifier is mandatory for direct debit"), + ), + // BR-DE-31 + validation.Field(&dd.Account, + validation.Required.Error("Debited account identifier is mandatory for direct debit"), + ), + ) } diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 23834f1e..3f259228 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -4,7 +4,6 @@ import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/org" "github.com/invopop/gobl/pay" "github.com/invopop/gobl/pkg/here" "github.com/invopop/gobl/tax" @@ -48,10 +47,7 @@ func newAddon() *tax.AddonDef { } func normalize(doc any) { - switch obj := doc.(type) { - case *bill.Invoice: - normalizeInvoice(obj) - } + // No normalizations yet } func validate(doc any) error { @@ -60,8 +56,6 @@ func validate(doc any) error { return validateInvoice(obj) case *pay.Instructions: return validatePayInstructions(obj) - case *org.Party: - return validateParty(obj) case *tax.Combo: return validateTaxCombo(obj) } From 55acc876205b63be955df448ff1293e97ea05d24 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 14 Oct 2024 11:07:20 +0000 Subject: [PATCH 03/27] Corrective Invoices, Rule References and Instructions --- addons/de/xrechnung/instructions.go | 63 ++++++++++------ addons/de/xrechnung/invoices.go | 108 ++++++++++++---------------- addons/de/xrechnung/xrechnung.go | 8 --- 3 files changed, 86 insertions(+), 93 deletions(-) diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index 805331ad..cea4e933 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -1,31 +1,52 @@ package xrechnung import ( + "github.com/invopop/gobl/bill" "github.com/invopop/gobl/pay" "github.com/invopop/validation" ) -func validatePayInstructions(instructions *pay.Instructions) error { - return validation.ValidateStruct(instructions, - validation.Field(&instructions.CreditTransfer, - validation.When(instructions.Key == pay.MeansKeyCreditTransfer, - validation.By(validateCreditTransfer), - ), - ), - ) -} - -func validateCreditTransfer(value interface{}) error { - credit, _ := value.(*pay.CreditTransfer) - if credit == nil { +func validatePaymentInstructions(value interface{}) error { + inv, ok := value.(*bill.Invoice) + if !ok || inv == nil { + return nil + } + if inv.Payment == nil || inv.Payment.Instructions == nil { return nil } - return nil - // return validation.ValidateStruct(credit, - // validation.Field(&credit.IBAN, - // validation.When(credit.Key == pay.MeansKeyCreditTransfer, - // validation.Required, - // ), - // ), - // ) + + instr := inv.Payment.Instructions + return validation.ValidateStruct(instr, + validation.Field(&instr.Key, validation.Required), + validation.Field(&instr.CreditTransfer, + validation.When(instr.Key == pay.MeansKeyCreditTransfer, + validation.Required.Error("Credit transfer details are required when payment means is credit transfer"), + validation.Each(validation.By(func(ct interface{}) error { + creditTransfer, _ := ct.(*pay.CreditTransfer) + if creditTransfer.IBAN == "" && creditTransfer.Number == "" { + return validation.NewError("required", "Either IBAN or account number must be provided for credit transfer") + } + return nil + })), + ).Else(validation.Empty), + ), + validation.Field(&instr.Card, + validation.When(instr.Key == pay.MeansKeyCard, + validation.Required.Error("Card details are required when payment means is card"), + validation.By(func(card interface{}) error { + c, _ := card.(*pay.Card) + if c == nil || (c.Last4 == "" && c.Holder == "") { + return validation.NewError("required", "Card details must include either last 4 digits or holder name") + } + return nil + }), + ).Else(validation.Nil), + ), + validation.Field(&instr.DirectDebit, + validation.When(instr.Key == pay.MeansKeyDirectDebit, + validation.Required.Error("Direct debit details are required when payment means is direct debit"), + validation.By(validateDirectDebit), + ).Else(validation.Nil), + ), + ) } diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index d8086fa2..f0a642e4 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -9,30 +9,35 @@ import ( "github.com/invopop/validation" ) -func isValidInvoiceType(t cbc.Key) bool { - validTypes := []cbc.Key{ - bill.InvoiceTypeStandard, - bill.InvoiceTypeCreditNote, - bill.InvoiceTypeCorrective, - invoiceTypeSelfBilled, - invoiceTypePartial, - } - for _, validType := range validTypes { - if t == validType { - return true - } - } - return false +const ( + invoiceTypeSelfBilled cbc.Key = "389" + invoiceTypePartial cbc.Key = "326" + invoiceTypePartialConstruction cbc.Key = "875" + invoiceTypePartialFinalConstruction cbc.Key = "876" + invoiceTypeFinalConstruction cbc.Key = "877" +) + +var validTypes = []cbc.Key{ + bill.InvoiceTypeStandard, + bill.InvoiceTypeCreditNote, + bill.InvoiceTypeCorrective, + invoiceTypeSelfBilled, + invoiceTypePartial, + invoiceTypePartialConstruction, + invoiceTypePartialFinalConstruction, + invoiceTypeFinalConstruction, } func validateInvoice(inv *bill.Invoice) error { return validation.ValidateStruct(inv, + // BR-DE-17 validation.Field(&inv.Type, - validation.In(bill.InvoiceTypeStandard, bill.InvoiceTypeCreditNote, bill.InvoiceTypeCorrective, invoiceTypeSelfBilled, invoiceTypePartial), + validation.By(validateInvoiceType), ), // BR-DE-01 validation.Field(&inv.Payment.Instructions, validation.Required, + validation.By(validatePaymentInstructions), ), validation.Field(&inv.Supplier, validation.By(validateSupplier), @@ -45,20 +50,35 @@ func validateInvoice(inv *bill.Invoice) error { validation.By(validateDeliveryParty), ), ), + // BR-DE-26 + validation.Field(&inv, + validation.By(validateCorrectiveInvoice), + ), ) } +func validateInvoiceType(value interface{}) error { + t, ok := value.(cbc.Key) + if !ok { + return validation.NewError("type", "Invalid invoice type") + } + if t.In(validTypes...) { + return nil + } + return validation.NewError("invalid", "Invalid invoice type") +} + func validateSupplier(value interface{}) error { party, _ := value.(*org.Party) if party == nil { return nil } return validation.ValidateStruct(party, - // BR-DE-05 + // BR-DE-02 validation.Field(&party.Name, validation.Required, ), - // BR-DE-02 + // BR-DE-03, BR-DE-04 validation.Field(&party.Addresses, validation.Required, validation.Each(validation.By(validatePartyAddress)), @@ -67,9 +87,11 @@ func validateSupplier(value interface{}) error { validation.Field(&party.People, validation.Required, ), + // BR-DE-05 validation.Field(&party.Telephones, validation.Required, ), + // BR-DE-07 validation.Field(&party.Emails, validation.Required, ), @@ -82,6 +104,7 @@ func validateCustomer(value interface{}) error { return nil } return validation.ValidateStruct(party, + // BR-DE-08, BR-DE-09 validation.Field(&party.Addresses, validation.Required, validation.Each(validation.By(validatePartyAddress)), @@ -112,7 +135,6 @@ func validateDeliveryParty(value interface{}) error { return validation.ValidateStruct(party, validation.Field(&party.Addresses, validation.Required, - validation.Length(1, 1), validation.Each(validation.By(validateDeliveryAddress)), ), ) @@ -148,6 +170,7 @@ func validateTaxCombo(tc *tax.Combo) error { ) } +// BR-DE-14 func validateVATRate(value interface{}) error { rate, _ := value.(cbc.Key) if rate == "" { @@ -156,54 +179,11 @@ func validateVATRate(value interface{}) error { return nil } -func validatePaymentMeans(inv *bill.Invoice) error { - if inv.Payment == nil || inv.Payment.Instructions == nil { +func validateCorrectiveInvoice(value interface{}) error { + inv, ok := value.(*bill.Invoice) + if !ok || inv == nil { return nil } - - instr := inv.Payment.Instructions - return validation.ValidateStruct(instr, - validation.Field(&instr.Key, validation.Required), - validation.Field(&instr.CreditTransfer, - validation.When(instr.Key == pay.MeansKeyCreditTransfer, - validation.Required.Error("Credit transfer details are required when payment means is credit transfer"), - validation.Length(1, 1).Error("Exactly one credit transfer detail must be provided"), - validation.Each(validation.By(func(ct interface{}) error { - creditTransfer, _ := ct.(*pay.CreditTransfer) - if creditTransfer.IBAN == "" && creditTransfer.Number == "" { - return validation.NewError("required", "Either IBAN or account number must be provided for credit transfer") - } - return nil - })), - ).Else(validation.Empty), - ), - validation.Field(&instr.Card, - validation.When(instr.Key == pay.MeansKeyCard, - validation.Required.Error("Card details are required when payment means is card"), - validation.By(func(card interface{}) error { - c, _ := card.(*pay.Card) - if c == nil || (c.Last4 == "" && c.Holder == "") { - return validation.NewError("required", "Card details must include either last 4 digits or holder name") - } - return nil - }), - ).Else(validation.Nil), - ), - validation.Field(&instr.DirectDebit, - validation.When(instr.Key == pay.MeansKeyDirectDebit, - validation.Required.Error("Direct debit details are required when payment means is direct debit"), - validation.By(validateDirectDebit), - ).Else(validation.Nil), - ), - validation.Field(&instr.Online, - validation.When(instr.Key != pay.MeansKeyCreditTransfer && instr.Key != pay.MeansKeyCard && instr.Key != pay.MeansKeyDirectDebit, - validation.Empty.Error("Online payment details should not be present for this payment means"), - ), - ), - ) -} - -func validateCorrectiveInvoice(inv *bill.Invoice) error { if inv.Type.In(bill.InvoiceTypeCorrective) { if inv.Preceding == nil { return validation.NewError("required", "Preceding invoice details are required for corrective invoices") diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 3f259228..8b6237a2 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -4,7 +4,6 @@ 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" ) @@ -13,11 +12,6 @@ const ( V3 cbc.Key = "de-xrechnung-3.0.2" ) -const ( - invoiceTypeSelfBilled cbc.Key = "389" - invoiceTypePartial cbc.Key = "326" -) - func init() { tax.RegisterAddonDef(newAddon()) } @@ -54,8 +48,6 @@ func validate(doc any) error { switch obj := doc.(type) { case *bill.Invoice: return validateInvoice(obj) - case *pay.Instructions: - return validatePayInstructions(obj) case *tax.Combo: return validateTaxCombo(obj) } From 6e03fb7c9f5650a20f948f44e93f57cd86a8ee28 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 14 Oct 2024 14:33:04 +0000 Subject: [PATCH 04/27] Credit transfer & Direct debit instructions --- addons/de/xrechnung/instructions.go | 82 ++++++++++++++++++++--------- addons/de/xrechnung/invoices.go | 41 ++++----------- 2 files changed, 67 insertions(+), 56 deletions(-) diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index cea4e933..26d733e1 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -2,51 +2,85 @@ package xrechnung import ( "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/invopop/validation" ) +const ( + keyPaymentMeansSEPACreditTransfer cbc.Key = "sepa-credit-transfer" + keyPaymentMeansSEPADirectDebit cbc.Key = "sepa-direct-debit" +) + func validatePaymentInstructions(value interface{}) error { inv, ok := value.(*bill.Invoice) - if !ok || inv == nil { - return nil - } - if inv.Payment == nil || inv.Payment.Instructions == nil { + if !ok || inv == nil || inv.Payment == nil || inv.Payment.Instructions == nil { return nil } instr := inv.Payment.Instructions return validation.ValidateStruct(instr, validation.Field(&instr.Key, validation.Required), + // BR-DE-23 validation.Field(&instr.CreditTransfer, - validation.When(instr.Key == pay.MeansKeyCreditTransfer, - validation.Required.Error("Credit transfer details are required when payment means is credit transfer"), - validation.Each(validation.By(func(ct interface{}) error { - creditTransfer, _ := ct.(*pay.CreditTransfer) - if creditTransfer.IBAN == "" && creditTransfer.Number == "" { - return validation.NewError("required", "Either IBAN or account number must be provided for credit transfer") - } - return nil - })), - ).Else(validation.Empty), + validation.When(instr.Key == keyPaymentMeansSEPACreditTransfer, + validation.Required, + validation.By(validateCreditTransfer), + ).Else(validation.Nil), ), + // BR-DE-24 validation.Field(&instr.Card, validation.When(instr.Key == pay.MeansKeyCard, - validation.Required.Error("Card details are required when payment means is card"), - validation.By(func(card interface{}) error { - c, _ := card.(*pay.Card) - if c == nil || (c.Last4 == "" && c.Holder == "") { - return validation.NewError("required", "Card details must include either last 4 digits or holder name") - } - return nil - }), + validation.Required, ).Else(validation.Nil), ), + // BR-DE-25 validation.Field(&instr.DirectDebit, - validation.When(instr.Key == pay.MeansKeyDirectDebit, - validation.Required.Error("Direct debit details are required when payment means is direct debit"), + validation.When(instr.Key == keyPaymentMeansSEPADirectDebit || instr.Key == pay.MeansKeyDirectDebit, + validation.Required, validation.By(validateDirectDebit), ).Else(validation.Nil), ), ) } + +func validateDirectDebit(value interface{}) error { + inv, ok := value.(*bill.Invoice) + if !ok || inv == nil { + return nil + } + if inv.Payment == nil || inv.Payment.Instructions == nil || inv.Payment.Instructions.Key != pay.MeansKeyDirectDebit { + return nil + } + + dd := inv.Payment.Instructions.DirectDebit + return validation.ValidateStruct(dd, + // BR-DE-29 - Changed to Peppol-EN16931-R061 + validation.Field(&dd.Ref, + validation.Required.Error("Mandate reference is mandatory for direct debit"), + ), + // BR-DE-30 + validation.Field(&dd.Creditor, + validation.Required.Error("Creditor identifier is mandatory for direct debit"), + ), + // BR-DE-31 + validation.Field(&dd.Account, + validation.Required.Error("Debited account identifier is mandatory for direct debit"), + ), + ) +} + +// BR-DE-19 +func validateCreditTransfer(value interface{}) error { + creditTransfer, _ := value.(*pay.CreditTransfer) + if creditTransfer == nil { + return nil + } + return validation.ValidateStruct(creditTransfer, + validation.Field(&creditTransfer.Number, + validation.When(creditTransfer.IBAN == "", + validation.Required.Error("IBAN must be provided for SEPA credit transfer"), + ), + ), + ) +} diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index f0a642e4..05b14668 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -4,17 +4,16 @@ import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" - "github.com/invopop/gobl/pay" "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) const ( - invoiceTypeSelfBilled cbc.Key = "389" - invoiceTypePartial cbc.Key = "326" - invoiceTypePartialConstruction cbc.Key = "875" - invoiceTypePartialFinalConstruction cbc.Key = "876" - invoiceTypeFinalConstruction cbc.Key = "877" + invoiceTypeSelfBilled cbc.Key = "self-billed" + invoiceTypePartial cbc.Key = "partial" + invoiceTypePartialConstruction cbc.Key = "partial-construction" + invoiceTypePartialFinalConstruction cbc.Key = "partial-final-construction" + invoiceTypeFinalConstruction cbc.Key = "final-construction" ) var validTypes = []cbc.Key{ @@ -39,6 +38,10 @@ func validateInvoice(inv *bill.Invoice) error { validation.Required, validation.By(validatePaymentInstructions), ), + // BR-DE-15 + validation.Field(&inv.Ordering.Code, + validation.Required, + ), validation.Field(&inv.Supplier, validation.By(validateSupplier), ), @@ -191,29 +194,3 @@ func validateCorrectiveInvoice(value interface{}) error { } return nil } - -func validateDirectDebit(value interface{}) error { - inv, ok := value.(*bill.Invoice) - if !ok || inv == nil { - return nil - } - if inv.Payment == nil || inv.Payment.Instructions == nil || inv.Payment.Instructions.Key != pay.MeansKeyDirectDebit { - return nil - } - - dd := inv.Payment.Instructions.DirectDebit - return validation.ValidateStruct(dd, - // BR-DE-29 - Changed to Peppol-EN16931-R061 - validation.Field(&dd.Ref, - validation.Required.Error("Mandate reference is mandatory for direct debit"), - ), - // BR-DE-30 - validation.Field(&dd.Creditor, - validation.Required.Error("Creditor identifier is mandatory for direct debit"), - ), - // BR-DE-31 - validation.Field(&dd.Account, - validation.Required.Error("Debited account identifier is mandatory for direct debit"), - ), - ) -} From 6b8c2ded474e76d3dd7e4e360caca75b7c4d17e8 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 14 Oct 2024 15:52:06 +0000 Subject: [PATCH 05/27] Cleaned code and fixes --- addons/de/xrechnung/invoices.go | 23 ----------------------- addons/de/xrechnung/tax_combo.go | 29 +++++++++++++++++++++++++++++ addons/de/xrechnung/xrechnung.go | 2 -- 3 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 addons/de/xrechnung/tax_combo.go diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 05b14668..9d8ca791 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -4,7 +4,6 @@ import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" - "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) @@ -160,28 +159,6 @@ func validateDeliveryAddress(value interface{}) error { ) } -func validateTaxCombo(tc *tax.Combo) error { - if tc == nil { - return nil - } - return validation.ValidateStruct(tc, - validation.Field(&tc.Category, - validation.When(tc.Category == tax.CategoryVAT, - validation.By(validateVATRate), - ), - ), - ) -} - -// BR-DE-14 -func validateVATRate(value interface{}) error { - rate, _ := value.(cbc.Key) - if rate == "" { - return validation.NewError("required", "VAT category rate is required") - } - return nil -} - func validateCorrectiveInvoice(value interface{}) error { inv, ok := value.(*bill.Invoice) if !ok || inv == nil { diff --git a/addons/de/xrechnung/tax_combo.go b/addons/de/xrechnung/tax_combo.go new file mode 100644 index 00000000..b28938d0 --- /dev/null +++ b/addons/de/xrechnung/tax_combo.go @@ -0,0 +1,29 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +func validateTaxCombo(tc *tax.Combo) error { + if tc == nil { + return nil + } + return validation.ValidateStruct(tc, + validation.Field(&tc.Category, + validation.When(tc.Category == tax.CategoryVAT, + validation.By(validateVATRate), + ), + ), + ) +} + +// BR-DE-14 +func validateVATRate(value interface{}) error { + rate, _ := value.(cbc.Key) + if rate == "" { + return validation.NewError("required", "VAT category rate is required") + } + return nil +} diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 8b6237a2..ce302239 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -33,8 +33,6 @@ func newAddon() *tax.AddonDef { https://www.xrechnung.de/ `), }, - // Extensions: extensions, - // Identities: identities, Normalizer: normalize, Validator: validate, } From 866684bee59f31049ad754a07156329f38a8f5c3 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 14 Oct 2024 17:59:30 +0000 Subject: [PATCH 06/27] Added Extensions and Initial Tests --- addons/de/xrechnung/extensions.go | 103 ++++++++++++++++++++++++++ addons/de/xrechnung/invoices.go | 2 +- addons/de/xrechnung/invoices_test.go | 74 ++++++++++++++++++ addons/de/xrechnung/tax_combo.go | 30 +++++++- addons/de/xrechnung/tax_combo_test.go | 29 ++++++++ addons/de/xrechnung/xrechnung.go | 10 ++- data/schemas/bill/invoice.json | 4 + 7 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 addons/de/xrechnung/extensions.go create mode 100644 addons/de/xrechnung/invoices_test.go create mode 100644 addons/de/xrechnung/tax_combo_test.go diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go new file mode 100644 index 00000000..df7048ad --- /dev/null +++ b/addons/de/xrechnung/extensions.go @@ -0,0 +1,103 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +var ( + ExtKeyPostCode cbc.Key = "de-xrechnung-post-code" + ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" +) + +var extensions = []*cbc.KeyDefinition{ + { + Key: ExtKeyPostCode, + Name: i18n.String{ + i18n.EN: "Post Code", + i18n.DE: "Postleitzahl", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Post code of a supplier or customer to use instead of an address. Example: "55000". + `), + i18n.DE: here.Doc(` + Postleitzahl eines Lieferanten oder Kunden, die anstelle einer Adresse verwendet wird. Beispiel: "55000". + `), + }, + Pattern: "^[0-9]{5}$", + }, + { + Key: ExtKeyTaxRate, + Name: i18n.String{ + i18n.EN: "Tax Rate", + i18n.DE: "Steuersatz", + }, + Values: []*cbc.ValueDefinition{ + { + Value: "S", + Name: i18n.String{ + i18n.EN: "Standard Rate", + i18n.DE: "Standardsteuersatz", + }, + }, + { + Value: "Z", + Name: i18n.String{ + i18n.EN: "Zero rated goods", + i18n.DE: "Güter mit Nullbewertung", + }, + }, + { + Value: "E", + Name: i18n.String{ + i18n.EN: "Exempt from tax", + i18n.DE: "von der Steuer befreit", + }, + }, + { + Value: "AE", + Name: i18n.String{ + i18n.EN: "VAT Reverse Charge", + i18n.DE: "Mehrwertsteuer Umkehrung der Steuerschuldnerschaft", + }, + }, + { + Value: "K", + Name: i18n.String{ + i18n.EN: "VAT exempt for EEA intra-community supply of goods and services", + i18n.DE: "Mehrwertsteuerbefreiung für innergemeinschaftliche Lieferungen von Gegenständen und Dienstleistungen im EWR", + }, + }, + { + Value: "G", + Name: i18n.String{ + i18n.EN: "Free export item, tax not charged", + i18n.DE: "Kostenlose Ausfuhrsendung, ohne Steuer", + }, + }, + { + Value: "O", + Name: i18n.String{ + i18n.EN: "Services outside scope of tax", + i18n.DE: "Dienstleistungen, die nicht unter die Steuer fallen", + }, + }, + { + Value: "L", + Name: i18n.String{ + i18n.EN: "Canary Islands general indirect tax", + i18n.DE: "Allgemeine indirekte Steuer der Kanarischen Inseln", + }, + }, + { + Value: "M", + Name: i18n.String{ + i18n.EN: "Tax for production, services and importation in Ceuta and Melilla", + i18n.DE: "Steuer auf Produktion, Dienstleistungen und Importe in Ceuta und Melilla", + }, + }, + }, + }, +} diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 9d8ca791..6d220d21 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -26,7 +26,7 @@ var validTypes = []cbc.Key{ invoiceTypeFinalConstruction, } -func validateInvoice(inv *bill.Invoice) error { +func ValidateInvoice(inv *bill.Invoice) error { return validation.ValidateStruct(inv, // BR-DE-17 validation.Field(&inv.Type, diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go new file mode 100644 index 00000000..3d06e315 --- /dev/null +++ b/addons/de/xrechnung/invoices_test.go @@ -0,0 +1,74 @@ +package xrechnung_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/de/xrechnung" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "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{ + Type: cbc.Key("standard"), + Addons: tax.WithAddons(xrechnung.V3), + Series: "A", + Customer: &org.Party{ + TaxID: &tax.Identity{ + Country: "DE", + Code: "123456789", + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 3), + Item: &org.Item{ + Name: "Cursor Subscription", + Price: num.MakeAmount(1000, 3), + }, + }, + }, + } + 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 customer tax ID", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Customer.TaxID = nil + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "customer: (tax_id: cannot be blank.)") + }) + + t.Run("with exemption reason", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Lines[0].Taxes[0].Ext = nil + assertValidationError(t, inv, "de-xrechnung-exemption: required") + }) + + t.Run("without series", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Series = "" + assertValidationError(t, inv, "series: cannot be blank") + }) +} + +func assertValidationError(t *testing.T, inv *bill.Invoice, expectedError string) { + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, expectedError) +} diff --git a/addons/de/xrechnung/tax_combo.go b/addons/de/xrechnung/tax_combo.go index b28938d0..9f01ab42 100644 --- a/addons/de/xrechnung/tax_combo.go +++ b/addons/de/xrechnung/tax_combo.go @@ -6,7 +6,35 @@ import ( "github.com/invopop/validation" ) -func validateTaxCombo(tc *tax.Combo) error { +func TaxRateExtensions() tax.Extensions { + return taxRateMap +} + +var taxRateMap = tax.Extensions{ + tax.RateStandard: "S", + tax.RateZero: "Z", + tax.RateExempt: "E", +} + +func normalizeTaxCombo(combo *tax.Combo) { + // copy the SAF-T tax rate code to the line + switch combo.Category { + case tax.CategoryVAT: + if combo.Rate.IsEmpty() { + return + } + k, ok := taxRateMap[combo.Rate] + if !ok { + return + } + if combo.Ext == nil { + combo.Ext = make(tax.Extensions) + } + combo.Ext[ExtKeyTaxRate] = k + } +} + +func ValidateTaxCombo(tc *tax.Combo) error { if tc == nil { return nil } diff --git a/addons/de/xrechnung/tax_combo_test.go b/addons/de/xrechnung/tax_combo_test.go new file mode 100644 index 00000000..e2fc5038 --- /dev/null +++ b/addons/de/xrechnung/tax_combo_test.go @@ -0,0 +1,29 @@ +package xrechnung_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/de/xrechnung" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestTaxComboValidation(t *testing.T) { + t.Run("standard VAT rate", func(t *testing.T) { + combo := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + } + assert.NoError(t, xrechnung.ValidateTaxCombo(combo)) + assert.Equal(t, "S", combo.Ext["de-xrechnung-tax-rate"]) + }) + + t.Run("missing rate", func(t *testing.T) { + combo := &tax.Combo{ + Category: tax.CategoryVAT, + } + err := xrechnung.ValidateTaxCombo(combo) + assert.EqualError(t, err, "VAT category rate is required") + }) + +} diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index ce302239..5a10f6a1 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -33,21 +33,25 @@ func newAddon() *tax.AddonDef { https://www.xrechnung.de/ `), }, + Extensions: extensions, Normalizer: normalize, Validator: validate, } } func normalize(doc any) { - // No normalizations yet + switch obj := doc.(type) { + case *tax.Combo: + normalizeTaxCombo(obj) + } } func validate(doc any) error { switch obj := doc.(type) { case *bill.Invoice: - return validateInvoice(obj) + return ValidateInvoice(obj) case *tax.Combo: - return validateTaxCombo(obj) + return ValidateTaxCombo(obj) } return nil } diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index 50122c1e..b0bed939 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -252,6 +252,10 @@ "const": "co-dian-v2", "title": "Colombia DIAN UBL 2.X" }, + { + "const": "de-xrechnung-v3", + "title": "Germany XRechnung v3.x" + }, { "const": "es-facturae-v3", "title": "Spain FacturaE" From 04d1a8e6ae155bf9841ba84f1432661e2b7d03a8 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 14 Oct 2024 18:21:00 +0000 Subject: [PATCH 07/27] Added Extensions and Initial Tests --- addons/de/xrechnung/invoices_test.go | 59 ++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go index 3d06e315..c18d938a 100644 --- a/addons/de/xrechnung/invoices_test.go +++ b/addons/de/xrechnung/invoices_test.go @@ -5,7 +5,9 @@ import ( "github.com/invopop/gobl/addons/de/xrechnung" "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/cal" + + // "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" @@ -17,13 +19,49 @@ import ( func testInvoiceStandard(t *testing.T) *bill.Invoice { t.Helper() inv := &bill.Invoice{ - Type: cbc.Key("standard"), - Addons: tax.WithAddons(xrechnung.V3), - Series: "A", + Addons: tax.WithAddons(xrechnung.V3), + IssueDate: cal.MakeDate(2024, 1, 1), + Currency: "EUR", + Series: "A", + Code: "1000", + Supplier: &org.Party{ + Name: "Cursor AG", + TaxID: &tax.Identity{ + Country: "DE", + Code: "82741168", + }, + 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: "123456789", + Code: "111111125", + }, + Addresses: []*org.Address{ + { + Street: "Werner-Heisenberg-Allee", + Locality: "München", + Code: "80939", + Country: "DE", + }, }, }, Lines: []*bill.Line{ @@ -54,17 +92,6 @@ func TestInvoiceValidation(t *testing.T) { assert.ErrorContains(t, err, "customer: (tax_id: cannot be blank.)") }) - t.Run("with exemption reason", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Lines[0].Taxes[0].Ext = nil - assertValidationError(t, inv, "de-xrechnung-exemption: required") - }) - - t.Run("without series", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Series = "" - assertValidationError(t, inv, "series: cannot be blank") - }) } func assertValidationError(t *testing.T, inv *bill.Invoice, expectedError string) { From a083cabac6d3b99c13aaea85ad437906cde4406a Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 15 Oct 2024 08:33:40 +0000 Subject: [PATCH 08/27] Tax Combo tests --- addons/de/xrechnung/invoices.go | 32 +++++++++++----------- addons/de/xrechnung/invoices_test.go | 38 ++++++++++++++++++++------- addons/de/xrechnung/tax_combo.go | 18 +++---------- addons/de/xrechnung/tax_combo_test.go | 18 ++++++++----- addons/de/xrechnung/xrechnung.go | 2 +- 5 files changed, 62 insertions(+), 46 deletions(-) diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 6d220d21..d65a10b2 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -71,43 +71,43 @@ func validateInvoiceType(value interface{}) error { } func validateSupplier(value interface{}) error { - party, _ := value.(*org.Party) - if party == nil { + p, _ := value.(*org.Party) + if p == nil { return nil } - return validation.ValidateStruct(party, + return validation.ValidateStruct(p, // BR-DE-02 - validation.Field(&party.Name, + validation.Field(&p.Name, validation.Required, ), // BR-DE-03, BR-DE-04 - validation.Field(&party.Addresses, + validation.Field(&p.Addresses, validation.Required, validation.Each(validation.By(validatePartyAddress)), ), // BR-DE-06 - validation.Field(&party.People, + validation.Field(&p.People, validation.Required, ), // BR-DE-05 - validation.Field(&party.Telephones, + validation.Field(&p.Telephones, validation.Required, ), // BR-DE-07 - validation.Field(&party.Emails, + validation.Field(&p.Emails, validation.Required, ), ) } func validateCustomer(value interface{}) error { - party, _ := value.(*org.Party) - if party == nil { + p, _ := value.(*org.Party) + if p == nil { return nil } - return validation.ValidateStruct(party, + return validation.ValidateStruct(p, // BR-DE-08, BR-DE-09 - validation.Field(&party.Addresses, + validation.Field(&p.Addresses, validation.Required, validation.Each(validation.By(validatePartyAddress)), ), @@ -130,12 +130,12 @@ func validatePartyAddress(value interface{}) error { } func validateDeliveryParty(value interface{}) error { - party, _ := value.(*org.Party) - if party == nil { + p, _ := value.(*org.Party) + if p == nil { return nil } - return validation.ValidateStruct(party, - validation.Field(&party.Addresses, + return validation.ValidateStruct(p, + validation.Field(&p.Addresses, validation.Required, validation.Each(validation.By(validateDeliveryAddress)), ), diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go index c18d938a..e06843b4 100644 --- a/addons/de/xrechnung/invoices_test.go +++ b/addons/de/xrechnung/invoices_test.go @@ -1,6 +1,8 @@ package xrechnung_test import ( + "encoding/json" + "fmt" "testing" "github.com/invopop/gobl/addons/de/xrechnung" @@ -10,6 +12,7 @@ import ( // "github.com/invopop/gobl/cbc" "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" @@ -21,14 +24,15 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { inv := &bill.Invoice{ Addons: tax.WithAddons(xrechnung.V3), IssueDate: cal.MakeDate(2024, 1, 1), + Type: "standard", Currency: "EUR", - Series: "A", + Series: "2024", Code: "1000", Supplier: &org.Party{ Name: "Cursor AG", TaxID: &tax.Identity{ Country: "DE", - Code: "82741168", + Code: "505898911", }, Addresses: []*org.Address{ { @@ -53,7 +57,7 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { Name: "Sample Consumer", TaxID: &tax.Identity{ Country: "DE", - Code: "111111125", + Code: "449674701", }, Addresses: []*org.Address{ { @@ -71,6 +75,25 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { Name: "Cursor Subscription", Price: num.MakeAmount(1000, 3), }, + Taxes: tax.Set{ + { + Category: "VAT", + Rate: "standard", + }, + }, + }, + }, + Ordering: &bill.Ordering{ + Code: "1234567890", + }, + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: "credit-transfer", + CreditTransfer: []*pay.CreditTransfer{ + { + IBAN: "DE89370400440532013000", + }, + }, }, }, } @@ -81,6 +104,9 @@ func TestInvoiceValidation(t *testing.T) { t.Run("standard invoice", func(t *testing.T) { inv := testInvoiceStandard(t) require.NoError(t, inv.Calculate()) + invJSON, err := json.MarshalIndent(inv, "", " ") + require.NoError(t, err) + fmt.Println(string(invJSON)) require.NoError(t, inv.Validate()) }) @@ -93,9 +119,3 @@ func TestInvoiceValidation(t *testing.T) { }) } - -func assertValidationError(t *testing.T, inv *bill.Invoice, expectedError string) { - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, expectedError) -} diff --git a/addons/de/xrechnung/tax_combo.go b/addons/de/xrechnung/tax_combo.go index 9f01ab42..4ce42873 100644 --- a/addons/de/xrechnung/tax_combo.go +++ b/addons/de/xrechnung/tax_combo.go @@ -1,7 +1,6 @@ package xrechnung import ( - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) @@ -16,7 +15,7 @@ var taxRateMap = tax.Extensions{ tax.RateExempt: "E", } -func normalizeTaxCombo(combo *tax.Combo) { +func NormalizeTaxCombo(combo *tax.Combo) { // copy the SAF-T tax rate code to the line switch combo.Category { case tax.CategoryVAT: @@ -38,20 +37,11 @@ func ValidateTaxCombo(tc *tax.Combo) error { if tc == nil { return nil } + // BR-DE-14: Percentage required for VAT return validation.ValidateStruct(tc, - validation.Field(&tc.Category, + validation.Field(&tc.Percent, validation.When(tc.Category == tax.CategoryVAT, - validation.By(validateVATRate), - ), + validation.Required), ), ) } - -// BR-DE-14 -func validateVATRate(value interface{}) error { - rate, _ := value.(cbc.Key) - if rate == "" { - return validation.NewError("required", "VAT category rate is required") - } - return nil -} diff --git a/addons/de/xrechnung/tax_combo_test.go b/addons/de/xrechnung/tax_combo_test.go index e2fc5038..48601f19 100644 --- a/addons/de/xrechnung/tax_combo_test.go +++ b/addons/de/xrechnung/tax_combo_test.go @@ -4,26 +4,32 @@ import ( "testing" "github.com/invopop/gobl/addons/de/xrechnung" + "github.com/invopop/gobl/num" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" ) func TestTaxComboValidation(t *testing.T) { t.Run("standard VAT rate", func(t *testing.T) { - combo := &tax.Combo{ + p := num.MakePercentage(19, 2) + c := &tax.Combo{ Category: tax.CategoryVAT, Rate: tax.RateStandard, + Percent: &p, } - assert.NoError(t, xrechnung.ValidateTaxCombo(combo)) - assert.Equal(t, "S", combo.Ext["de-xrechnung-tax-rate"]) + xrechnung.NormalizeTaxCombo(c) + assert.NoError(t, xrechnung.ValidateTaxCombo(c)) + assert.Equal(t, "S", c.Ext[xrechnung.ExtKeyTaxRate].String()) + assert.Equal(t, "19%", c.Percent.String()) }) t.Run("missing rate", func(t *testing.T) { - combo := &tax.Combo{ + c := &tax.Combo{ Category: tax.CategoryVAT, + Rate: tax.RateStandard, } - err := xrechnung.ValidateTaxCombo(combo) - assert.EqualError(t, err, "VAT category rate is required") + err := xrechnung.ValidateTaxCombo(c) + assert.EqualError(t, err, "percent: cannot be blank.") }) } diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 5a10f6a1..bdc7a6f2 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -42,7 +42,7 @@ func newAddon() *tax.AddonDef { func normalize(doc any) { switch obj := doc.(type) { case *tax.Combo: - normalizeTaxCombo(obj) + NormalizeTaxCombo(obj) } } From 3cb34c109e4aee6087eef099c608c26809a97ade Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 15 Oct 2024 11:33:57 +0000 Subject: [PATCH 09/27] Invoice tests --- addons/de/xrechnung/extensions.go | 20 +--- addons/de/xrechnung/invoices.go | 48 +++++--- addons/de/xrechnung/invoices_test.go | 165 ++++++++++++++++++++++++--- addons/de/xrechnung/xrechnung.go | 2 +- regimes/de/invoices.go | 4 +- regimes/de/invoices_test.go | 4 +- 6 files changed, 191 insertions(+), 52 deletions(-) diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go index df7048ad..38f98d09 100644 --- a/addons/de/xrechnung/extensions.go +++ b/addons/de/xrechnung/extensions.go @@ -3,31 +3,13 @@ package xrechnung import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" ) var ( - ExtKeyPostCode cbc.Key = "de-xrechnung-post-code" - ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" + ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" ) var extensions = []*cbc.KeyDefinition{ - { - Key: ExtKeyPostCode, - Name: i18n.String{ - i18n.EN: "Post Code", - i18n.DE: "Postleitzahl", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - Post code of a supplier or customer to use instead of an address. Example: "55000". - `), - i18n.DE: here.Doc(` - Postleitzahl eines Lieferanten oder Kunden, die anstelle einer Adresse verwendet wird. Beispiel: "55000". - `), - }, - Pattern: "^[0-9]{5}$", - }, { Key: ExtKeyTaxRate, Name: i18n.String{ diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index d65a10b2..5691eca0 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -32,14 +32,34 @@ func ValidateInvoice(inv *bill.Invoice) error { validation.Field(&inv.Type, validation.By(validateInvoiceType), ), - // BR-DE-01 - validation.Field(&inv.Payment.Instructions, - validation.Required, - validation.By(validatePaymentInstructions), - ), - // BR-DE-15 - validation.Field(&inv.Ordering.Code, - validation.Required, + // BR-DE-01 (modified) + validation.Field(&inv.Payment, validation.Required), + validation.Field(&inv.Payment, + validation.By(func(value interface{}) error { + payment, ok := value.(*bill.Payment) + if !ok || payment == nil { + return validation.NewError("payment_type", "must be a valid non-empty Payment type") + } + return validation.ValidateStruct(payment, + validation.Field(&payment.Instructions, + validation.Required, + validation.By(validatePaymentInstructions), + ), + ) + }), + ), + // BR-DE-15 (modified) + validation.Field(&inv.Ordering, validation.Required), + validation.Field(&inv.Ordering, + validation.By(func(value interface{}) error { + ordering, ok := value.(*bill.Ordering) + if !ok || ordering == nil { + return validation.NewError("ordering_type", "must be a valid Ordering type") + } + return validation.ValidateStruct(ordering, + validation.Field(&ordering.Code, validation.Required), + ) + }), ), validation.Field(&inv.Supplier, validation.By(validateSupplier), @@ -53,9 +73,9 @@ func ValidateInvoice(inv *bill.Invoice) error { ), ), // BR-DE-26 - validation.Field(&inv, - validation.By(validateCorrectiveInvoice), - ), + // validation.Field(&inv, + // validation.By(validateCorrectiveInvoice), + // ), ) } @@ -64,10 +84,10 @@ func validateInvoiceType(value interface{}) error { if !ok { return validation.NewError("type", "Invalid invoice type") } - if t.In(validTypes...) { - return nil + if !t.In(validTypes...) { + return validation.NewError("invalid", "Invalid invoice type") } - return validation.NewError("invalid", "Invalid invoice type") + return nil } func validateSupplier(value interface{}) error { diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go index e06843b4..9cb420aa 100644 --- a/addons/de/xrechnung/invoices_test.go +++ b/addons/de/xrechnung/invoices_test.go @@ -9,9 +9,13 @@ import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" + // "github.com/invopop/gobl/l10n" + // "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" + + // "github.com/invopop/gobl/regimes/de" "github.com/invopop/gobl/pay" "github.com/invopop/gobl/tax" @@ -21,7 +25,9 @@ import ( func testInvoiceStandard(t *testing.T) *bill.Invoice { t.Helper() + p := num.MakePercentage(19, 2) inv := &bill.Invoice{ + Regime: tax.WithRegime("DE"), Addons: tax.WithAddons(xrechnung.V3), IssueDate: cal.MakeDate(2024, 1, 1), Type: "standard", @@ -30,9 +36,17 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { Code: "1000", Supplier: &org.Party{ Name: "Cursor AG", - TaxID: &tax.Identity{ - Country: "DE", - Code: "505898911", + // TaxID: &tax.Identity{ + // Country: "DE", + // Code: "505898911", + // }, + People: []*org.Person{ + { + Name: &org.Name{ + Given: "Peter", + Surname: "Cursorstone", + }, + }, }, Addresses: []*org.Address{ { @@ -55,9 +69,17 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { }, Customer: &org.Party{ Name: "Sample Consumer", - TaxID: &tax.Identity{ - Country: "DE", - Code: "449674701", + // TaxID: &tax.Identity{ + // Country: "DE", + // Code: "449674701", + // }, + People: []*org.Person{ + { + Name: &org.Name{ + Given: "Max", + Surname: "Musterman", + }, + }, }, Addresses: []*org.Address{ { @@ -70,15 +92,22 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { }, Lines: []*bill.Line{ { - Quantity: num.MakeAmount(1, 3), + Quantity: num.MakeAmount(10, 0), Item: &org.Item{ - Name: "Cursor Subscription", - Price: num.MakeAmount(1000, 3), + Name: "Test Item", + Price: num.MakeAmount(10000, 2), }, Taxes: tax.Set{ { Category: "VAT", - Rate: "standard", + // Rate: "standard", + Percent: &p, + }, + }, + Discounts: []*bill.LineDiscount{ + { + Reason: "Testing", + Percent: num.NewPercentage(10, 2), }, }, }, @@ -104,18 +133,126 @@ 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 invJSON, err := json.MarshalIndent(inv, "", " ") require.NoError(t, err) fmt.Println(string(invJSON)) - require.NoError(t, inv.Validate()) + + require.NoError(t, inv.Calculate()) + errr := inv.Validate() + assert.ErrorContains(t, errr, "supplier: (tax_id: cannot be blank.)") + }) + + t.Run("missing invoice type", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Type = "" + err := inv.Validate() + assert.ErrorContains(t, err, "type: cannot be blank.") + }) + + t.Run("missing payment instructions", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Payment.Instructions = nil + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "payment: (instructions: cannot be blank.).") + }) + + t.Run("missing ordering code", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Ordering.Code = "" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "ordering: (code: cannot be blank.).") + }) + + t.Run("missing supplier city", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.Addresses[0].Locality = "" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "supplier: (addresses: (0: (locality: cannot be blank.).).).") + }) + + t.Run("missing supplier postcode", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.Addresses[0].Code = "" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "supplier: (addresses: (0: (code: cannot be blank.).).).") + }) + + t.Run("missing customer city", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Customer.Addresses[0].Locality = "" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "customer: (addresses: (0: (locality: cannot be blank.).).).") + }) + + t.Run("missing customer postcode", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Customer.Addresses[0].Code = "" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "customer: (addresses: (0: (code: cannot be blank.).).).") + }) + + t.Run("missing supplier name", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.Name = "" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "supplier: (name: cannot be blank.)") }) - t.Run("missing customer tax ID", func(t *testing.T) { + t.Run("missing delivery address", func(t *testing.T) { inv := testInvoiceStandard(t) - inv.Customer.TaxID = nil + inv.Delivery = nil + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.NoError(t, err, "Delivery address should be optional") + }) + + t.Run("incomplete delivery address", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Delivery = &bill.Delivery{ + Receiver: &org.Party{ + Addresses: []*org.Address{ + { + Street: "Delivery Street", + Country: "DE", + }, + }, + }, + } + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "delivery: (party.addresses.0.locality: cannot be blank.)") + assert.ErrorContains(t, err, "delivery: (party.addresses.0.code: cannot be blank.)") + }) + + t.Run("valid delivery address", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Delivery = &bill.Delivery{ + Receiver: &org.Party{ + Addresses: []*org.Address{ + { + Street: "Delivery Street", + Locality: "Delivery City", + Code: "12345", + Country: "DE", + }, + }, + }, + } require.NoError(t, inv.Calculate()) err := inv.Validate() - assert.ErrorContains(t, err, "customer: (tax_id: cannot be blank.)") + assert.NoError(t, err, "Valid delivery address should not cause validation errors") }) } diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index bdc7a6f2..599d985d 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -9,7 +9,7 @@ import ( ) const ( - V3 cbc.Key = "de-xrechnung-3.0.2" + V3 cbc.Key = "de-xrechnung-v3" ) func init() { diff --git a/regimes/de/invoices.go b/regimes/de/invoices.go index d6c7d709..15faf7fe 100644 --- a/regimes/de/invoices.go +++ b/regimes/de/invoices.go @@ -33,14 +33,14 @@ func validateInvoiceSupplier(value any) error { !hasTaxNumber(p), tax.RequireIdentityCode, ), - validation.Skip, + // validation.Skip, ), validation.Field(&p.Identities, validation.When( !hasTaxIDCode(p), org.RequireIdentityKey(IdentityKeyTaxNumber), ), - validation.Skip, + // validation.Skip, ), ) } diff --git a/regimes/de/invoices_test.go b/regimes/de/invoices_test.go index aee7c561..ed4bf8a0 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) { From 978cd877c315ba366af7407b1676995f91d91a3d Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 15 Oct 2024 12:41:02 +0000 Subject: [PATCH 10/27] Corrective Invoice Tests --- addons/de/xrechnung/invoices.go | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 5691eca0..70352c92 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -32,7 +32,7 @@ func ValidateInvoice(inv *bill.Invoice) error { validation.Field(&inv.Type, validation.By(validateInvoiceType), ), - // BR-DE-01 (modified) + // BR-DE-01 validation.Field(&inv.Payment, validation.Required), validation.Field(&inv.Payment, validation.By(func(value interface{}) error { @@ -48,7 +48,7 @@ func ValidateInvoice(inv *bill.Invoice) error { ) }), ), - // BR-DE-15 (modified) + // BR-DE-15 validation.Field(&inv.Ordering, validation.Required), validation.Field(&inv.Ordering, validation.By(func(value interface{}) error { @@ -73,9 +73,11 @@ func ValidateInvoice(inv *bill.Invoice) error { ), ), // BR-DE-26 - // validation.Field(&inv, - // validation.By(validateCorrectiveInvoice), - // ), + validation.Field(&inv.Preceding, + validation.When(inv.Type.In(bill.InvoiceTypeCorrective), + validation.Required, + ), + ), ) } @@ -178,16 +180,3 @@ func validateDeliveryAddress(value interface{}) error { ), ) } - -func validateCorrectiveInvoice(value interface{}) error { - inv, ok := value.(*bill.Invoice) - if !ok || inv == nil { - return nil - } - if inv.Type.In(bill.InvoiceTypeCorrective) { - if inv.Preceding == nil { - return validation.NewError("required", "Preceding invoice details are required for corrective invoices") - } - } - return nil -} From 07f83d9a5e240ef1faef9739b4818d05391776de Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 15 Oct 2024 13:40:27 +0000 Subject: [PATCH 11/27] Delivery Address Tests --- addons/de/xrechnung/invoices.go | 56 ++++++++++++---------------- addons/de/xrechnung/invoices_test.go | 15 +------- 2 files changed, 24 insertions(+), 47 deletions(-) diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 70352c92..6bd97193 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -65,12 +65,10 @@ func ValidateInvoice(inv *bill.Invoice) error { validation.By(validateSupplier), ), validation.Field(&inv.Customer, - validation.By(validateCustomer), + validation.By(validateCustomerReceiver), ), validation.Field(&inv.Delivery, - validation.When(inv.Delivery != nil, - validation.By(validateDeliveryParty), - ), + validation.By(validateDelivery), ), // BR-DE-26 validation.Field(&inv.Preceding, @@ -122,59 +120,51 @@ func validateSupplier(value interface{}) error { ) } -func validateCustomer(value interface{}) error { - p, _ := value.(*org.Party) - if p == nil { +func validateDelivery(value interface{}) error { + d, _ := value.(*bill.Delivery) + if d == nil { return nil } - return validation.ValidateStruct(p, - // BR-DE-08, BR-DE-09 - validation.Field(&p.Addresses, - validation.Required, - validation.Each(validation.By(validatePartyAddress)), - ), + return validation.ValidateStruct(d, + validation.Field(&d.Receiver, + validation.By(validateCustomerReceiver)), ) } -func validatePartyAddress(value interface{}) error { - addr, _ := value.(*org.Address) - if addr == nil { - return nil - } - return validation.ValidateStruct(addr, - validation.Field(&addr.Locality, - validation.Required, - ), - validation.Field(&addr.Code, - validation.Required, - ), - ) -} +// func validateDeliveryParty(value interface{}) error { +// d, _ := value.(*bill.Delivery) +// if d == nil { +// return nil +// } +// return validation.ValidateStruct(d, +// validation.Field(&d.Receiver, +// validation.Required, +// validation.By(validateCustomerReceiver)), +// ) +// } -func validateDeliveryParty(value interface{}) error { +func validateCustomerReceiver(value interface{}) error { p, _ := value.(*org.Party) if p == nil { return nil } return validation.ValidateStruct(p, + // BR-DE-08, BR-DE-09 validation.Field(&p.Addresses, validation.Required, - validation.Each(validation.By(validateDeliveryAddress)), + validation.Each(validation.By(validatePartyAddress)), ), ) } - -func validateDeliveryAddress(value interface{}) error { +func validatePartyAddress(value interface{}) error { addr, _ := value.(*org.Address) if addr == nil { return nil } return validation.ValidateStruct(addr, - // BR-DE-10 validation.Field(&addr.Locality, validation.Required, ), - // BR-DE-11 validation.Field(&addr.Code, validation.Required, ), diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go index 9cb420aa..11911fee 100644 --- a/addons/de/xrechnung/invoices_test.go +++ b/addons/de/xrechnung/invoices_test.go @@ -1,21 +1,13 @@ package xrechnung_test import ( - "encoding/json" - "fmt" "testing" "github.com/invopop/gobl/addons/de/xrechnung" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" - - // "github.com/invopop/gobl/l10n" - - // "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" - - // "github.com/invopop/gobl/regimes/de" "github.com/invopop/gobl/pay" "github.com/invopop/gobl/tax" @@ -138,10 +130,6 @@ func TestInvoiceValidation(t *testing.T) { t.Run("missing supplier tax ID", func(t *testing.T) { inv := testInvoiceStandard(t) inv.Supplier.TaxID = nil - invJSON, err := json.MarshalIndent(inv, "", " ") - require.NoError(t, err) - fmt.Println(string(invJSON)) - require.NoError(t, inv.Calculate()) errr := inv.Validate() assert.ErrorContains(t, errr, "supplier: (tax_id: cannot be blank.)") @@ -232,8 +220,7 @@ func TestInvoiceValidation(t *testing.T) { } require.NoError(t, inv.Calculate()) err := inv.Validate() - assert.ErrorContains(t, err, "delivery: (party.addresses.0.locality: cannot be blank.)") - assert.ErrorContains(t, err, "delivery: (party.addresses.0.code: cannot be blank.)") + assert.ErrorContains(t, err, "delivery: (receiver: (addresses: (0: (code: cannot be blank; locality: cannot be blank.).).).).") }) t.Run("valid delivery address", func(t *testing.T) { From c89eba7bb91247d739103b08f663e955494eaeac Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 15 Oct 2024 14:22:15 +0000 Subject: [PATCH 12/27] Tax ID/Number Validation and Tests --- addons/de/xrechnung/invoices.go | 67 +++++++++++++++++++++++----- addons/de/xrechnung/invoices_test.go | 31 +++++++++---- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 6bd97193..29999b15 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -64,6 +64,9 @@ func ValidateInvoice(inv *bill.Invoice) error { validation.Field(&inv.Supplier, validation.By(validateSupplier), ), + validation.Field(&inv.Supplier, + validation.By(validateSellerTaxInfo), + ), validation.Field(&inv.Customer, validation.By(validateCustomerReceiver), ), @@ -120,6 +123,56 @@ func validateSupplier(value interface{}) error { ) } +// func validateSellerTaxInfo(value interface{}) error { +// supplier, ok := value.(*org.Party) +// if !ok || supplier == nil { +// return validation.NewError("invalid_supplier", "Supplier is invalid or nil") +// } +// hasVATIdentifier := supplier.TaxID != nil && supplier.TaxID.Code != "" +// hasTaxIdentifier := org.IdentityForKey(supplier.Identities, "de-tax-number") != nil + +// if !hasVATIdentifier && !hasTaxIdentifier { +// return validation.NewError( +// "missing_seller_tax_info", +// "Either Seller VAT identifier or Seller tax identifier must be provided", +// ) +// } + +// return nil +// } + +func validateSellerTaxInfo(value interface{}) error { + supplier, ok := value.(*org.Party) + if !ok || supplier == nil { + return validation.NewError("invalid_supplier", "Supplier is invalid or nil") + } + + return validation.ValidateStruct(supplier, + validation.Field(&supplier.TaxID, + validation.When(supplier.Identities == nil || org.IdentityForKey(supplier.Identities, "de-tax-number") == nil, + validation.Required, + ), + ), + validation.Field(&supplier.Identities, + validation.When(supplier.TaxID == nil || supplier.TaxID.Code == "", + validation.Required, + validation.By(validateTaxNumber), + ), + ), + ) +} + +func validateTaxNumber(value interface{}) error { + identities, ok := value.([]*org.Identity) + if !ok { + return validation.NewError("invalid_identities", "Identities are invalid") + } + if org.IdentityForKey(identities, "de-tax-number") == nil { + return validation.NewError("missing_tax_identifier", "German tax identifier (de-tax-number) is required") + } + return nil +} + func validateDelivery(value interface{}) error { d, _ := value.(*bill.Delivery) if d == nil { @@ -131,18 +184,8 @@ func validateDelivery(value interface{}) error { ) } -// func validateDeliveryParty(value interface{}) error { -// d, _ := value.(*bill.Delivery) -// if d == nil { -// return nil -// } -// return validation.ValidateStruct(d, -// validation.Field(&d.Receiver, -// validation.Required, -// validation.By(validateCustomerReceiver)), -// ) -// } - +// As the fields for customer and delivery reciver have the same requirements +// they are handled by the same validation function. func validateCustomerReceiver(value interface{}) error { p, _ := value.(*org.Party) if p == nil { diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go index 11911fee..906de81a 100644 --- a/addons/de/xrechnung/invoices_test.go +++ b/addons/de/xrechnung/invoices_test.go @@ -28,10 +28,10 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { Code: "1000", Supplier: &org.Party{ Name: "Cursor AG", - // TaxID: &tax.Identity{ - // Country: "DE", - // Code: "505898911", - // }, + TaxID: &tax.Identity{ + Country: "DE", + Code: "505898911", + }, People: []*org.Person{ { Name: &org.Name{ @@ -61,10 +61,10 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { }, Customer: &org.Party{ Name: "Sample Consumer", - // TaxID: &tax.Identity{ - // Country: "DE", - // Code: "449674701", - // }, + TaxID: &tax.Identity{ + Country: "DE", + Code: "449674701", + }, People: []*org.Person{ { Name: &org.Name{ @@ -132,7 +132,20 @@ func TestInvoiceValidation(t *testing.T) { inv.Supplier.TaxID = nil require.NoError(t, inv.Calculate()) errr := inv.Validate() - assert.ErrorContains(t, errr, "supplier: (tax_id: cannot be blank.)") + assert.ErrorContains(t, errr, "supplier: (identities: cannot be blank; tax_id: cannot be blank.).") + }) + t.Run("missing supplier tax ID but has tax number", func(t *testing.T) { + 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) }) t.Run("missing invoice type", func(t *testing.T) { From be4ded1d53fca137017cf5753fc4badc5772846a Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 15 Oct 2024 15:39:31 +0000 Subject: [PATCH 13/27] Instruction Tests --- addons/de/xrechnung/instructions.go | 55 +++++++++---- addons/de/xrechnung/instructions_test.go | 100 +++++++++++++++++++++++ addons/de/xrechnung/invoices.go | 24 +----- 3 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 addons/de/xrechnung/instructions_test.go diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index 26d733e1..762e6916 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -8,52 +8,71 @@ import ( ) const ( - keyPaymentMeansSEPACreditTransfer cbc.Key = "sepa-credit-transfer" - keyPaymentMeansSEPADirectDebit cbc.Key = "sepa-direct-debit" + KeyPaymentMeansSEPACreditTransfer cbc.Key = "sepa-credit-transfer" + KeyPaymentMeansSEPADirectDebit cbc.Key = "sepa-direct-debit" ) -func validatePaymentInstructions(value interface{}) error { +var validPaymentKeys = []cbc.Key{ + pay.MeansKeyCash, + pay.MeansKeyCheque, + pay.MeansKeyCreditTransfer, + pay.MeansKeyCard, + pay.MeansKeyDirectDebit, + pay.MeansKeyOther, + KeyPaymentMeansSEPACreditTransfer, + KeyPaymentMeansSEPADirectDebit, +} + +func ValidatePaymentInstructions(value interface{}) error { inv, ok := value.(*bill.Invoice) if !ok || inv == nil || inv.Payment == nil || inv.Payment.Instructions == nil { return nil } - instr := inv.Payment.Instructions return validation.ValidateStruct(instr, - validation.Field(&instr.Key, validation.Required), + validation.Field(&instr.Key, + validation.Required, + validation.By(validatePaymentKey), + ), // BR-DE-23 validation.Field(&instr.CreditTransfer, - validation.When(instr.Key == keyPaymentMeansSEPACreditTransfer, + validation.When(instr.Key == KeyPaymentMeansSEPACreditTransfer, validation.Required, - validation.By(validateCreditTransfer), - ).Else(validation.Nil), + validation.Each(validation.By(validateCreditTransfer)), + ), ), // BR-DE-24 validation.Field(&instr.Card, validation.When(instr.Key == pay.MeansKeyCard, validation.Required, - ).Else(validation.Nil), + ), ), // BR-DE-25 validation.Field(&instr.DirectDebit, - validation.When(instr.Key == keyPaymentMeansSEPADirectDebit || instr.Key == pay.MeansKeyDirectDebit, + validation.When(instr.Key == KeyPaymentMeansSEPADirectDebit || instr.Key == pay.MeansKeyDirectDebit, validation.Required, validation.By(validateDirectDebit), - ).Else(validation.Nil), + ), ), ) } -func validateDirectDebit(value interface{}) error { - inv, ok := value.(*bill.Invoice) - if !ok || inv == nil { - return nil +func validatePaymentKey(value interface{}) error { + t, ok := value.(cbc.Key) + if !ok { + return validation.NewError("type", "Invalid payment key") } - if inv.Payment == nil || inv.Payment.Instructions == nil || inv.Payment.Instructions.Key != pay.MeansKeyDirectDebit { - return nil + if !t.In(validPaymentKeys...) { + return validation.NewError("invalid", "Invalid payment key") } + return nil +} - dd := inv.Payment.Instructions.DirectDebit +func validateDirectDebit(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, diff --git a/addons/de/xrechnung/instructions_test.go b/addons/de/xrechnung/instructions_test.go new file mode 100644 index 00000000..92e95d71 --- /dev/null +++ b/addons/de/xrechnung/instructions_test.go @@ -0,0 +1,100 @@ +package xrechnung_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/de/xrechnung" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/pay" + "github.com/stretchr/testify/assert" +) + +func TestPaymentInstructions(t *testing.T) { + t.Run("valid SEPA credit transfer", func(t *testing.T) { + inv := &bill.Invoice{ + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, + CreditTransfer: []*pay.CreditTransfer{ + { + IBAN: "DE89370400440532013000", + BIC: "DEUTDEFF", + }, + }, + }, + }, + } + assert.NoError(t, xrechnung.ValidatePaymentInstructions(inv)) + }) + + t.Run("missing IBAN for SEPA credit transfer", func(t *testing.T) { + inv := &bill.Invoice{ + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, + CreditTransfer: []*pay.CreditTransfer{ + { + BIC: "DEUTDEFF", + }, + }, + }, + }, + } + assert.Error(t, xrechnung.ValidatePaymentInstructions(inv)) + }) + + t.Run("valid card payment", func(t *testing.T) { + inv := &bill.Invoice{ + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: pay.MeansKeyCard, + Card: &pay.Card{}, + }, + }, + } + assert.NoError(t, xrechnung.ValidatePaymentInstructions(inv)) + }) + + t.Run("valid SEPA direct debit", func(t *testing.T) { + inv := &bill.Invoice{ + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + DirectDebit: &pay.DirectDebit{ + Ref: "MANDATE123", + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", + }, + }, + }, + } + assert.NoError(t, xrechnung.ValidatePaymentInstructions(inv)) + }) + + t.Run("missing mandate reference for direct debit", func(t *testing.T) { + inv := &bill.Invoice{ + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + DirectDebit: &pay.DirectDebit{ + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", + }, + }, + }, + } + assert.Error(t, xrechnung.ValidatePaymentInstructions(inv)) + }) + + t.Run("invalid payment key", func(t *testing.T) { + inv := &bill.Invoice{ + Payment: &bill.Payment{ + Instructions: &pay.Instructions{ + Key: cbc.Key("invalid-key"), + }, + }, + } + assert.Error(t, xrechnung.ValidatePaymentInstructions(inv)) + }) +} diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 29999b15..1bee43e7 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -43,7 +43,7 @@ func ValidateInvoice(inv *bill.Invoice) error { return validation.ValidateStruct(payment, validation.Field(&payment.Instructions, validation.Required, - validation.By(validatePaymentInstructions), + validation.By(ValidatePaymentInstructions), ), ) }), @@ -65,7 +65,7 @@ func ValidateInvoice(inv *bill.Invoice) error { validation.By(validateSupplier), ), validation.Field(&inv.Supplier, - validation.By(validateSellerTaxInfo), + validation.By(validateSupplierTaxInfo), ), validation.Field(&inv.Customer, validation.By(validateCustomerReceiver), @@ -123,25 +123,7 @@ func validateSupplier(value interface{}) error { ) } -// func validateSellerTaxInfo(value interface{}) error { -// supplier, ok := value.(*org.Party) -// if !ok || supplier == nil { -// return validation.NewError("invalid_supplier", "Supplier is invalid or nil") -// } -// hasVATIdentifier := supplier.TaxID != nil && supplier.TaxID.Code != "" -// hasTaxIdentifier := org.IdentityForKey(supplier.Identities, "de-tax-number") != nil - -// if !hasVATIdentifier && !hasTaxIdentifier { -// return validation.NewError( -// "missing_seller_tax_info", -// "Either Seller VAT identifier or Seller tax identifier must be provided", -// ) -// } - -// return nil -// } - -func validateSellerTaxInfo(value interface{}) error { +func validateSupplierTaxInfo(value interface{}) error { supplier, ok := value.(*org.Party) if !ok || supplier == nil { return validation.NewError("invalid_supplier", "Supplier is invalid or nil") From c7f9083ee0fb06d8768f0b37bed1a8303e4936e2 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 15 Oct 2024 16:05:47 +0000 Subject: [PATCH 14/27] Lint Fix --- addons/de/xrechnung/extensions.go | 5 ++--- addons/de/xrechnung/instructions.go | 2 ++ addons/de/xrechnung/invoices.go | 1 + addons/de/xrechnung/tax_combo.go | 4 ++++ addons/de/xrechnung/xrechnung.go | 2 ++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go index 38f98d09..8b63dbf5 100644 --- a/addons/de/xrechnung/extensions.go +++ b/addons/de/xrechnung/extensions.go @@ -5,9 +5,8 @@ import ( "github.com/invopop/gobl/i18n" ) -var ( - ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" -) +// ExtKeyTaxRate is the key for the tax rate extension in XRechnung +var ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" var extensions = []*cbc.KeyDefinition{ { diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index 762e6916..2d4ac913 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -7,6 +7,7 @@ import ( "github.com/invopop/validation" ) +// Payment keys for XRechnung SEPA direct debit and credit transfer const ( KeyPaymentMeansSEPACreditTransfer cbc.Key = "sepa-credit-transfer" KeyPaymentMeansSEPADirectDebit cbc.Key = "sepa-direct-debit" @@ -23,6 +24,7 @@ var validPaymentKeys = []cbc.Key{ KeyPaymentMeansSEPADirectDebit, } +// ValidatePaymentInstructions validates the payment instructions according to the XRechnung standard func ValidatePaymentInstructions(value interface{}) error { inv, ok := value.(*bill.Invoice) if !ok || inv == nil || inv.Payment == nil || inv.Payment.Instructions == nil { diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 1bee43e7..7b9c451d 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -26,6 +26,7 @@ var validTypes = []cbc.Key{ invoiceTypeFinalConstruction, } +// ValidateInvoice validates the invoice according to the XRechnung standard func ValidateInvoice(inv *bill.Invoice) error { return validation.ValidateStruct(inv, // BR-DE-17 diff --git a/addons/de/xrechnung/tax_combo.go b/addons/de/xrechnung/tax_combo.go index 4ce42873..957172ca 100644 --- a/addons/de/xrechnung/tax_combo.go +++ b/addons/de/xrechnung/tax_combo.go @@ -5,6 +5,8 @@ import ( "github.com/invopop/validation" ) +// TaxRateExtensions returns the mapping of tax rates defined in DE +// to their extension values used by XRechnung. func TaxRateExtensions() tax.Extensions { return taxRateMap } @@ -15,6 +17,7 @@ var taxRateMap = tax.Extensions{ tax.RateExempt: "E", } +// NormalizeTaxCombo adds the XRechnung tax rate code to the tax combo. func NormalizeTaxCombo(combo *tax.Combo) { // copy the SAF-T tax rate code to the line switch combo.Category { @@ -33,6 +36,7 @@ func NormalizeTaxCombo(combo *tax.Combo) { } } +// ValidateTaxCombo validates percentage is included as BR-DE-14 indicates func ValidateTaxCombo(tc *tax.Combo) error { if tc == nil { return nil diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 599d985d..fa927c68 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -1,3 +1,4 @@ +// Package xrechnung provides extensions and validations for the German XRechnung standard version 3.0.2 for electronic invoicing. package xrechnung import ( @@ -9,6 +10,7 @@ import ( ) const ( + // V3 is the key for the XRechnung version 3.x V3 cbc.Key = "de-xrechnung-v3" ) From 3508528b61c353edff653835c23cf5ae0e603219 Mon Sep 17 00:00:00 2001 From: apardods Date: Fri, 18 Oct 2024 15:39:37 +0000 Subject: [PATCH 15/27] Fixed Validation.Skip and Instructions Testing, started work on Scenarios --- addons/de/xrechnung/extensions.go | 49 ++++++++++- addons/de/xrechnung/instructions.go | 11 +-- addons/de/xrechnung/instructions_test.go | 91 ++++++++------------- addons/de/xrechnung/invoices.go | 25 +++--- addons/de/xrechnung/scenarios.go | 100 +++++++++++++++++++++++ 5 files changed, 198 insertions(+), 78 deletions(-) create mode 100644 addons/de/xrechnung/scenarios.go diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go index 8b63dbf5..cb4c92f7 100644 --- a/addons/de/xrechnung/extensions.go +++ b/addons/de/xrechnung/extensions.go @@ -6,7 +6,10 @@ import ( ) // ExtKeyTaxRate is the key for the tax rate extension in XRechnung -var ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" +const ( + ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" + ExtKeyDocType cbc.Key = "de-xrechnung-doc-type" +) var extensions = []*cbc.KeyDefinition{ { @@ -81,4 +84,48 @@ var extensions = []*cbc.KeyDefinition{ }, }, }, + { + Key: ExtKeyDocType, + Name: i18n.String{ + i18n.EN: "Document Type", + i18n.DE: "Dokumentenart", + }, + Values: []*cbc.ValueDefinition{ + { + Value: string(invoiceTypeSelfBilled), + Name: i18n.String{ + i18n.EN: "Self-Billed Invoice", + i18n.DE: "Gutschrift", + }, + }, + { + Value: string(invoiceTypePartial), + Name: i18n.String{ + i18n.EN: "Partial Invoice", + i18n.DE: "Teilrechnung", + }, + }, + { + Value: string(invoiceTypePartialConstruction), + Name: i18n.String{ + i18n.EN: "Partial Construction Invoice", + i18n.DE: "Teilrechnung für Bauleistungen", + }, + }, + { + Value: string(invoiceTypePartialFinalConstruction), + Name: i18n.String{ + i18n.EN: "Partial Final Construction Invoice", + i18n.DE: "Schlussrechnung für Bauleistungen mit Teilrechnungen", + }, + }, + { + Value: string(invoiceTypeFinalConstruction), + Name: i18n.String{ + i18n.EN: "Final Construction Invoice", + i18n.DE: "Schlussrechnung für Bauleistungen", + }, + }, + }, + }, } diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index 2d4ac913..13b9bf91 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -1,7 +1,6 @@ package xrechnung import ( - "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/invopop/validation" @@ -26,15 +25,15 @@ var validPaymentKeys = []cbc.Key{ // ValidatePaymentInstructions validates the payment instructions according to the XRechnung standard func ValidatePaymentInstructions(value interface{}) error { - inv, ok := value.(*bill.Invoice) - if !ok || inv == nil || inv.Payment == nil || inv.Payment.Instructions == nil { + instr, ok := value.(*pay.Instructions) + if !ok || instr == nil { return nil } - instr := inv.Payment.Instructions return validation.ValidateStruct(instr, validation.Field(&instr.Key, validation.Required, validation.By(validatePaymentKey), + validation.Skip, ), // BR-DE-23 validation.Field(&instr.CreditTransfer, @@ -42,6 +41,7 @@ func ValidatePaymentInstructions(value interface{}) error { validation.Required, validation.Each(validation.By(validateCreditTransfer)), ), + validation.Skip, ), // BR-DE-24 validation.Field(&instr.Card, @@ -54,6 +54,7 @@ func ValidatePaymentInstructions(value interface{}) error { validation.When(instr.Key == KeyPaymentMeansSEPADirectDebit || instr.Key == pay.MeansKeyDirectDebit, validation.Required, validation.By(validateDirectDebit), + validation.Skip, ), ), ) @@ -62,7 +63,7 @@ func ValidatePaymentInstructions(value interface{}) error { func validatePaymentKey(value interface{}) error { t, ok := value.(cbc.Key) if !ok { - return validation.NewError("type", "Invalid payment key") + return validation.NewError("invalid_key", "invalid payment key") } if !t.In(validPaymentKeys...) { return validation.NewError("invalid", "Invalid payment key") diff --git a/addons/de/xrechnung/instructions_test.go b/addons/de/xrechnung/instructions_test.go index 92e95d71..9d208944 100644 --- a/addons/de/xrechnung/instructions_test.go +++ b/addons/de/xrechnung/instructions_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/invopop/gobl/addons/de/xrechnung" - "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/stretchr/testify/assert" @@ -12,89 +11,65 @@ import ( func TestPaymentInstructions(t *testing.T) { t.Run("valid SEPA credit transfer", func(t *testing.T) { - inv := &bill.Invoice{ - Payment: &bill.Payment{ - Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, - CreditTransfer: []*pay.CreditTransfer{ - { - IBAN: "DE89370400440532013000", - BIC: "DEUTDEFF", - }, - }, + instr := &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, + CreditTransfer: []*pay.CreditTransfer{ + { + IBAN: "DE89370400440532013000", + BIC: "DEUTDEFF", }, }, } - assert.NoError(t, xrechnung.ValidatePaymentInstructions(inv)) + assert.NoError(t, xrechnung.ValidatePaymentInstructions(instr)) }) t.Run("missing IBAN for SEPA credit transfer", func(t *testing.T) { - inv := &bill.Invoice{ - Payment: &bill.Payment{ - Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, - CreditTransfer: []*pay.CreditTransfer{ - { - BIC: "DEUTDEFF", - }, - }, + instr := &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, + CreditTransfer: []*pay.CreditTransfer{ + { + BIC: "DEUTDEFF", }, }, } - assert.Error(t, xrechnung.ValidatePaymentInstructions(inv)) + assert.Error(t, xrechnung.ValidatePaymentInstructions(instr)) }) t.Run("valid card payment", func(t *testing.T) { - inv := &bill.Invoice{ - Payment: &bill.Payment{ - Instructions: &pay.Instructions{ - Key: pay.MeansKeyCard, - Card: &pay.Card{}, - }, - }, + instr := &pay.Instructions{ + Key: pay.MeansKeyCard, + Card: &pay.Card{}, } - assert.NoError(t, xrechnung.ValidatePaymentInstructions(inv)) + assert.NoError(t, xrechnung.ValidatePaymentInstructions(instr)) }) t.Run("valid SEPA direct debit", func(t *testing.T) { - inv := &bill.Invoice{ - Payment: &bill.Payment{ - Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, - DirectDebit: &pay.DirectDebit{ - Ref: "MANDATE123", - Creditor: "DE98ZZZ09999999999", - Account: "DE89370400440532013000", - }, - }, + instr := &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + DirectDebit: &pay.DirectDebit{ + Ref: "MANDATE123", + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", }, } - assert.NoError(t, xrechnung.ValidatePaymentInstructions(inv)) + assert.NoError(t, xrechnung.ValidatePaymentInstructions(instr)) }) t.Run("missing mandate reference for direct debit", func(t *testing.T) { - inv := &bill.Invoice{ - Payment: &bill.Payment{ - Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, - DirectDebit: &pay.DirectDebit{ - Creditor: "DE98ZZZ09999999999", - Account: "DE89370400440532013000", - }, - }, + instr := &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + DirectDebit: &pay.DirectDebit{ + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", }, } - assert.Error(t, xrechnung.ValidatePaymentInstructions(inv)) + assert.Error(t, xrechnung.ValidatePaymentInstructions(instr)) }) t.Run("invalid payment key", func(t *testing.T) { - inv := &bill.Invoice{ - Payment: &bill.Payment{ - Instructions: &pay.Instructions{ - Key: cbc.Key("invalid-key"), - }, - }, + instr := &pay.Instructions{ + Key: cbc.Key("invalid-key"), } - assert.Error(t, xrechnung.ValidatePaymentInstructions(inv)) + assert.Error(t, xrechnung.ValidatePaymentInstructions(instr)) }) } diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 7b9c451d..e768d7e0 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -7,23 +7,10 @@ import ( "github.com/invopop/validation" ) -const ( - invoiceTypeSelfBilled cbc.Key = "self-billed" - invoiceTypePartial cbc.Key = "partial" - invoiceTypePartialConstruction cbc.Key = "partial-construction" - invoiceTypePartialFinalConstruction cbc.Key = "partial-final-construction" - invoiceTypeFinalConstruction cbc.Key = "final-construction" -) - var validTypes = []cbc.Key{ bill.InvoiceTypeStandard, bill.InvoiceTypeCreditNote, bill.InvoiceTypeCorrective, - invoiceTypeSelfBilled, - invoiceTypePartial, - invoiceTypePartialConstruction, - invoiceTypePartialFinalConstruction, - invoiceTypeFinalConstruction, } // ValidateInvoice validates the invoice according to the XRechnung standard @@ -45,6 +32,7 @@ func ValidateInvoice(inv *bill.Invoice) error { validation.Field(&payment.Instructions, validation.Required, validation.By(ValidatePaymentInstructions), + validation.Skip, ), ) }), @@ -64,15 +52,19 @@ func ValidateInvoice(inv *bill.Invoice) error { ), validation.Field(&inv.Supplier, validation.By(validateSupplier), + validation.Skip, ), validation.Field(&inv.Supplier, validation.By(validateSupplierTaxInfo), + validation.Skip, ), validation.Field(&inv.Customer, validation.By(validateCustomerReceiver), + validation.Skip, ), validation.Field(&inv.Delivery, validation.By(validateDelivery), + validation.Skip, ), // BR-DE-26 validation.Field(&inv.Preceding, @@ -108,6 +100,7 @@ func validateSupplier(value interface{}) error { validation.Field(&p.Addresses, validation.Required, validation.Each(validation.By(validatePartyAddress)), + validation.Skip, ), // BR-DE-06 validation.Field(&p.People, @@ -140,6 +133,7 @@ func validateSupplierTaxInfo(value interface{}) error { validation.When(supplier.TaxID == nil || supplier.TaxID.Code == "", validation.Required, validation.By(validateTaxNumber), + validation.Skip, ), ), ) @@ -163,7 +157,9 @@ func validateDelivery(value interface{}) error { } return validation.ValidateStruct(d, validation.Field(&d.Receiver, - validation.By(validateCustomerReceiver)), + validation.By(validateCustomerReceiver), + validation.Skip, + ), ) } @@ -179,6 +175,7 @@ func validateCustomerReceiver(value interface{}) error { validation.Field(&p.Addresses, validation.Required, validation.Each(validation.By(validatePartyAddress)), + validation.Skip, ), ) } diff --git a/addons/de/xrechnung/scenarios.go b/addons/de/xrechnung/scenarios.go new file mode 100644 index 00000000..ace8f08c --- /dev/null +++ b/addons/de/xrechnung/scenarios.go @@ -0,0 +1,100 @@ +package xrechnung + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +var scenarios = []*tax.ScenarioSet{ + { + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // ** Invoice Document Types ** + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + bill.InvoiceTypeCorrective, + bill.InvoiceTypeCreditNote, + bill.InvoiceTypeDebitNote, + }, + }, + { + Tags: []cbc.Key{ + tax.TagSelfBilled, + }, + Ext: tax.Extensions{ + ExtKeyDocType: tax.ExtValue(invoiceTypeSelfBilled), + }, + }, + { + Tags: []cbc.Key{ + tax.TagPartial, + }, + Ext: tax.Extensions{ + ExtKeyDocType: tax.ExtValue(invoiceTypePartial), + }, + }, + { + Tags: []cbc.Key{ + tax.TagPartial, + }, + Ext: tax.Extensions{ + ExtKeyDocType: tax.ExtValue(invoiceTypePartialConstruction), + }, + }, + { + Tags: []cbc.Key{ + tax.TagPartial, + }, + Ext: tax.Extensions{ + ExtKeyDocType: tax.ExtValue(invoiceTypePartialFinalConstruction), + }, + }, + { + Ext: tax.Extensions{ + ExtKeyDocType: tax.ExtValue(invoiceTypeFinalConstruction), + }, + }, + // ** Tax Rates ** + { + Ext: tax.Extensions{ + ExtKeyTaxRate: "S", + }, + }, + { + Ext: tax.Extensions{ + ExtKeyTaxRate: "Z", + }, + }, + { + Ext: tax.Extensions{ + ExtKeyTaxRate: "E", + }, + }, + { + Tags: []cbc.Key{ + tax.TagReverseCharge, + }, + Ext: tax.Extensions{ + ExtKeyTaxRate: "AE", + }, + }, + { + Ext: tax.Extensions{ + ExtKeyTaxRate: "K", + }, + }, + { + Ext: tax.Extensions{ + ExtKeyTaxRate: "G", + }, + }, + { + Ext: tax.Extensions{ + ExtKeyTaxRate: "O", + }, + }, + }, + }, +} From 5ab5d842828de0f9f492aecde40a038bf4ac6b16 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 22 Oct 2024 08:51:11 +0000 Subject: [PATCH 16/27] Update Extensions and Scenarios --- addons/de/xrechnung/extensions.go | 37 ++++++++++++---- addons/de/xrechnung/scenarios.go | 70 +++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go index cb4c92f7..1a3158a5 100644 --- a/addons/de/xrechnung/extensions.go +++ b/addons/de/xrechnung/extensions.go @@ -92,35 +92,56 @@ var extensions = []*cbc.KeyDefinition{ }, Values: []*cbc.ValueDefinition{ { - Value: string(invoiceTypeSelfBilled), + Value: "326", Name: i18n.String{ - i18n.EN: "Self-Billed Invoice", + i18n.EN: "Partial Invoice", + i18n.DE: "Teilrechnung", + }, + }, + { + Value: "380", + Name: i18n.String{ + i18n.EN: "Standard Invoice", + i18n.DE: "Standardrechnung", + }, + }, + { + Value: "381", + Name: i18n.String{ + i18n.EN: "Credit Note", i18n.DE: "Gutschrift", }, }, { - Value: string(invoiceTypePartial), + Value: "384", Name: i18n.String{ - i18n.EN: "Partial Invoice", - i18n.DE: "Teilrechnung", + i18n.EN: "Corrected Invoice", + i18n.DE: "Korrigierte Rechnung", + }, + }, + { + Value: "389", + Name: i18n.String{ + i18n.EN: "Self-Billed Invoice", + i18n.DE: "Gutschrift", }, }, { - Value: string(invoiceTypePartialConstruction), + Value: "875", Name: i18n.String{ i18n.EN: "Partial Construction Invoice", i18n.DE: "Teilrechnung für Bauleistungen", }, }, { - Value: string(invoiceTypePartialFinalConstruction), + Value: "876", Name: i18n.String{ i18n.EN: "Partial Final Construction Invoice", i18n.DE: "Schlussrechnung für Bauleistungen mit Teilrechnungen", }, }, { - Value: string(invoiceTypeFinalConstruction), + Value: "877", Name: i18n.String{ i18n.EN: "Final Construction Invoice", i18n.DE: "Schlussrechnung für Bauleistungen", diff --git a/addons/de/xrechnung/scenarios.go b/addons/de/xrechnung/scenarios.go index ace8f08c..f1960d7e 100644 --- a/addons/de/xrechnung/scenarios.go +++ b/addons/de/xrechnung/scenarios.go @@ -3,9 +3,70 @@ package xrechnung import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/tax" ) +// Document tag keys +const ( + // Tags for invoice types + TagSelfBilled cbc.Key = "self-billed" + TagPartial cbc.Key = "partial" + TagPartialConstruction cbc.Key = "partial-construction" + TagPartialFinalConstruction cbc.Key = "partial-final-construction" + TagFinalConstruction cbc.Key = "final-construction" +) + +// Invoice type constants +const ( + invoiceTypeSelfBilled = "380" + invoiceTypePartial = "326" + invoiceTypePartialConstruction = "80" + invoiceTypePartialFinalConstruction = "84" + invoiceTypeFinalConstruction = "389" +) + +var invoiceTags = &tax.TagSet{ + Schema: bill.ShortSchemaInvoice, + List: []*cbc.KeyDefinition{ + { + Key: TagSelfBilled, + Name: i18n.String{ + i18n.EN: "Self-billed Invoice", + i18n.DE: "Gutschrift", + }, + }, + { + Key: TagPartial, + Name: i18n.String{ + i18n.EN: "Partial Invoice", + i18n.DE: "Abschlagsrechnung", + }, + }, + { + Key: TagPartialConstruction, + Name: i18n.String{ + i18n.EN: "Partial Construction Invoice", + i18n.DE: "Abschlagsrechnung (Bauleistung)", + }, + }, + { + Key: TagPartialFinalConstruction, + Name: i18n.String{ + i18n.EN: "Partial Final Construction Invoice", + i18n.DE: "Schlussrechnung (Bauleistung)", + }, + }, + { + Key: TagFinalConstruction, + Name: i18n.String{ + i18n.EN: "Final Construction Invoice", + i18n.DE: "Schlussrechnung", + }, + }, + }, +} + var scenarios = []*tax.ScenarioSet{ { Schema: bill.ShortSchemaInvoice, @@ -58,16 +119,25 @@ var scenarios = []*tax.ScenarioSet{ }, // ** Tax Rates ** { + Tags: []cbc.Key{ + tax.RateStandard, + }, Ext: tax.Extensions{ ExtKeyTaxRate: "S", }, }, { + Tags: []cbc.Key{ + tax.RateZero, + }, Ext: tax.Extensions{ ExtKeyTaxRate: "Z", }, }, { + Tags: []cbc.Key{ + tax.RateExempt, + }, Ext: tax.Extensions{ ExtKeyTaxRate: "E", }, From f52229d34cf861764516e013fb4777ff57c06f18 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 22 Oct 2024 10:51:28 +0000 Subject: [PATCH 17/27] Update Tags and Scenarios --- addons/de/xrechnung/extensions.go | 17 ++++ addons/de/xrechnung/instructions.go | 13 +-- addons/de/xrechnung/instructions_test.go | 106 ++++++++++++++--------- addons/de/xrechnung/invoices.go | 64 ++++++++------ addons/de/xrechnung/scenarios.go | 9 +- addons/de/xrechnung/xrechnung.go | 4 + 6 files changed, 135 insertions(+), 78 deletions(-) diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go index 1a3158a5..e742933f 100644 --- a/addons/de/xrechnung/extensions.go +++ b/addons/de/xrechnung/extensions.go @@ -3,6 +3,7 @@ package xrechnung import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" ) // ExtKeyTaxRate is the key for the tax rate extension in XRechnung @@ -18,6 +19,14 @@ var extensions = []*cbc.KeyDefinition{ i18n.EN: "Tax Rate", i18n.DE: "Steuersatz", }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Code used to describe the applicable tax rate. Taken from the UNTDID 5305 code list. + `), + i18n.DE: here.Doc(` + Code verwendet um den anwendbaren Steuersatz zu beschreiben. Entnommen aus der UNTDID 5305 Code-Liste. + `), + }, Values: []*cbc.ValueDefinition{ { Value: "S", @@ -90,6 +99,14 @@ var extensions = []*cbc.KeyDefinition{ i18n.EN: "Document Type", i18n.DE: "Dokumentenart", }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Code used to describe the type of document. + `), + i18n.DE: here.Doc(` + Code verwendet um die Art des Dokuments zu beschreiben. + `), + }, Values: []*cbc.ValueDefinition{ { Value: "326", diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index 13b9bf91..8d3d9c44 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -24,7 +24,7 @@ var validPaymentKeys = []cbc.Key{ } // ValidatePaymentInstructions validates the payment instructions according to the XRechnung standard -func ValidatePaymentInstructions(value interface{}) error { +func validatePaymentInstructions(value interface{}) error { instr, ok := value.(*pay.Instructions) if !ok || instr == nil { return nil @@ -48,6 +48,7 @@ func ValidatePaymentInstructions(value interface{}) error { validation.When(instr.Key == pay.MeansKeyCard, validation.Required, ), + validation.Skip, ), // BR-DE-25 validation.Field(&instr.DirectDebit, @@ -66,7 +67,7 @@ func validatePaymentKey(value interface{}) error { return validation.NewError("invalid_key", "invalid payment key") } if !t.In(validPaymentKeys...) { - return validation.NewError("invalid", "Invalid payment key") + return validation.NewError("invalid", "invalid payment key") } return nil } @@ -79,15 +80,15 @@ func validateDirectDebit(value interface{}) error { return validation.ValidateStruct(dd, // BR-DE-29 - Changed to Peppol-EN16931-R061 validation.Field(&dd.Ref, - validation.Required.Error("Mandate reference is mandatory for direct debit"), + validation.Required, ), // BR-DE-30 validation.Field(&dd.Creditor, - validation.Required.Error("Creditor identifier is mandatory for direct debit"), + validation.Required, ), // BR-DE-31 validation.Field(&dd.Account, - validation.Required.Error("Debited account identifier is mandatory for direct debit"), + validation.Required, ), ) } @@ -101,7 +102,7 @@ func validateCreditTransfer(value interface{}) error { return validation.ValidateStruct(creditTransfer, validation.Field(&creditTransfer.Number, validation.When(creditTransfer.IBAN == "", - validation.Required.Error("IBAN must be provided for SEPA credit transfer"), + validation.Required, ), ), ) diff --git a/addons/de/xrechnung/instructions_test.go b/addons/de/xrechnung/instructions_test.go index 9d208944..36e8a14f 100644 --- a/addons/de/xrechnung/instructions_test.go +++ b/addons/de/xrechnung/instructions_test.go @@ -4,72 +4,98 @@ import ( "testing" "github.com/invopop/gobl/addons/de/xrechnung" + "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/stretchr/testify/assert" ) -func TestPaymentInstructions(t *testing.T) { - t.Run("valid SEPA credit transfer", func(t *testing.T) { - instr := &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, - CreditTransfer: []*pay.CreditTransfer{ - { - IBAN: "DE89370400440532013000", - BIC: "DEUTDEFF", +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: cbc.Key("sepa-credit-transfer"), + CreditTransfer: []*pay.CreditTransfer{ + { + IBAN: "DE89370400440532013000", + BIC: "DEUTDEFF", + }, }, }, } - assert.NoError(t, xrechnung.ValidatePaymentInstructions(instr)) + assert.NoError(t, xrechnung.ValidateInvoice(inv)) }) - t.Run("missing IBAN for SEPA credit transfer", func(t *testing.T) { - instr := &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, - CreditTransfer: []*pay.CreditTransfer{ - { - BIC: "DEUTDEFF", + t.Run("invalid invoice with missing IBAN for SEPA credit transfer", func(t *testing.T) { + inv := invoiceTemplate(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, + CreditTransfer: []*pay.CreditTransfer{ + { + BIC: "DEUTDEFF", + }, }, }, } - assert.Error(t, xrechnung.ValidatePaymentInstructions(instr)) + assert.Error(t, xrechnung.ValidateInvoice(inv)) }) - t.Run("valid card payment", func(t *testing.T) { - instr := &pay.Instructions{ - Key: pay.MeansKeyCard, - Card: &pay.Card{}, + 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{}, + }, } - assert.NoError(t, xrechnung.ValidatePaymentInstructions(instr)) + assert.NoError(t, xrechnung.ValidateInvoice(inv)) }) - t.Run("valid SEPA direct debit", func(t *testing.T) { - instr := &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, - DirectDebit: &pay.DirectDebit{ - Ref: "MANDATE123", - Creditor: "DE98ZZZ09999999999", - Account: "DE89370400440532013000", + t.Run("valid invoice with SEPA direct debit", func(t *testing.T) { + inv := invoiceTemplate(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + DirectDebit: &pay.DirectDebit{ + Ref: "MANDATE123", + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", + }, }, } - assert.NoError(t, xrechnung.ValidatePaymentInstructions(instr)) + assert.NoError(t, xrechnung.ValidateInvoice(inv)) }) - t.Run("missing mandate reference for direct debit", func(t *testing.T) { - instr := &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, - DirectDebit: &pay.DirectDebit{ - Creditor: "DE98ZZZ09999999999", - Account: "DE89370400440532013000", + t.Run("invalid invoice with missing mandate reference for direct debit", func(t *testing.T) { + inv := invoiceTemplate(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + DirectDebit: &pay.DirectDebit{ + Creditor: "DE98ZZZ09999999999", + Account: "DE89370400440532013000", + }, }, } - assert.Error(t, xrechnung.ValidatePaymentInstructions(instr)) + assert.Error(t, xrechnung.ValidateInvoice(inv)) }) - t.Run("invalid payment key", func(t *testing.T) { - instr := &pay.Instructions{ - Key: cbc.Key("invalid-key"), + 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"), + }, } - assert.Error(t, xrechnung.ValidatePaymentInstructions(instr)) + assert.Error(t, xrechnung.ValidateInvoice(inv)) }) } diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index e768d7e0..1b713be3 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -19,36 +19,19 @@ func ValidateInvoice(inv *bill.Invoice) error { // BR-DE-17 validation.Field(&inv.Type, validation.By(validateInvoiceType), + validation.Skip, ), // BR-DE-01 - validation.Field(&inv.Payment, validation.Required), validation.Field(&inv.Payment, - validation.By(func(value interface{}) error { - payment, ok := value.(*bill.Payment) - if !ok || payment == nil { - return validation.NewError("payment_type", "must be a valid non-empty Payment type") - } - return validation.ValidateStruct(payment, - validation.Field(&payment.Instructions, - validation.Required, - validation.By(ValidatePaymentInstructions), - validation.Skip, - ), - ) - }), + validation.Required, + validation.By(validatePayment), + validation.Skip, ), // BR-DE-15 - validation.Field(&inv.Ordering, validation.Required), validation.Field(&inv.Ordering, - validation.By(func(value interface{}) error { - ordering, ok := value.(*bill.Ordering) - if !ok || ordering == nil { - return validation.NewError("ordering_type", "must be a valid Ordering type") - } - return validation.ValidateStruct(ordering, - validation.Field(&ordering.Code, validation.Required), - ) - }), + validation.Required, + validation.By(validateOrdering), + validation.Skip, ), validation.Field(&inv.Supplier, validation.By(validateSupplier), @@ -75,13 +58,38 @@ func ValidateInvoice(inv *bill.Invoice) error { ) } +func validatePayment(value interface{}) error { + payment, ok := value.(*bill.Payment) + if !ok || payment == nil { + return nil + } + return validation.ValidateStruct(payment, + validation.Field(&payment.Instructions, + validation.Required, + validation.By(validatePaymentInstructions), + ), + ) +} + +func validateOrdering(value interface{}) error { + ordering, ok := value.(*bill.Ordering) + if !ok || ordering == nil { + return nil + } + return validation.ValidateStruct(ordering, + validation.Field(&ordering.Code, + validation.Required, + ), + ) +} + func validateInvoiceType(value interface{}) error { t, ok := value.(cbc.Key) if !ok { - return validation.NewError("type", "Invalid invoice type") + return validation.NewError("type", "invalid invoice type") } if !t.In(validTypes...) { - return validation.NewError("invalid", "Invalid invoice type") + return validation.NewError("invalid", "invalid invoice type") } return nil } @@ -142,10 +150,10 @@ func validateSupplierTaxInfo(value interface{}) error { func validateTaxNumber(value interface{}) error { identities, ok := value.([]*org.Identity) if !ok { - return validation.NewError("invalid_identities", "Identities are invalid") + return validation.NewError("invalid_identities", "identities are invalid") } if org.IdentityForKey(identities, "de-tax-number") == nil { - return validation.NewError("missing_tax_identifier", "German tax identifier (de-tax-number) is required") + return validation.NewError("missing_tax_identifier", "tax identifier (de-tax-number) is required") } return nil } diff --git a/addons/de/xrechnung/scenarios.go b/addons/de/xrechnung/scenarios.go index f1960d7e..204868c8 100644 --- a/addons/de/xrechnung/scenarios.go +++ b/addons/de/xrechnung/scenarios.go @@ -19,11 +19,11 @@ const ( // Invoice type constants const ( - invoiceTypeSelfBilled = "380" + invoiceTypeSelfBilled = "389" invoiceTypePartial = "326" - invoiceTypePartialConstruction = "80" - invoiceTypePartialFinalConstruction = "84" - invoiceTypeFinalConstruction = "389" + invoiceTypePartialConstruction = "875" + invoiceTypePartialFinalConstruction = "876" + invoiceTypeFinalConstruction = "877" ) var invoiceTags = &tax.TagSet{ @@ -150,6 +150,7 @@ var scenarios = []*tax.ScenarioSet{ ExtKeyTaxRate: "AE", }, }, + // TODO: Map Scenarios { Ext: tax.Extensions{ ExtKeyTaxRate: "K", diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index fa927c68..9ed4a02a 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -35,6 +35,10 @@ func newAddon() *tax.AddonDef { https://www.xrechnung.de/ `), }, + Tags: []*tax.TagSet{ + invoiceTags, + }, + Scenarios: scenarios, Extensions: extensions, Normalizer: normalize, Validator: validate, From e80e161750d2e7a825eda5ed531c4bf6d49480bc Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 09:13:04 +0000 Subject: [PATCH 18/27] Final proposal for EN16931 addon and catalogues with XRechnung --- CHANGELOG.md | 12 + addons/addons.go | 1 + addons/de/xrechnung/extensions.go | 169 ---- addons/de/xrechnung/instructions.go | 58 +- addons/de/xrechnung/instructions_test.go | 31 +- addons/de/xrechnung/invoices.go | 203 +---- addons/de/xrechnung/invoices_test.go | 118 +-- addons/de/xrechnung/scenarios.go | 171 ---- addons/de/xrechnung/tax_combo.go | 51 -- addons/de/xrechnung/tax_combo_test.go | 35 - addons/de/xrechnung/xrechnung.go | 34 +- addons/eu/en16931/bill.go | 31 + addons/eu/en16931/bill_test.go | 116 +++ addons/eu/en16931/en16931.go | 66 ++ addons/eu/en16931/pay.go | 64 ++ addons/eu/en16931/pay_test.go | 82 ++ addons/eu/en16931/scenarios.go | 85 ++ addons/eu/en16931/tax_combo.go | 52 ++ addons/eu/en16931/tax_combo_test.go | 98 ++ bill/invoice.go | 17 +- bill/invoice_correct.go | 2 +- bill/invoice_scenarios.go | 2 +- catalogues/catalogues.go | 10 + catalogues/generate.go | 40 + catalogues/iso/extensions.go | 28 + catalogues/iso/iso.go | 20 + catalogues/iso/iso_test.go | 14 + catalogues/untdid/extensions.go | 746 +++++++++++++++ catalogues/untdid/untdid.go | 19 + catalogues/untdid/untdid_test.go | 14 + cbc/code.go | 24 +- cbc/code_test.go | 127 +++ cbc/key.go | 11 + cbc/key_test.go | 6 + data/addons/de-xrechnung-v3.json | 16 + data/catalogues/iso.json | 20 + data/catalogues/untdid.json | 997 +++++++++++++++++++++ data/data.go | 2 +- data/regimes/de.json | 12 + data/schemas/bill/invoice.json | 2 +- data/schemas/org/identity.json | 5 + data/schemas/org/party.json | 4 + data/schemas/pay/advance.json | 10 + data/schemas/pay/instructions.json | 10 + data/schemas/tax/addon-def.json | 8 + data/schemas/tax/catalogue-def.json | 38 + gobl.go | 2 + i18n/string.go | 8 + i18n/string_test.go | 3 + internal/cli/bulk_test.go | 2 +- org/identity.go | 17 +- org/identity_test.go | 54 ++ org/party.go | 11 +- org/party_test.go | 19 + pay/instructions.go | 10 - pay/means_key.go | 106 ++- regimes/de/examples/invoice-de-de.yaml | 9 + regimes/de/examples/out/invoice-de-de.json | 33 +- regimes/de/identities.go | 4 +- regimes/de/invoices.go | 12 +- regimes/de/invoices_test.go | 13 + regimes/de/tax_categories.go | 12 + tax/addons.go | 22 +- tax/addons_test.go | 8 +- tax/catalogue.go | 62 ++ tax/catalogue_test.go | 22 + tax/extensions.go | 49 +- tax/extensions_test.go | 124 +++ tax/identity.go | 2 +- tax/regime_def.go | 4 +- tax/tax.go | 33 +- 71 files changed, 3448 insertions(+), 874 deletions(-) delete mode 100644 addons/de/xrechnung/extensions.go delete mode 100644 addons/de/xrechnung/scenarios.go delete mode 100644 addons/de/xrechnung/tax_combo.go delete mode 100644 addons/de/xrechnung/tax_combo_test.go create mode 100644 addons/eu/en16931/bill.go create mode 100644 addons/eu/en16931/bill_test.go create mode 100644 addons/eu/en16931/en16931.go create mode 100644 addons/eu/en16931/pay.go create mode 100644 addons/eu/en16931/pay_test.go create mode 100644 addons/eu/en16931/scenarios.go create mode 100644 addons/eu/en16931/tax_combo.go create mode 100644 addons/eu/en16931/tax_combo_test.go create mode 100644 catalogues/catalogues.go create mode 100644 catalogues/generate.go create mode 100644 catalogues/iso/extensions.go create mode 100644 catalogues/iso/iso.go create mode 100644 catalogues/iso/iso_test.go create mode 100644 catalogues/untdid/extensions.go create mode 100644 catalogues/untdid/untdid.go create mode 100644 catalogues/untdid/untdid_test.go create mode 100644 data/addons/de-xrechnung-v3.json create mode 100644 data/catalogues/iso.json create mode 100644 data/catalogues/untdid.json create mode 100644 data/schemas/tax/catalogue-def.json create mode 100644 tax/catalogue.go create mode 100644 tax/catalogue_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 597cb9c5..fdbf228b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to GOBL will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). See also the [GOBL versions](https://docs.gobl.org/overview/versions) documentation site for more details. +## [Unreleased] + +### Added + +- New "tax catalogues" used for defining extensions for specific standards. +- `eu-en16931-v2017`: addon for underlying support of the EN16931 semantic specifications. +- `de-xrechnung-v3`: addon with extra normalization for XRechnung specification in Germany. + +### Removed + +- `pay`: UNTDID 4461 mappings from payment means table, now provided by catalogues + ## [v0.203.0] ### Added diff --git a/addons/addons.go b/addons/addons.go index 73c1bf34..63a5f773 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -13,6 +13,7 @@ import ( _ "github.com/invopop/gobl/addons/de/xrechnung" _ "github.com/invopop/gobl/addons/es/facturae" _ "github.com/invopop/gobl/addons/es/tbai" + _ "github.com/invopop/gobl/addons/eu/en16931" _ "github.com/invopop/gobl/addons/gr/mydata" _ "github.com/invopop/gobl/addons/it/sdi" _ "github.com/invopop/gobl/addons/mx/cfdi" diff --git a/addons/de/xrechnung/extensions.go b/addons/de/xrechnung/extensions.go deleted file mode 100644 index e742933f..00000000 --- a/addons/de/xrechnung/extensions.go +++ /dev/null @@ -1,169 +0,0 @@ -package xrechnung - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" -) - -// ExtKeyTaxRate is the key for the tax rate extension in XRechnung -const ( - ExtKeyTaxRate cbc.Key = "de-xrechnung-tax-rate" - ExtKeyDocType cbc.Key = "de-xrechnung-doc-type" -) - -var extensions = []*cbc.KeyDefinition{ - { - Key: ExtKeyTaxRate, - Name: i18n.String{ - i18n.EN: "Tax Rate", - i18n.DE: "Steuersatz", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - Code used to describe the applicable tax rate. Taken from the UNTDID 5305 code list. - `), - i18n.DE: here.Doc(` - Code verwendet um den anwendbaren Steuersatz zu beschreiben. Entnommen aus der UNTDID 5305 Code-Liste. - `), - }, - Values: []*cbc.ValueDefinition{ - { - Value: "S", - Name: i18n.String{ - i18n.EN: "Standard Rate", - i18n.DE: "Standardsteuersatz", - }, - }, - { - Value: "Z", - Name: i18n.String{ - i18n.EN: "Zero rated goods", - i18n.DE: "Güter mit Nullbewertung", - }, - }, - { - Value: "E", - Name: i18n.String{ - i18n.EN: "Exempt from tax", - i18n.DE: "von der Steuer befreit", - }, - }, - { - Value: "AE", - Name: i18n.String{ - i18n.EN: "VAT Reverse Charge", - i18n.DE: "Mehrwertsteuer Umkehrung der Steuerschuldnerschaft", - }, - }, - { - Value: "K", - Name: i18n.String{ - i18n.EN: "VAT exempt for EEA intra-community supply of goods and services", - i18n.DE: "Mehrwertsteuerbefreiung für innergemeinschaftliche Lieferungen von Gegenständen und Dienstleistungen im EWR", - }, - }, - { - Value: "G", - Name: i18n.String{ - i18n.EN: "Free export item, tax not charged", - i18n.DE: "Kostenlose Ausfuhrsendung, ohne Steuer", - }, - }, - { - Value: "O", - Name: i18n.String{ - i18n.EN: "Services outside scope of tax", - i18n.DE: "Dienstleistungen, die nicht unter die Steuer fallen", - }, - }, - { - Value: "L", - Name: i18n.String{ - i18n.EN: "Canary Islands general indirect tax", - i18n.DE: "Allgemeine indirekte Steuer der Kanarischen Inseln", - }, - }, - { - Value: "M", - Name: i18n.String{ - i18n.EN: "Tax for production, services and importation in Ceuta and Melilla", - i18n.DE: "Steuer auf Produktion, Dienstleistungen und Importe in Ceuta und Melilla", - }, - }, - }, - }, - { - Key: ExtKeyDocType, - Name: i18n.String{ - i18n.EN: "Document Type", - i18n.DE: "Dokumentenart", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - Code used to describe the type of document. - `), - i18n.DE: here.Doc(` - Code verwendet um die Art des Dokuments zu beschreiben. - `), - }, - Values: []*cbc.ValueDefinition{ - { - Value: "326", - Name: i18n.String{ - i18n.EN: "Partial Invoice", - i18n.DE: "Teilrechnung", - }, - }, - { - Value: "380", - Name: i18n.String{ - i18n.EN: "Standard Invoice", - i18n.DE: "Standardrechnung", - }, - }, - { - Value: "381", - Name: i18n.String{ - i18n.EN: "Credit Note", - i18n.DE: "Gutschrift", - }, - }, - { - Value: "384", - Name: i18n.String{ - i18n.EN: "Corrected Invoice", - i18n.DE: "Korrigierte Rechnung", - }, - }, - { - Value: "389", - Name: i18n.String{ - i18n.EN: "Self-Billed Invoice", - i18n.DE: "Gutschrift", - }, - }, - { - Value: "875", - Name: i18n.String{ - i18n.EN: "Partial Construction Invoice", - i18n.DE: "Teilrechnung für Bauleistungen", - }, - }, - { - Value: "876", - Name: i18n.String{ - i18n.EN: "Partial Final Construction Invoice", - i18n.DE: "Schlussrechnung für Bauleistungen mit Teilrechnungen", - }, - }, - { - Value: "877", - Name: i18n.String{ - i18n.EN: "Final Construction Invoice", - i18n.DE: "Schlussrechnung für Bauleistungen", - }, - }, - }, - }, -} diff --git a/addons/de/xrechnung/instructions.go b/addons/de/xrechnung/instructions.go index 8d3d9c44..d09fb6b8 100644 --- a/addons/de/xrechnung/instructions.go +++ b/addons/de/xrechnung/instructions.go @@ -1,28 +1,10 @@ package xrechnung import ( - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/invopop/validation" ) -// Payment keys for XRechnung SEPA direct debit and credit transfer -const ( - KeyPaymentMeansSEPACreditTransfer cbc.Key = "sepa-credit-transfer" - KeyPaymentMeansSEPADirectDebit cbc.Key = "sepa-direct-debit" -) - -var validPaymentKeys = []cbc.Key{ - pay.MeansKeyCash, - pay.MeansKeyCheque, - pay.MeansKeyCreditTransfer, - pay.MeansKeyCard, - pay.MeansKeyDirectDebit, - pay.MeansKeyOther, - KeyPaymentMeansSEPACreditTransfer, - KeyPaymentMeansSEPADirectDebit, -} - // ValidatePaymentInstructions validates the payment instructions according to the XRechnung standard func validatePaymentInstructions(value interface{}) error { instr, ok := value.(*pay.Instructions) @@ -30,14 +12,10 @@ func validatePaymentInstructions(value interface{}) error { return nil } return validation.ValidateStruct(instr, - validation.Field(&instr.Key, - validation.Required, - validation.By(validatePaymentKey), - validation.Skip, - ), // BR-DE-23 validation.Field(&instr.CreditTransfer, - validation.When(instr.Key == KeyPaymentMeansSEPACreditTransfer, + validation.When( + instr.Key.Has(pay.MeansKeyCreditTransfer), validation.Required, validation.Each(validation.By(validateCreditTransfer)), ), @@ -45,34 +23,25 @@ func validatePaymentInstructions(value interface{}) error { ), // BR-DE-24 validation.Field(&instr.Card, - validation.When(instr.Key == pay.MeansKeyCard, + validation.When( + instr.Key.Has(pay.MeansKeyCard), validation.Required, ), validation.Skip, ), // BR-DE-25 validation.Field(&instr.DirectDebit, - validation.When(instr.Key == KeyPaymentMeansSEPADirectDebit || instr.Key == pay.MeansKeyDirectDebit, + validation.When( + instr.Key.Has(pay.MeansKeyDirectDebit), validation.Required, - validation.By(validateDirectDebit), + validation.By(validateInstructionsDirectDebit), validation.Skip, ), ), ) } -func validatePaymentKey(value interface{}) error { - t, ok := value.(cbc.Key) - if !ok { - return validation.NewError("invalid_key", "invalid payment key") - } - if !t.In(validPaymentKeys...) { - return validation.NewError("invalid", "invalid payment key") - } - return nil -} - -func validateDirectDebit(value interface{}) error { +func validateInstructionsDirectDebit(value interface{}) error { dd, ok := value.(*pay.DirectDebit) if !ok || dd == nil { return nil @@ -95,13 +64,14 @@ func validateDirectDebit(value interface{}) error { // BR-DE-19 func validateCreditTransfer(value interface{}) error { - creditTransfer, _ := value.(*pay.CreditTransfer) - if creditTransfer == nil { + ct, ok := value.(*pay.CreditTransfer) + if ct == nil || !ok { return nil } - return validation.ValidateStruct(creditTransfer, - validation.Field(&creditTransfer.Number, - validation.When(creditTransfer.IBAN == "", + return validation.ValidateStruct(ct, + validation.Field(&ct.Number, + validation.When( + ct.IBAN == "", validation.Required, ), ), diff --git a/addons/de/xrechnung/instructions_test.go b/addons/de/xrechnung/instructions_test.go index 36e8a14f..2f3cdf05 100644 --- a/addons/de/xrechnung/instructions_test.go +++ b/addons/de/xrechnung/instructions_test.go @@ -3,11 +3,11 @@ package xrechnung_test import ( "testing" - "github.com/invopop/gobl/addons/de/xrechnung" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/pay" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func invoiceTemplate(t *testing.T) *bill.Invoice { @@ -22,7 +22,7 @@ func TestValidateInvoice(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: cbc.Key("sepa-credit-transfer"), + Key: "credit-transfer+sepa", CreditTransfer: []*pay.CreditTransfer{ { IBAN: "DE89370400440532013000", @@ -31,14 +31,15 @@ func TestValidateInvoice(t *testing.T) { }, }, } - assert.NoError(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) }) t.Run("invalid invoice with missing IBAN for SEPA credit transfer", func(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPACreditTransfer, + Key: pay.MeansKeyCreditTransfer.With(pay.MeansKeySEPA), CreditTransfer: []*pay.CreditTransfer{ { BIC: "DEUTDEFF", @@ -46,7 +47,9 @@ func TestValidateInvoice(t *testing.T) { }, }, } - assert.Error(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "payment: (instructions: (credit_transfer: (0: (number: cannot be blank.).).).)") }) t.Run("valid invoice with card payment", func(t *testing.T) { @@ -57,14 +60,15 @@ func TestValidateInvoice(t *testing.T) { Card: &pay.Card{}, }, } - assert.NoError(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) }) t.Run("valid invoice with SEPA direct debit", func(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + Key: "direct-debit+sepa", DirectDebit: &pay.DirectDebit{ Ref: "MANDATE123", Creditor: "DE98ZZZ09999999999", @@ -72,21 +76,24 @@ func TestValidateInvoice(t *testing.T) { }, }, } - assert.NoError(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) }) t.Run("invalid invoice with missing mandate reference for direct debit", func(t *testing.T) { inv := invoiceTemplate(t) inv.Payment = &bill.Payment{ Instructions: &pay.Instructions{ - Key: xrechnung.KeyPaymentMeansSEPADirectDebit, + Key: "direct-debit+sepa", DirectDebit: &pay.DirectDebit{ Creditor: "DE98ZZZ09999999999", Account: "DE89370400440532013000", }, }, } - assert.Error(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "payment: (instructions: (direct_debit: (ref: cannot be blank.).).)") }) t.Run("invalid invoice with invalid payment key", func(t *testing.T) { @@ -96,6 +103,8 @@ func TestValidateInvoice(t *testing.T) { Key: cbc.Key("invalid-key"), }, } - assert.Error(t, xrechnung.ValidateInvoice(inv)) + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "payment: (instructions: (key: must be or start with a valid key.).)") }) } diff --git a/addons/de/xrechnung/invoices.go b/addons/de/xrechnung/invoices.go index 1b713be3..c00a98c0 100644 --- a/addons/de/xrechnung/invoices.go +++ b/addons/de/xrechnung/invoices.go @@ -2,202 +2,53 @@ package xrechnung import ( "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/org" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) -var validTypes = []cbc.Key{ - bill.InvoiceTypeStandard, - bill.InvoiceTypeCreditNote, - bill.InvoiceTypeCorrective, +// BR-DE-17 - restricted subset of UNTDID document type codes +var validInvoiceUNTDIDDocumentTypeValues = []tax.ExtValue{ + "326", // Partial + "380", // Commercial + "384", // Corrected + "389", // Self-billed + "381", // Credit note + "875", // Partial construction invoice + "876", // Partial Final construction invoice + "877", // Final construction invoice } -// ValidateInvoice validates the invoice according to the XRechnung standard -func ValidateInvoice(inv *bill.Invoice) error { +// validateInvoice validates the invoice according to the XRechnung standard +func validateInvoice(inv *bill.Invoice) error { return validation.ValidateStruct(inv, // BR-DE-17 - validation.Field(&inv.Type, - validation.By(validateInvoiceType), + validation.Field(&inv.Tax, + validation.By(validateInvoiceTax), validation.Skip, ), - // BR-DE-01 - validation.Field(&inv.Payment, - validation.Required, - validation.By(validatePayment), - validation.Skip, - ), - // BR-DE-15 - validation.Field(&inv.Ordering, - validation.Required, - validation.By(validateOrdering), - validation.Skip, - ), - validation.Field(&inv.Supplier, - validation.By(validateSupplier), - validation.Skip, - ), - validation.Field(&inv.Supplier, - validation.By(validateSupplierTaxInfo), - validation.Skip, - ), - validation.Field(&inv.Customer, - validation.By(validateCustomerReceiver), - validation.Skip, - ), - validation.Field(&inv.Delivery, - validation.By(validateDelivery), - validation.Skip, - ), - // BR-DE-26 validation.Field(&inv.Preceding, - validation.When(inv.Type.In(bill.InvoiceTypeCorrective), - validation.Required, - ), - ), - ) -} - -func validatePayment(value interface{}) error { - payment, ok := value.(*bill.Payment) - if !ok || payment == nil { - return nil - } - return validation.ValidateStruct(payment, - validation.Field(&payment.Instructions, - validation.Required, - validation.By(validatePaymentInstructions), - ), - ) -} - -func validateOrdering(value interface{}) error { - ordering, ok := value.(*bill.Ordering) - if !ok || ordering == nil { - return nil - } - return validation.ValidateStruct(ordering, - validation.Field(&ordering.Code, - validation.Required, - ), - ) -} - -func validateInvoiceType(value interface{}) error { - t, ok := value.(cbc.Key) - if !ok { - return validation.NewError("type", "invalid invoice type") - } - if !t.In(validTypes...) { - return validation.NewError("invalid", "invalid invoice type") - } - return nil -} - -func validateSupplier(value interface{}) error { - p, _ := value.(*org.Party) - if p == nil { - return nil - } - return validation.ValidateStruct(p, - // BR-DE-02 - validation.Field(&p.Name, - validation.Required, - ), - // BR-DE-03, BR-DE-04 - validation.Field(&p.Addresses, - validation.Required, - validation.Each(validation.By(validatePartyAddress)), - validation.Skip, - ), - // BR-DE-06 - validation.Field(&p.People, - validation.Required, - ), - // BR-DE-05 - validation.Field(&p.Telephones, - validation.Required, - ), - // BR-DE-07 - validation.Field(&p.Emails, - validation.Required, - ), - ) -} - -func validateSupplierTaxInfo(value interface{}) error { - supplier, ok := value.(*org.Party) - if !ok || supplier == nil { - return validation.NewError("invalid_supplier", "Supplier is invalid or nil") - } - - return validation.ValidateStruct(supplier, - validation.Field(&supplier.TaxID, - validation.When(supplier.Identities == nil || org.IdentityForKey(supplier.Identities, "de-tax-number") == nil, - validation.Required, - ), - ), - validation.Field(&supplier.Identities, - validation.When(supplier.TaxID == nil || supplier.TaxID.Code == "", + validation.When( + inv.Type.In( + bill.InvoiceTypeCorrective, + bill.InvoiceTypeCreditNote, + ), validation.Required, - validation.By(validateTaxNumber), - validation.Skip, ), - ), - ) -} - -func validateTaxNumber(value interface{}) error { - identities, ok := value.([]*org.Identity) - if !ok { - return validation.NewError("invalid_identities", "identities are invalid") - } - if org.IdentityForKey(identities, "de-tax-number") == nil { - return validation.NewError("missing_tax_identifier", "tax identifier (de-tax-number) is required") - } - return nil -} - -func validateDelivery(value interface{}) error { - d, _ := value.(*bill.Delivery) - if d == nil { - return nil - } - return validation.ValidateStruct(d, - validation.Field(&d.Receiver, - validation.By(validateCustomerReceiver), validation.Skip, ), ) } -// As the fields for customer and delivery reciver have the same requirements -// they are handled by the same validation function. -func validateCustomerReceiver(value interface{}) error { - p, _ := value.(*org.Party) - if p == nil { +func validateInvoiceTax(value any) error { + tx, ok := value.(*bill.Tax) + if !ok || tx == nil { return nil } - return validation.ValidateStruct(p, - // BR-DE-08, BR-DE-09 - validation.Field(&p.Addresses, - validation.Required, - validation.Each(validation.By(validatePartyAddress)), + return validation.ValidateStruct(tx, + validation.Field(&tx.Ext, + tax.ExtensionsHasValues(untdid.ExtKeyTaxCategory, validInvoiceUNTDIDDocumentTypeValues...), validation.Skip, ), ) } -func validatePartyAddress(value interface{}) error { - addr, _ := value.(*org.Address) - if addr == nil { - return nil - } - return validation.ValidateStruct(addr, - validation.Field(&addr.Locality, - validation.Required, - ), - validation.Field(&addr.Code, - validation.Required, - ), - ) -} diff --git a/addons/de/xrechnung/invoices_test.go b/addons/de/xrechnung/invoices_test.go index 906de81a..6c07ed39 100644 --- a/addons/de/xrechnung/invoices_test.go +++ b/addons/de/xrechnung/invoices_test.go @@ -3,6 +3,7 @@ package xrechnung_test import ( "testing" + _ "github.com/invopop/gobl" "github.com/invopop/gobl/addons/de/xrechnung" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" @@ -17,7 +18,6 @@ import ( func testInvoiceStandard(t *testing.T) *bill.Invoice { t.Helper() - p := num.MakePercentage(19, 2) inv := &bill.Invoice{ Regime: tax.WithRegime("DE"), Addons: tax.WithAddons(xrechnung.V3), @@ -92,8 +92,7 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { Taxes: tax.Set{ { Category: "VAT", - // Rate: "standard", - Percent: &p, + Rate: "standard", }, }, Discounts: []*bill.LineDiscount{ @@ -131,10 +130,12 @@ func TestInvoiceValidation(t *testing.T) { inv := testInvoiceStandard(t) inv.Supplier.TaxID = nil require.NoError(t, inv.Calculate()) - errr := inv.Validate() - assert.ErrorContains(t, errr, "supplier: (identities: cannot be blank; tax_id: cannot be blank.).") + err := inv.Validate() + assert.ErrorContains(t, err, "supplier: (identities: missing key de-tax-number; tax_id: cannot be blank.).") }) t.Run("missing supplier tax ID but has tax number", func(t *testing.T) { + // this is validation is performed in the DE regime, but we're + // leaving it here for completeness. inv := testInvoiceStandard(t) inv.Supplier.TaxID = nil inv.Supplier.Identities = []*org.Identity{ @@ -148,111 +149,4 @@ func TestInvoiceValidation(t *testing.T) { assert.NoError(t, err) }) - t.Run("missing invoice type", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Type = "" - err := inv.Validate() - assert.ErrorContains(t, err, "type: cannot be blank.") - }) - - t.Run("missing payment instructions", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Payment.Instructions = nil - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "payment: (instructions: cannot be blank.).") - }) - - t.Run("missing ordering code", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Ordering.Code = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "ordering: (code: cannot be blank.).") - }) - - t.Run("missing supplier city", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Supplier.Addresses[0].Locality = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "supplier: (addresses: (0: (locality: cannot be blank.).).).") - }) - - t.Run("missing supplier postcode", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Supplier.Addresses[0].Code = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "supplier: (addresses: (0: (code: cannot be blank.).).).") - }) - - t.Run("missing customer city", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Customer.Addresses[0].Locality = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "customer: (addresses: (0: (locality: cannot be blank.).).).") - }) - - t.Run("missing customer postcode", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Customer.Addresses[0].Code = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "customer: (addresses: (0: (code: cannot be blank.).).).") - }) - - t.Run("missing supplier name", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Supplier.Name = "" - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "supplier: (name: cannot be blank.)") - }) - - t.Run("missing delivery address", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Delivery = nil - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.NoError(t, err, "Delivery address should be optional") - }) - - t.Run("incomplete delivery address", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Delivery = &bill.Delivery{ - Receiver: &org.Party{ - Addresses: []*org.Address{ - { - Street: "Delivery Street", - Country: "DE", - }, - }, - }, - } - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.ErrorContains(t, err, "delivery: (receiver: (addresses: (0: (code: cannot be blank; locality: cannot be blank.).).).).") - }) - - t.Run("valid delivery address", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Delivery = &bill.Delivery{ - Receiver: &org.Party{ - Addresses: []*org.Address{ - { - Street: "Delivery Street", - Locality: "Delivery City", - Code: "12345", - Country: "DE", - }, - }, - }, - } - require.NoError(t, inv.Calculate()) - err := inv.Validate() - assert.NoError(t, err, "Valid delivery address should not cause validation errors") - }) - } diff --git a/addons/de/xrechnung/scenarios.go b/addons/de/xrechnung/scenarios.go deleted file mode 100644 index 204868c8..00000000 --- a/addons/de/xrechnung/scenarios.go +++ /dev/null @@ -1,171 +0,0 @@ -package xrechnung - -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/tax" -) - -// Document tag keys -const ( - // Tags for invoice types - TagSelfBilled cbc.Key = "self-billed" - TagPartial cbc.Key = "partial" - TagPartialConstruction cbc.Key = "partial-construction" - TagPartialFinalConstruction cbc.Key = "partial-final-construction" - TagFinalConstruction cbc.Key = "final-construction" -) - -// Invoice type constants -const ( - invoiceTypeSelfBilled = "389" - invoiceTypePartial = "326" - invoiceTypePartialConstruction = "875" - invoiceTypePartialFinalConstruction = "876" - invoiceTypeFinalConstruction = "877" -) - -var invoiceTags = &tax.TagSet{ - Schema: bill.ShortSchemaInvoice, - List: []*cbc.KeyDefinition{ - { - Key: TagSelfBilled, - Name: i18n.String{ - i18n.EN: "Self-billed Invoice", - i18n.DE: "Gutschrift", - }, - }, - { - Key: TagPartial, - Name: i18n.String{ - i18n.EN: "Partial Invoice", - i18n.DE: "Abschlagsrechnung", - }, - }, - { - Key: TagPartialConstruction, - Name: i18n.String{ - i18n.EN: "Partial Construction Invoice", - i18n.DE: "Abschlagsrechnung (Bauleistung)", - }, - }, - { - Key: TagPartialFinalConstruction, - Name: i18n.String{ - i18n.EN: "Partial Final Construction Invoice", - i18n.DE: "Schlussrechnung (Bauleistung)", - }, - }, - { - Key: TagFinalConstruction, - Name: i18n.String{ - i18n.EN: "Final Construction Invoice", - i18n.DE: "Schlussrechnung", - }, - }, - }, -} - -var scenarios = []*tax.ScenarioSet{ - { - Schema: bill.ShortSchemaInvoice, - List: []*tax.Scenario{ - // ** Invoice Document Types ** - { - Types: []cbc.Key{ - bill.InvoiceTypeStandard, - bill.InvoiceTypeCorrective, - bill.InvoiceTypeCreditNote, - bill.InvoiceTypeDebitNote, - }, - }, - { - Tags: []cbc.Key{ - tax.TagSelfBilled, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypeSelfBilled), - }, - }, - { - Tags: []cbc.Key{ - tax.TagPartial, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypePartial), - }, - }, - { - Tags: []cbc.Key{ - tax.TagPartial, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypePartialConstruction), - }, - }, - { - Tags: []cbc.Key{ - tax.TagPartial, - }, - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypePartialFinalConstruction), - }, - }, - { - Ext: tax.Extensions{ - ExtKeyDocType: tax.ExtValue(invoiceTypeFinalConstruction), - }, - }, - // ** Tax Rates ** - { - Tags: []cbc.Key{ - tax.RateStandard, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "S", - }, - }, - { - Tags: []cbc.Key{ - tax.RateZero, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "Z", - }, - }, - { - Tags: []cbc.Key{ - tax.RateExempt, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "E", - }, - }, - { - Tags: []cbc.Key{ - tax.TagReverseCharge, - }, - Ext: tax.Extensions{ - ExtKeyTaxRate: "AE", - }, - }, - // TODO: Map Scenarios - { - Ext: tax.Extensions{ - ExtKeyTaxRate: "K", - }, - }, - { - Ext: tax.Extensions{ - ExtKeyTaxRate: "G", - }, - }, - { - Ext: tax.Extensions{ - ExtKeyTaxRate: "O", - }, - }, - }, - }, -} diff --git a/addons/de/xrechnung/tax_combo.go b/addons/de/xrechnung/tax_combo.go deleted file mode 100644 index 957172ca..00000000 --- a/addons/de/xrechnung/tax_combo.go +++ /dev/null @@ -1,51 +0,0 @@ -package xrechnung - -import ( - "github.com/invopop/gobl/tax" - "github.com/invopop/validation" -) - -// TaxRateExtensions returns the mapping of tax rates defined in DE -// to their extension values used by XRechnung. -func TaxRateExtensions() tax.Extensions { - return taxRateMap -} - -var taxRateMap = tax.Extensions{ - tax.RateStandard: "S", - tax.RateZero: "Z", - tax.RateExempt: "E", -} - -// NormalizeTaxCombo adds the XRechnung tax rate code to the tax combo. -func NormalizeTaxCombo(combo *tax.Combo) { - // copy the SAF-T tax rate code to the line - switch combo.Category { - case tax.CategoryVAT: - if combo.Rate.IsEmpty() { - return - } - k, ok := taxRateMap[combo.Rate] - if !ok { - return - } - if combo.Ext == nil { - combo.Ext = make(tax.Extensions) - } - combo.Ext[ExtKeyTaxRate] = k - } -} - -// ValidateTaxCombo validates percentage is included as BR-DE-14 indicates -func ValidateTaxCombo(tc *tax.Combo) error { - if tc == nil { - return nil - } - // BR-DE-14: Percentage required for VAT - return validation.ValidateStruct(tc, - validation.Field(&tc.Percent, - validation.When(tc.Category == tax.CategoryVAT, - validation.Required), - ), - ) -} diff --git a/addons/de/xrechnung/tax_combo_test.go b/addons/de/xrechnung/tax_combo_test.go deleted file mode 100644 index 48601f19..00000000 --- a/addons/de/xrechnung/tax_combo_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package xrechnung_test - -import ( - "testing" - - "github.com/invopop/gobl/addons/de/xrechnung" - "github.com/invopop/gobl/num" - "github.com/invopop/gobl/tax" - "github.com/stretchr/testify/assert" -) - -func TestTaxComboValidation(t *testing.T) { - t.Run("standard VAT rate", func(t *testing.T) { - p := num.MakePercentage(19, 2) - c := &tax.Combo{ - Category: tax.CategoryVAT, - Rate: tax.RateStandard, - Percent: &p, - } - xrechnung.NormalizeTaxCombo(c) - assert.NoError(t, xrechnung.ValidateTaxCombo(c)) - assert.Equal(t, "S", c.Ext[xrechnung.ExtKeyTaxRate].String()) - assert.Equal(t, "19%", c.Percent.String()) - }) - - t.Run("missing rate", func(t *testing.T) { - c := &tax.Combo{ - Category: tax.CategoryVAT, - Rate: tax.RateStandard, - } - err := xrechnung.ValidateTaxCombo(c) - assert.EqualError(t, err, "percent: cannot be blank.") - }) - -} diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 9ed4a02a..604a97c5 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -2,9 +2,11 @@ package xrechnung import ( + "github.com/invopop/gobl/addons/eu/en16931" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pay" "github.com/invopop/gobl/pkg/here" "github.com/invopop/gobl/tax" ) @@ -22,42 +24,36 @@ func newAddon() *tax.AddonDef { return &tax.AddonDef{ Key: V3, Name: i18n.String{ - i18n.EN: "German XRechnung 3.0.2", + i18n.EN: "German XRechnung 3.X", + }, + Requires: []cbc.Key{ + en16931.V2017, }, Description: i18n.String{ i18n.EN: here.Doc(` - Extensions to support the German XRechnung standard version 3.0.2 for electronic invoicing. - XRechnung is based on the European Norm (EN) 16931 and is mandatory for business-to-government - (B2G) invoices in Germany. This addon provides the necessary structures and validations to - ensure compliance with the XRechnung format. + Support for the German XRechnung version 3.X standard for electronic invoicing. + XRechnung is based on the European Norm (EN) 16931 and is mandatory for business-to-government + (B2G) invoices in Germany. This addon provides the necessary structures and validations to + ensure compliance with the XRechnung specifications. - For more information on XRechnung, visit: - https://www.xrechnung.de/ + For more information on XRechnung, visit [www.xrechnung.de](https://www.xrechnung.de/). `), }, - Tags: []*tax.TagSet{ - invoiceTags, - }, - Scenarios: scenarios, - Extensions: extensions, Normalizer: normalize, Validator: validate, } } func normalize(doc any) { - switch obj := doc.(type) { - case *tax.Combo: - NormalizeTaxCombo(obj) - } + // nothing to normalize yet } func validate(doc any) error { switch obj := doc.(type) { case *bill.Invoice: - return ValidateInvoice(obj) - case *tax.Combo: - return ValidateTaxCombo(obj) + return validateInvoice(obj) + case *pay.Instructions: + return validatePaymentInstructions(obj) } return nil } diff --git a/addons/eu/en16931/bill.go b/addons/eu/en16931/bill.go new file mode 100644 index 00000000..1e41ebff --- /dev/null +++ b/addons/eu/en16931/bill.go @@ -0,0 +1,31 @@ +package en16931 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +func validateBillInvoice(inv *bill.Invoice) error { + return validation.ValidateStruct(inv, + validation.Field(&inv.Tax, + validation.Required, + validation.By(validateBillInvoiceTax), + validation.Skip, + ), + ) +} + +func validateBillInvoiceTax(value any) error { + tx, ok := value.(*bill.Tax) + if !ok || tx == nil { + return nil + } + return validation.ValidateStruct(tx, + validation.Field(&tx.Ext, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + validation.Skip, + ), + ) +} diff --git a/addons/eu/en16931/bill_test.go b/addons/eu/en16931/bill_test.go new file mode 100644 index 00000000..a54abaec --- /dev/null +++ b/addons/eu/en16931/bill_test.go @@ -0,0 +1,116 @@ +package en16931_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/addons/eu/en16931" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvoiceValidation(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("valid invoice", func(t *testing.T) { + inv := testInvoiceStandard(t) + require.NoError(t, inv.Calculate()) + assert.Equal(t, "380", inv.Tax.Ext[untdid.ExtKeyDocumentType].String()) + err := inv.Validate() + assert.NoError(t, err) + }) + t.Run("missing tax", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Type = bill.InvoiceTypeOther + require.NoError(t, inv.Calculate()) + assert.Nil(t, inv.Tax) + err := ad.Validator(inv) + assert.ErrorContains(t, err, "tax: cannot be blank") + }) + t.Run("missing tax document type", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Type = bill.InvoiceTypeOther + inv.Tax = &bill.Tax{PricesInclude: "VAT"} + require.NoError(t, inv.Calculate()) + err := ad.Validator(inv) + assert.ErrorContains(t, err, "tax: (ext: (untdid-document-type: required.).)") + }) +} + +func testInvoiceStandard(t *testing.T) *bill.Invoice { + t.Helper() + inv := &bill.Invoice{ + Regime: tax.WithRegime("DE"), + Addons: tax.WithAddons(en16931.V2017), + Type: "standard", + Currency: "EUR", + Series: "2024", + Code: "1000", + Supplier: &org.Party{ + Name: "Cursor AG", + TaxID: &tax.Identity{ + Country: "DE", + Code: "505898911", + }, + People: []*org.Person{ + { + Name: &org.Name{ + Given: "Peter", + Surname: "Cursorstone", + }, + }, + }, + Addresses: []*org.Address{ + { + Street: "Dietmar-Hopp-Allee", + Locality: "Walldorf", + Code: "69190", + Country: "DE", + }, + }, + }, + Customer: &org.Party{ + Name: "Sample Consumer", + TaxID: &tax.Identity{ + Country: "DE", + Code: "449674701", + }, + People: []*org.Person{ + { + Name: &org.Name{ + Given: "Max", + Surname: "Musterman", + }, + }, + }, + Addresses: []*org.Address{ + { + Street: "Werner-Heisenberg-Allee", + Locality: "München", + Code: "80939", + Country: "DE", + }, + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(10, 0), + Item: &org.Item{ + Name: "Test Item", + Price: num.MakeAmount(10000, 2), + }, + Taxes: tax.Set{ + { + Category: tax.CategoryVAT, + Rate: "standard", + }, + }, + }, + }, + } + return inv +} diff --git a/addons/eu/en16931/en16931.go b/addons/eu/en16931/en16931.go new file mode 100644 index 00000000..5048ab4e --- /dev/null +++ b/addons/eu/en16931/en16931.go @@ -0,0 +1,66 @@ +// Package en16931 defines an addon that will apply rules from the EN 16931 specification to +// GOBL documents. +package en16931 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/tax" +) + +const ( + // V2017 is the key for the EN16931-1:2017 specification. + V2017 cbc.Key = "eu-en16931-v2017" +) + +func init() { + tax.RegisterAddonDef(newAddon()) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V2017, + Name: i18n.String{ + i18n.EN: "EN 16931-1:2017", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Support for the European Norm (EN) 16931-1:2017 standard for electronic invoicing. + + This addon ensures the basic rules and mappings are applied to the GOBL document + ensure that it is compliant and easily convertible to other formats. + `), + }, + Scenarios: scenarios, + Normalizer: normalize, + Validator: validate, + } +} + +func normalize(doc any) { + switch obj := doc.(type) { + case *pay.Advance: + normalizePayAdvance(obj) + case *pay.Instructions: + normalizePayInstructions(obj) + case *tax.Combo: + normalizeTaxCombo(obj) + } +} + +func validate(doc any) error { + switch obj := doc.(type) { + case *pay.Advance: + return validatePayAdvance(obj) + case *pay.Instructions: + return validatePayInstructions(obj) + case *bill.Invoice: + return validateBillInvoice(obj) + case *tax.Combo: + return validateTaxCombo(obj) + } + return nil +} diff --git a/addons/eu/en16931/pay.go b/addons/eu/en16931/pay.go new file mode 100644 index 00000000..05b24e05 --- /dev/null +++ b/addons/eu/en16931/pay.go @@ -0,0 +1,64 @@ +package en16931 + +import ( + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +var paymentMeansMap = tax.Extensions{ + pay.MeansKeyAny: "1", + pay.MeansKeyCard: "48", + pay.MeansKeyCreditTransfer: "30", + pay.MeansKeyDebitTransfer: "31", + pay.MeansKeyCash: "10", + pay.MeansKeyCheque: "20", + pay.MeansKeyBankDraft: "21", + pay.MeansKeyDirectDebit: "49", + pay.MeansKeyOnline: "68", + pay.MeansKeyPromissoryNote: "60", + pay.MeansKeyNetting: "97", + pay.MeansKeyCreditTransfer.With(pay.MeansKeySEPA): "58", + pay.MeansKeyDirectDebit.With(pay.MeansKeySEPA): "59", +} + +func normalizePayAdvance(adv *pay.Advance) { + if adv == nil { + return + } + if val, ok := paymentMeansMap[adv.Key]; ok { + adv.Ext = adv.Ext.Merge( + tax.Extensions{untdid.ExtKeyPaymentMeans: val}, + ) + } +} + +func validatePayAdvance(adv *pay.Advance) error { + return validation.ValidateStruct(adv, + validation.Field(&adv.Ext, + tax.ExtensionsRequires(untdid.ExtKeyPaymentMeans), + validation.Skip, + ), + ) +} + +func normalizePayInstructions(instr *pay.Instructions) { + if instr == nil { + return + } + if val, ok := paymentMeansMap[instr.Key]; ok { + instr.Ext = instr.Ext.Merge( + tax.Extensions{untdid.ExtKeyPaymentMeans: val}, + ) + } +} + +func validatePayInstructions(instr *pay.Instructions) error { + return validation.ValidateStruct(instr, + validation.Field(&instr.Ext, + tax.ExtensionsRequires(untdid.ExtKeyPaymentMeans), + validation.Skip, + ), + ) +} diff --git a/addons/eu/en16931/pay_test.go b/addons/eu/en16931/pay_test.go new file mode 100644 index 00000000..67c5f90c --- /dev/null +++ b/addons/eu/en16931/pay_test.go @@ -0,0 +1,82 @@ +package en16931_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/eu/en16931" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPayAdvances(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + + t.Run("valid advance", func(t *testing.T) { + adv := &pay.Advance{ + Key: pay.MeansKeyCreditTransfer, + } + ad.Normalizer(adv) + assert.Equal(t, "30", adv.Ext[untdid.ExtKeyPaymentMeans].String()) + }) + + t.Run("nil advance", func(t *testing.T) { + var adv *pay.Advance + assert.NotPanics(t, func() { + ad.Normalizer(adv) + }) + }) + + t.Run("validation", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Payment = &bill.Payment{ + Advances: []*pay.Advance{ + { + Key: pay.MeansKeyCreditTransfer, + Description: "Advance payment", + Percent: num.NewPercentage(100, 2), + }, + }, + } + require.NoError(t, inv.Calculate()) + assert.Equal(t, "30", inv.Payment.Advances[0].Ext[untdid.ExtKeyPaymentMeans].String()) + err := inv.Validate() + assert.NoError(t, err) + }) +} + +func TestPayInstructions(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + + t.Run("valid", func(t *testing.T) { + m := &pay.Instructions{ + Key: pay.MeansKeyCreditTransfer, + } + ad.Normalizer(m) + assert.Equal(t, "30", m.Ext[untdid.ExtKeyPaymentMeans].String()) + }) + + t.Run("nil", func(t *testing.T) { + var m *pay.Instructions + assert.NotPanics(t, func() { + ad.Normalizer(m) + }) + }) + + t.Run("validation", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Payment = &bill.Payment{ + Instructions: &pay.Instructions{ + Key: pay.MeansKeyCreditTransfer, + }, + } + require.NoError(t, inv.Calculate()) + assert.Equal(t, "30", inv.Payment.Instructions.Ext[untdid.ExtKeyPaymentMeans].String()) + err := inv.Validate() + assert.NoError(t, err) + }) +} diff --git a/addons/eu/en16931/scenarios.go b/addons/eu/en16931/scenarios.go new file mode 100644 index 00000000..90771e35 --- /dev/null +++ b/addons/eu/en16931/scenarios.go @@ -0,0 +1,85 @@ +package en16931 + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +// Scenarios provides a list of scenarios related to the UNTDID addon +// that can be used inside other addons. +func Scenarios() []*tax.ScenarioSet { + return scenarios +} + +var scenarios = []*tax.ScenarioSet{ + { + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // ** Invoice Document Type Mappings for most common use cases ** + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "380", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "381", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeDebitNote, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "383", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeCorrective, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "384", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeProforma, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "325", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Tags: []cbc.Key{ + tax.TagPartial, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Tags: []cbc.Key{ + tax.TagSelfBilled, + }, + Ext: tax.Extensions{ + untdid.ExtKeyDocumentType: "389", + }, + }, + }, + }, +} diff --git a/addons/eu/en16931/tax_combo.go b/addons/eu/en16931/tax_combo.go new file mode 100644 index 00000000..06a03aff --- /dev/null +++ b/addons/eu/en16931/tax_combo.go @@ -0,0 +1,52 @@ +package en16931 + +import ( + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/regimes/es" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +var taxCategoryMap = tax.Extensions{ + tax.RateStandard: "S", + tax.RateReduced: "S", // Same as standard + tax.RateZero: "Z", + tax.RateExempt: "E", + tax.RateExempt.With(tax.TagReverseCharge): "AE", +} + +func normalizeTaxCombo(tc *tax.Combo) { + switch tc.Category { + case tax.CategoryVAT: + if tc.Rate.IsEmpty() { + return + } + k, ok := taxCategoryMap[tc.Rate] + if !ok { + return + } + tc.Ext = tc.Ext.Merge( + tax.Extensions{untdid.ExtKeyTaxCategory: k}, + ) + case es.TaxCategoryIGIC: + tc.Ext = tc.Ext.Merge( + tax.Extensions{untdid.ExtKeyTaxCategory: "L"}, + ) + case es.TaxCategoryIPSI: + tc.Ext = tc.Ext.Merge( + tax.Extensions{untdid.ExtKeyTaxCategory: "M"}, + ) + } +} + +func validateTaxCombo(tc *tax.Combo) error { + if tc == nil { + return nil + } + return validation.ValidateStruct(tc, + validation.Field(&tc.Ext, + tax.ExtensionsRequires(untdid.ExtKeyTaxCategory), + validation.Skip, + ), + ) +} diff --git a/addons/eu/en16931/tax_combo_test.go b/addons/eu/en16931/tax_combo_test.go new file mode 100644 index 00000000..943588f0 --- /dev/null +++ b/addons/eu/en16931/tax_combo_test.go @@ -0,0 +1,98 @@ +package en16931_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/eu/en16931" + "github.com/invopop/gobl/catalogues/untdid" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/regimes/es" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestTaxComboNormalization(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("standard VAT rate", func(t *testing.T) { + p := num.MakePercentage(19, 2) + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + Percent: &p, + } + ad.Normalizer(c) + assert.Equal(t, "S", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "19%", c.Percent.String()) + }) + + t.Run("unkown rate", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: cbc.Key("unknown"), + Percent: num.NewPercentage(19, 2), + } + ad.Normalizer(c) + assert.Empty(t, c.Ext) + }) + t.Run("IGIC", func(t *testing.T) { + c := &tax.Combo{ + Category: es.TaxCategoryIGIC, + Percent: num.NewPercentage(7, 2), + } + ad.Normalizer(c) + assert.Equal(t, "L", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "7%", c.Percent.String()) + }) + + t.Run("IPSI", func(t *testing.T) { + c := &tax.Combo{ + Category: es.TaxCategoryIPSI, + Percent: num.NewPercentage(7, 2), + } + ad.Normalizer(c) + assert.Equal(t, "M", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "7%", c.Percent.String()) + }) + + t.Run("missing rate", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + } + ad.Normalizer(c) + assert.Empty(t, c.Ext) + }) +} + +func TestTaxComboValidation(t *testing.T) { + ad := tax.AddonForKey(en16931.V2017) + t.Run("standard VAT rate", func(t *testing.T) { + p := num.MakePercentage(19, 2) + c := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + Percent: &p, + } + ad.Normalizer(c) + assert.NoError(t, ad.Validator(c)) + assert.Equal(t, "S", c.Ext[untdid.ExtKeyTaxCategory].String()) + assert.Equal(t, "19%", c.Percent.String()) + }) + + t.Run("nil", func(t *testing.T) { + var tc *tax.Combo + err := ad.Validator(tc) + assert.NoError(t, err) + }) + + t.Run("missing rate", func(t *testing.T) { + c := &tax.Combo{ + Category: tax.CategoryVAT, + Percent: num.NewPercentage(19, 2), + } + ad.Normalizer(c) + err := ad.Validator(c) + assert.ErrorContains(t, err, "ext: (untdid-tax-category: required.)") + }) + +} diff --git a/bill/invoice.go b/bill/invoice.go index b915d8fe..4842ea79 100644 --- a/bill/invoice.go +++ b/bill/invoice.go @@ -266,7 +266,7 @@ func (inv *Invoice) Calculate() error { inv.SetRegime(inv.supplierTaxCountry()) } - inv.Normalize(inv.normalizers()) + inv.Normalize(tax.ExtractNormalizers(inv)) if err := inv.calculate(); err != nil { return err @@ -301,23 +301,12 @@ func (inv *Invoice) Normalize(normalizers tax.Normalizers) { tax.Normalize(normalizers, inv.Payment) } -func (inv *Invoice) normalizers() tax.Normalizers { - normalizers := make(tax.Normalizers, 0) - if r := inv.RegimeDef(); r != nil { - normalizers = normalizers.Append(r.Normalizer) - } - for _, a := range inv.GetAddonDefs() { - normalizers = normalizers.Append(a.Normalizer) - } - return normalizers -} - func (inv *Invoice) supportedTags() []cbc.Key { var ts *tax.TagSet if r := inv.RegimeDef(); r != nil { ts = ts.Merge(tax.TagSetForSchema(r.Tags, ShortSchemaInvoice)) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { ts = ts.Merge(tax.TagSetForSchema(a.Tags, ShortSchemaInvoice)) } return ts.Keys() @@ -329,7 +318,7 @@ func (inv *Invoice) ValidationContext(ctx context.Context) context.Context { if r := inv.RegimeDef(); r != nil { ctx = r.WithContext(ctx) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { ctx = a.WithContext(ctx) } return ctx diff --git a/bill/invoice_correct.go b/bill/invoice_correct.go index 01af9304..82cece22 100644 --- a/bill/invoice_correct.go +++ b/bill/invoice_correct.go @@ -314,7 +314,7 @@ func (inv *Invoice) correctionDef() *tax.CorrectionDefinition { if r != nil { cd = cd.Merge(r.Corrections.Def(ShortSchemaInvoice)) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { cd = cd.Merge(a.Corrections.Def(ShortSchemaInvoice)) } diff --git a/bill/invoice_scenarios.go b/bill/invoice_scenarios.go index dddd3f50..32b1116c 100644 --- a/bill/invoice_scenarios.go +++ b/bill/invoice_scenarios.go @@ -43,7 +43,7 @@ func (inv *Invoice) scenarioSummary() *tax.ScenarioSummary { if r := inv.RegimeDef(); r != nil { ss.Merge(r.Scenarios) } - for _, a := range inv.GetAddonDefs() { + for _, a := range inv.AddonDefs() { ss.Merge(a.Scenarios) } diff --git a/catalogues/catalogues.go b/catalogues/catalogues.go new file mode 100644 index 00000000..19e3534c --- /dev/null +++ b/catalogues/catalogues.go @@ -0,0 +1,10 @@ +// Package catalogues provides a set of re-useable extensions, scenarios, and validators +// for specific international standards that can be re-used and incorporated by addons +// or tax regimes. +package catalogues + +import ( + // Ensure all the catalogues are registered + _ "github.com/invopop/gobl/catalogues/iso" + _ "github.com/invopop/gobl/catalogues/untdid" +) diff --git a/catalogues/generate.go b/catalogues/generate.go new file mode 100644 index 00000000..4edd9abf --- /dev/null +++ b/catalogues/generate.go @@ -0,0 +1,40 @@ +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/schema" + "github.com/invopop/gobl/tax" +) + +func main() { + if error := generate(); error != nil { + panic(error) + } +} + +func generate() error { + for _, cd := range tax.AllCatalogueDefs() { + doc, err := schema.NewObject(cd) + if err != nil { + return err + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + n := string(cd.Key) + f := filepath.Join("data", "catalogues", n+".json") + if err := os.WriteFile(f, data, 0644); err != nil { + return err + } + fmt.Printf("Processed %v\n", f) + } + return nil +} diff --git a/catalogues/iso/extensions.go b/catalogues/iso/extensions.go new file mode 100644 index 00000000..6497ebc7 --- /dev/null +++ b/catalogues/iso/extensions.go @@ -0,0 +1,28 @@ +package iso + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeySchemeID is used by the ISO 6523 scheme identifier. + ExtKeySchemeID cbc.Key = "iso-scheme-id" +) + +var extensions = []*cbc.KeyDefinition{ + { + Key: ExtKeySchemeID, + Name: i18n.NewString("ISO/IEC 6523 Identifier scheme code"), + Desc: i18n.NewString(here.Doc(` + Defines a global structure for uniquely identifying organizations or entities. + This standard is essential in environments where electronic communications require + unambiguous identification of organizations, especially in automated systems or + electronic data interchange (EDI). + + The ISO 6523 set of identifies is used by the EN16931 standard for electronic invoicing. + `)), + Pattern: `^\d{4}$`, + }, +} diff --git a/catalogues/iso/iso.go b/catalogues/iso/iso.go new file mode 100644 index 00000000..1c6b4cfe --- /dev/null +++ b/catalogues/iso/iso.go @@ -0,0 +1,20 @@ +// Package iso is used to define ISO/IEC extensions and codes that may be used +// in documents. +package iso + +import ( + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterCatalogueDef(newCatalogue()) +} + +func newCatalogue() *tax.CatalogueDef { + return &tax.CatalogueDef{ + Key: "iso", + Name: i18n.NewString("ISO/IEC Data Elements"), + Extensions: extensions, + } +} diff --git a/catalogues/iso/iso_test.go b/catalogues/iso/iso_test.go new file mode 100644 index 00000000..2a229c35 --- /dev/null +++ b/catalogues/iso/iso_test.go @@ -0,0 +1,14 @@ +package iso_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestInit(t *testing.T) { + ext := tax.ExtensionForKey("iso-scheme-id") + assert.NotNil(t, ext) +} diff --git a/catalogues/untdid/extensions.go b/catalogues/untdid/extensions.go new file mode 100644 index 00000000..ed2b66d8 --- /dev/null +++ b/catalogues/untdid/extensions.go @@ -0,0 +1,746 @@ +package untdid + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +const ( + // ExtKeyDocumentType is used to identify the UNTDID 1001 document type code. + ExtKeyDocumentType cbc.Key = "untdid-document-type" + // ExtKeyPaymentMeans is used to identify the UNTDID 4461 payment means code. + ExtKeyPaymentMeans cbc.Key = "untdid-payment-means" + // ExtKeyTaxCategory is used to identify the UNTDID 5305 duty/tax/fee category code. + ExtKeyTaxCategory cbc.Key = "untdid-tax-category" +) + +var extensions = []*cbc.KeyDefinition{ + { + Key: ExtKeyDocumentType, + Name: i18n.String{ + i18n.EN: "UNTDID 1001 Document Type", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 1001 code used to describe the type of document. Ths list is based + on the [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists) + values table which focusses on invoices and payments. + + Other tax regimes and addons may use their own subset of codes. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "71", + Name: i18n.NewString("Request for payment"), + }, + { + Value: "80", + Name: i18n.NewString("Debit note related to goods or services"), + }, + { + Value: "81", + Name: i18n.NewString("Credit note related to goods or services"), + }, + { + Value: "82", + Name: i18n.NewString("Metered services invoice"), + }, + { + Value: "83", + Name: i18n.NewString("Credit note related to financial adjustments"), + }, + { + Value: "84", + Name: i18n.NewString("Debit note related to financial adjustments"), + }, + { + Value: "102", + Name: i18n.NewString("Tax notification"), + }, + { + Value: "130", + Name: i18n.NewString("Invoicing data sheet"), + }, + { + Value: "202", + Name: i18n.NewString("Direct payment valuation"), + }, + { + Value: "203", + Name: i18n.NewString("Provisional payment valuation"), + }, + { + Value: "204", + Name: i18n.NewString("Payment valuation"), + }, + { + Value: "211", + Name: i18n.NewString("Interim application for payment"), + }, + { + Value: "218", + Name: i18n.NewString("Final payment request based on completion of work"), + }, + { + Value: "219", + Name: i18n.NewString("Payment request for completed units"), + }, + { + Value: "261", + Name: i18n.NewString("Self billed credit note"), + }, + { + Value: "262", + Name: i18n.NewString("Consolidated credit note - goods and services"), + }, + { + Value: "295", + Name: i18n.NewString("Price variation invoice"), + }, + { + Value: "296", + Name: i18n.NewString("Credit note for price variation"), + }, + { + Value: "308", + Name: i18n.NewString("Delcredere credit note"), + }, + { + Value: "325", + Name: i18n.NewString("Proforma invoice"), + }, + { + Value: "326", + Name: i18n.NewString("Partial invoice"), + }, + { + Value: "380", + Name: i18n.NewString("Standard Invoice"), + }, + { + Value: "381", + Name: i18n.NewString("Credit note"), + }, + { + Value: "382", + Name: i18n.NewString("Commission note"), + }, + { + Value: "383", + Name: i18n.NewString("Debit note"), + }, + { + Value: "384", + Name: i18n.NewString("Corrected invoice"), + }, + { + Value: "385", + Name: i18n.NewString("Consolidated invoice"), + }, + { + Value: "386", + Name: i18n.NewString("Prepayment invoice"), + }, + { + Value: "387", + Name: i18n.NewString("Hire invoice"), + }, + { + Value: "388", + Name: i18n.NewString("Tax invoice"), + }, + { + Value: "389", + Name: i18n.NewString("Self-billed invoice"), + }, + { + Value: "390", + Name: i18n.NewString("Delcredere invoice"), + }, + { + Value: "393", + Name: i18n.NewString("Factored invoice"), + }, + { + Value: "394", + Name: i18n.NewString("Lease invoice"), + }, + { + Value: "395", + Name: i18n.NewString("Consignment invoice"), + }, + { + Value: "396", + Name: i18n.NewString("Factored credit note"), + }, + { + Value: "420", + Name: i18n.NewString("Optical Character Reading (OCR) payment credit note"), + }, + { + Value: "456", + Name: i18n.NewString("Debit advice"), + }, + { + Value: "457", + Name: i18n.NewString("Reversal of debit"), + }, + { + Value: "458", + Name: i18n.NewString("Reversal of credit"), + }, + { + Value: "527", + Name: i18n.NewString("Self billed debit note"), + }, + { + Value: "532", + Name: i18n.NewString("Forwarder's credit note"), + }, + { + Value: "553", + Name: i18n.NewString("Forwarder's invoice discrepancy report"), + }, + { + Value: "575", + Name: i18n.NewString("Insurer's invoice"), + }, + { + Value: "623", + Name: i18n.NewString("Forwarder's invoice"), + }, + { + Value: "633", + Name: i18n.NewString("Port charges documents"), + }, + { + Value: "751", + Name: i18n.NewString("Invoice information for accounting purposes"), + }, + { + Value: "780", + Name: i18n.NewString("Freight invoice"), + }, + { + Value: "817", + Name: i18n.NewString("Claim notification"), + }, + { + Value: "870", + Name: i18n.NewString("Consular invoice"), + }, + { + Value: "875", + Name: i18n.NewString("Partial construction invoice"), + }, + { + Value: "876", + Name: i18n.NewString("Partial final construction invoice"), + }, + { + Value: "877", + Name: i18n.NewString("Final construction invoice"), + }, + { + Value: "935", + Name: i18n.NewString("Customs invoice"), + }, + }, + }, + { + Key: ExtKeyPaymentMeans, + Name: i18n.String{ + i18n.EN: "UNTDID 4461 Payment Means", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 4461 code used to describe the means of payment. This list is based on the + [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists) + values table which focusses on invoices and payments. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "1", + Name: i18n.NewString("Instrument not defined"), + }, + { + Value: "2", + Name: i18n.NewString("Automated clearing house credit"), + }, + { + Value: "3", + Name: i18n.NewString("Automated clearing house debit"), + }, + { + Value: "4", + Name: i18n.NewString("ACH demand debit reversal"), + }, + { + Value: "5", + Name: i18n.NewString("ACH demand credit reversal"), + }, + { + Value: "6", + Name: i18n.NewString("ACH demand credit"), + }, + { + Value: "7", + Name: i18n.NewString("ACH demand debit"), + }, + { + Value: "8", + Name: i18n.NewString("Hold"), + }, + { + Value: "9", + Name: i18n.NewString("National or regional clearing"), + }, + { + Value: "10", + Name: i18n.NewString("In cash"), + }, + { + Value: "11", + Name: i18n.NewString("ACH savings credit reversal"), + }, + { + Value: "12", + Name: i18n.NewString("ACH savings debit reversal"), + }, + { + Value: "13", + Name: i18n.NewString("ACH savings credit"), + }, + { + Value: "14", + Name: i18n.NewString("ACH savings debit"), + }, + { + Value: "15", + Name: i18n.NewString("Bookentry credit"), + }, + { + Value: "16", + Name: i18n.NewString("Bookentry debit"), + }, + { + Value: "17", + Name: i18n.NewString("ACH demand cash concentration/disbursement (CCD) credit"), + }, + { + Value: "18", + Name: i18n.NewString("ACH demand cash concentration/disbursement (CCD) debit"), + }, + { + Value: "19", + Name: i18n.NewString("ACH demand corporate trade payment (CTP) credit"), + }, + { + Value: "20", + Name: i18n.NewString("Cheque"), + }, + { + Value: "21", + Name: i18n.NewString("Banker's draft"), + }, + { + Value: "22", + Name: i18n.NewString("Certified banker's draft"), + }, + { + Value: "23", + Name: i18n.NewString("Bank cheque (issued by a banking or similar establishment)"), + }, + { + Value: "24", + Name: i18n.NewString("Bill of exchange awaiting acceptance"), + }, + { + Value: "25", + Name: i18n.NewString("Certified cheque"), + }, + { + Value: "26", + Name: i18n.NewString("Local cheque"), + }, + { + Value: "27", + Name: i18n.NewString("ACH demand corporate trade payment (CTP) debit"), + }, + { + Value: "28", + Name: i18n.NewString("ACH demand corporate trade exchange (CTX) credit"), + }, + { + Value: "29", + Name: i18n.NewString("ACH demand corporate trade exchange (CTX) debit"), + }, + { + Value: "30", + Name: i18n.NewString("Credit transfer"), + }, + { + Value: "31", + Name: i18n.NewString("Debit transfer"), + }, + { + Value: "32", + Name: i18n.NewString("ACH demand cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "33", + Name: i18n.NewString("ACH demand cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "34", + Name: i18n.NewString("ACH prearranged payment and deposit (PPD)"), + }, + { + Value: "35", + Name: i18n.NewString("ACH savings cash concentration/disbursement (CCD) credit"), + }, + { + Value: "36", + Name: i18n.NewString("ACH savings cash concentration/disbursement (CCD) debit"), + }, + { + Value: "37", + Name: i18n.NewString("ACH savings corporate trade payment (CTP) credit"), + }, + { + Value: "38", + Name: i18n.NewString("ACH savings corporate trade payment (CTP) debit"), + }, + { + Value: "39", + Name: i18n.NewString("ACH savings corporate trade exchange (CTX) credit"), + }, + { + Value: "40", + Name: i18n.NewString("ACH savings corporate trade exchange (CTX) debit"), + }, + { + Value: "41", + Name: i18n.NewString("ACH savings cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "42", + Name: i18n.NewString("Payment to bank account"), + }, + { + Value: "43", + Name: i18n.NewString("ACH savings cash concentration/disbursement plus (CCD+)"), + }, + { + Value: "44", + Name: i18n.NewString("Accepted bill of exchange"), + }, + { + Value: "45", + Name: i18n.NewString("Referenced home-banking credit transfer"), + }, + { + Value: "46", + Name: i18n.NewString("Interbank debit transfer"), + }, + { + Value: "47", + Name: i18n.NewString("Home-banking debit transfer"), + }, + { + Value: "48", + Name: i18n.NewString("Bank card"), + }, + { + Value: "49", + Name: i18n.NewString("Direct debit"), + }, + { + Value: "50", + Name: i18n.NewString("Payment by postgiro"), + }, + { + Value: "51", + Name: i18n.NewString("FR, norme 6 97-Telereglement CFONB (French Organisation for"), + }, + { + Value: "52", + Name: i18n.NewString("Urgent commercial payment"), + }, + { + Value: "53", + Name: i18n.NewString("Urgent Treasury Payment"), + }, + { + Value: "54", + Name: i18n.NewString("Credit card"), + }, + { + Value: "55", + Name: i18n.NewString("Debit card"), + }, + { + Value: "56", + Name: i18n.NewString("Bankgiro"), + }, + { + Value: "57", + Name: i18n.NewString("Standing agreement"), + }, + { + Value: "58", + Name: i18n.NewString("SEPA credit transfer"), + }, + { + Value: "59", + Name: i18n.NewString("SEPA direct debit"), + }, + { + Value: "60", + Name: i18n.NewString("Promissory note"), + }, + { + Value: "61", + Name: i18n.NewString("Promissory note signed by the debtor"), + }, + { + Value: "62", + Name: i18n.NewString("Promissory note signed by the debtor and endorsed by a bank"), + }, + { + Value: "63", + Name: i18n.NewString("Promissory note signed by the debtor and endorsed by a"), + }, + { + Value: "64", + Name: i18n.NewString("Promissory note signed by a bank"), + }, + { + Value: "65", + Name: i18n.NewString("Promissory note signed by a bank and endorsed by another"), + }, + { + Value: "66", + Name: i18n.NewString("Promissory note signed by a third party"), + }, + { + Value: "67", + Name: i18n.NewString("Promissory note signed by a third party and endorsed by a"), + }, + { + Value: "68", + Name: i18n.NewString("Online payment service"), + }, + { + Value: "69", + Name: i18n.NewString("Transfer Advice"), + }, + { + Value: "70", + Name: i18n.NewString("Bill drawn by the creditor on the debtor"), + }, + { + Value: "74", + Name: i18n.NewString("Bill drawn by the creditor on a bank"), + }, + { + Value: "75", + Name: i18n.NewString("Bill drawn by the creditor, endorsed by another bank"), + }, + { + Value: "76", + Name: i18n.NewString("Bill drawn by the creditor on a bank and endorsed by a"), + }, + { + Value: "77", + Name: i18n.NewString("Bill drawn by the creditor on a third party"), + }, + { + Value: "78", + Name: i18n.NewString("Bill drawn by creditor on third party, accepted and"), + }, + { + Value: "91", + Name: i18n.NewString("Not transferable banker's draft"), + }, + { + Value: "92", + Name: i18n.NewString("Not transferable local cheque"), + }, + { + Value: "93", + Name: i18n.NewString("Reference giro"), + }, + { + Value: "94", + Name: i18n.NewString("Urgent giro"), + }, + { + Value: "95", + Name: i18n.NewString("Free format giro"), + }, + { + Value: "96", + Name: i18n.NewString("Requested method for payment was not used"), + }, + { + Value: "97", + Name: i18n.NewString("Clearing between partners"), + }, + { + Value: "98", + Name: i18n.NewString("JP, Electronically Recorded Monetary Claims"), + }, + { + Value: "ZZZ", + Name: i18n.NewString("Mutually defined"), + }, + }, + }, + { + Key: ExtKeyTaxCategory, + Name: i18n.String{ + i18n.EN: "UNTDID 3505 Tax Category", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + UNTDID 5305 code used to describe the applicable duty/tax/fee category. There are + multiple versions and subsets of this table so regimes and addons may need to filter + options for a specific subset of values. + + Data from https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5305.htm. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "A", + Name: i18n.String{ + i18n.EN: "Mixed tax rate", + }, + }, + { + Value: "AA", + Name: i18n.String{ + i18n.EN: "Lower rate", + }, + }, + { + Value: "AB", + Name: i18n.String{ + i18n.EN: "Exempt for resale", + }, + }, + { + Value: "AC", + Name: i18n.String{ + i18n.EN: "Exempt for resale", + }, + }, + { + Value: "AD", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) due from a previous invoice", + }, + }, + { + Value: "AE", + Name: i18n.String{ + i18n.EN: "VAT Reverse Charge", + }, + }, + { + Value: "B", + Name: i18n.String{ + i18n.EN: "Transferred (VAT)", + }, + }, + { + Value: "C", + Name: i18n.String{ + i18n.EN: "Duty paid by supplier", + }, + }, + { + Value: "D", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - travel agents", + }, + }, + { + Value: "E", + Name: i18n.String{ + i18n.EN: "Exempt from tax", + }, + }, + { + Value: "F", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - second-hand goods", + }, + }, + { + Value: "G", + Name: i18n.String{ + i18n.EN: "Free export item, tax not charged", + }, + }, + { + Value: "H", + Name: i18n.String{ + i18n.EN: "Higher rate", + }, + }, + { + Value: "I", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - works of art", + }, + }, + { + Value: "J", + Name: i18n.String{ + i18n.EN: "Value Added Tax (VAT) margin scheme - collector's items and antiques", + }, + }, + { + Value: "K", + Name: i18n.String{ + i18n.EN: "VAT exempt for EEA intra-community supply of goods and services", + }, + }, + { + Value: "L", + Name: i18n.String{ + i18n.EN: "Canary Islands general indirect tax", + }, + }, + { + Value: "M", + Name: i18n.String{ + i18n.EN: "Tax for production, services and importation in Ceuta and Melilla", + }, + }, + { + Value: "O", + Name: i18n.String{ + i18n.EN: "Services outside scope of tax", + }, + }, + { + Value: "S", + Name: i18n.String{ + i18n.EN: "Standard Rate", + }, + }, + { + Value: "Z", + Name: i18n.String{ + i18n.EN: "Zero rated goods", + }, + }, + }, + }, +} diff --git a/catalogues/untdid/untdid.go b/catalogues/untdid/untdid.go new file mode 100644 index 00000000..57cfdf67 --- /dev/null +++ b/catalogues/untdid/untdid.go @@ -0,0 +1,19 @@ +// Package untdid defines the UN/EDIFACT data elements contained in the UNTDID (United Nations Trade Data Interchange Directory). +package untdid + +import ( + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterCatalogueDef(newCatalogue()) +} + +func newCatalogue() *tax.CatalogueDef { + return &tax.CatalogueDef{ + Key: "untdid", + Name: i18n.NewString("UN/EDIFACT Data Elements"), + Extensions: extensions, + } +} diff --git a/catalogues/untdid/untdid_test.go b/catalogues/untdid/untdid_test.go new file mode 100644 index 00000000..6b415558 --- /dev/null +++ b/catalogues/untdid/untdid_test.go @@ -0,0 +1,14 @@ +package untdid_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestInit(t *testing.T) { + ext := tax.ExtensionForKey("untdid-tax-category") + assert.NotNil(t, ext) +} diff --git a/cbc/code.go b/cbc/code.go index 28da0ac7..34a67f57 100644 --- a/cbc/code.go +++ b/cbc/code.go @@ -41,8 +41,10 @@ var ( ) var ( - codeSeparatorRegexp = regexp.MustCompile(`([\.\-\/ _])[^A-Za-z0-9]+`) - codeInvalidCharsRegexp = regexp.MustCompile(`[^A-Za-z0-9\.\-\/ _]`) + codeSeparatorRegexp = regexp.MustCompile(`([\.\-\/ _])[^A-Za-z0-9]+`) + codeInvalidCharsRegexp = regexp.MustCompile(`[^A-Za-z0-9\.\-\/ _]`) + codeNonAlphanumericalRegexp = regexp.MustCompile(`[^A-Z\d]`) + codeNonNumericalRegexp = regexp.MustCompile(`[^\d]`) ) // CodeEmpty is used when no code is defined. @@ -58,6 +60,24 @@ func NormalizeCode(c Code) Code { return Code(code) } +// NormalizeAlphanumericalCode cleans and normalizes the code, +// ensuring all letters are uppercase while also removing +// non-alphanumerical characters. +func NormalizeAlphanumericalCode(c Code) Code { + code := NormalizeCode(c).String() + code = strings.ToUpper(code) + code = codeNonAlphanumericalRegexp.ReplaceAllString(code, "") + return Code(code) +} + +// NormalizeNumericalCode cleans and normalizes the code, while also +// removing non-numerical characters. +func NormalizeNumericalCode(c Code) Code { + code := NormalizeCode(c).String() + code = codeNonNumericalRegexp.ReplaceAllString(code, "") + return Code(code) +} + // Validate ensures that the code complies with the expected rules. func (c Code) Validate() error { return validation.Validate(string(c), diff --git a/cbc/code_test.go b/cbc/code_test.go index 7de0649e..45505643 100644 --- a/cbc/code_test.go +++ b/cbc/code_test.go @@ -109,7 +109,134 @@ func TestNormalizeCode(t *testing.T) { assert.Equal(t, tt.want, cbc.NormalizeCode(tt.code)) }) } +} +func TestNormalizeAlphanumericalCode(t *testing.T) { + tests := []struct { + name string + code cbc.Code + want cbc.Code + }{ + { + name: "uppercase", + code: cbc.Code("FOO"), + want: cbc.Code("FOO"), + }, + { + name: "lowercase", + code: cbc.Code("foo"), + want: cbc.Code("FOO"), + }, + { + name: "mixed case", + code: cbc.Code("Foo"), + want: cbc.Code("FOO"), + }, + { + name: "with spaces", + code: cbc.Code("FOO BAR"), + want: cbc.Code("FOOBAR"), + }, + { + name: "empty", + code: cbc.Code(""), + want: cbc.Code(""), + }, + { + name: "underscore", + code: cbc.Code("FOO_BAR"), + want: cbc.Code("FOOBAR"), + }, + { + name: "whitespace", + code: cbc.Code(" foo-bar1 "), + want: cbc.Code("FOOBAR1"), + }, + { + name: "invalid chars", + code: cbc.Code("f$oo-bar1!"), + want: cbc.Code("FOOBAR1"), + }, + { + name: "multiple spaces", + code: cbc.Code("foo bar dome"), + want: cbc.Code("FOOBARDOME"), + }, + { + name: "multiple symbols 1", + code: cbc.Code("foo- bar-$dome"), + want: cbc.Code("FOOBARDOME"), + }, + { + name: "multiple symbols 2", + code: cbc.Code("FOO BAR--DOME"), + want: cbc.Code("FOOBARDOME"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, cbc.NormalizeAlphanumericalCode(tt.code)) + }) + } +} + +func TestNormalizeNumericalCode(t *testing.T) { + tests := []struct { + name string + code cbc.Code + want cbc.Code + }{ + { + name: "letters", + code: cbc.Code("FOO"), + want: cbc.Code(""), + }, + { + name: "numbers", + code: cbc.Code("1234"), + want: cbc.Code("1234"), + }, + { + name: "mixed case", + code: cbc.Code("Foo1234"), + want: cbc.Code("1234"), + }, + { + name: "with spaces", + code: cbc.Code("12 34"), + want: cbc.Code("1234"), + }, + { + name: "empty", + code: cbc.Code(""), + want: cbc.Code(""), + }, + { + name: "underscore", + code: cbc.Code("12_34"), + want: cbc.Code("1234"), + }, + { + name: "whitespace", + code: cbc.Code(" 345 "), + want: cbc.Code("345"), + }, + { + name: "invalid chars", + code: cbc.Code("f$oo-bar1!"), + want: cbc.Code("1"), + }, + { + name: "multiple spaces", + code: cbc.Code("1 2 3 4"), + want: cbc.Code("1234"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, cbc.NormalizeNumericalCode(tt.code)) + }) + } } func TestCode_Validate(t *testing.T) { diff --git a/cbc/key.go b/cbc/key.go index f2d6ffb5..ef571bd4 100644 --- a/cbc/key.go +++ b/cbc/key.go @@ -102,6 +102,17 @@ func (k Key) IsEmpty() bool { return k == KeyEmpty } +// AppendUniqueKeys is a convenience method to append keys to a list ensuring +// that any existing keys are not re-added. +func AppendUniqueKeys(keys []Key, key ...Key) []Key { + for _, k := range key { + if !k.In(keys...) { + keys = append(keys, k) + } + } + return keys +} + // HasValidKeyIn provides a validator to check the Key's // value is within the provided known set. func HasValidKeyIn(keys ...Key) validation.Rule { diff --git a/cbc/key_test.go b/cbc/key_test.go index 8d70ba36..928a2a09 100644 --- a/cbc/key_test.go +++ b/cbc/key_test.go @@ -65,3 +65,9 @@ func TestKeyIn(t *testing.T) { assert.True(t, c.In("pro", "reduced+eqs", "standard")) assert.False(t, c.In("pro", "reduced")) } + +func TestAppendUniqueKeys(t *testing.T) { + keys := []cbc.Key{"a", "b", "c"} + keys = cbc.AppendUniqueKeys(keys, "b", "d") + assert.Equal(t, []cbc.Key{"a", "b", "c", "d"}, keys) +} diff --git a/data/addons/de-xrechnung-v3.json b/data/addons/de-xrechnung-v3.json new file mode 100644 index 00000000..5e66dd25 --- /dev/null +++ b/data/addons/de-xrechnung-v3.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/addon-def", + "key": "de-xrechnung-v3", + "requires": [ + "eu-en16931-v2017" + ], + "name": { + "en": "German XRechnung 3.X" + }, + "description": { + "en": "Support for the German XRechnung version 3.X standard for electronic invoicing.\nXRechnung is based on the European Norm (EN) 16931 and is mandatory for business-to-government\n(B2G) invoices in Germany. This addon provides the necessary structures and validations to\nensure compliance with the XRechnung specifications.\n\nFor more information on XRechnung, visit [www.xrechnung.de](https://www.xrechnung.de/)." + }, + "extensions": null, + "scenarios": null, + "corrections": null +} \ No newline at end of file diff --git a/data/catalogues/iso.json b/data/catalogues/iso.json new file mode 100644 index 00000000..92f4a66c --- /dev/null +++ b/data/catalogues/iso.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/catalogue-def", + "key": "iso", + "name": { + "en": "ISO/IEC Data Elements" + }, + "description": null, + "extensions": [ + { + "key": "iso-scheme-id", + "name": { + "en": "ISO/IEC 6523 Identifier scheme code" + }, + "desc": { + "en": "Defines a global structure for uniquely identifying organizations or entities.\nThis standard is essential in environments where electronic communications require\nunambiguous identification of organizations, especially in automated systems or\nelectronic data interchange (EDI).\n\nThe ISO 6523 set of identifies is used by the EN16931 standard for electronic invoicing." + }, + "pattern": "^\\d{4}$" + } + ] +} \ No newline at end of file diff --git a/data/catalogues/untdid.json b/data/catalogues/untdid.json new file mode 100644 index 00000000..b3074f37 --- /dev/null +++ b/data/catalogues/untdid.json @@ -0,0 +1,997 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/catalogue-def", + "key": "untdid", + "name": { + "en": "UN/EDIFACT Data Elements" + }, + "description": null, + "extensions": [ + { + "key": "untdid-document-type", + "name": { + "en": "UNTDID 1001 Document Type" + }, + "desc": { + "en": "UNTDID 1001 code used to describe the type of document. Ths list is based\non the [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists)\nvalues table which focusses on invoices and payments.\n\nOther tax regimes and addons may use their own subset of codes." + }, + "values": [ + { + "value": "71", + "name": { + "en": "Request for payment" + } + }, + { + "value": "80", + "name": { + "en": "Debit note related to goods or services" + } + }, + { + "value": "81", + "name": { + "en": "Credit note related to goods or services" + } + }, + { + "value": "82", + "name": { + "en": "Metered services invoice" + } + }, + { + "value": "83", + "name": { + "en": "Credit note related to financial adjustments" + } + }, + { + "value": "84", + "name": { + "en": "Debit note related to financial adjustments" + } + }, + { + "value": "102", + "name": { + "en": "Tax notification" + } + }, + { + "value": "130", + "name": { + "en": "Invoicing data sheet" + } + }, + { + "value": "202", + "name": { + "en": "Direct payment valuation" + } + }, + { + "value": "203", + "name": { + "en": "Provisional payment valuation" + } + }, + { + "value": "204", + "name": { + "en": "Payment valuation" + } + }, + { + "value": "211", + "name": { + "en": "Interim application for payment" + } + }, + { + "value": "218", + "name": { + "en": "Final payment request based on completion of work" + } + }, + { + "value": "219", + "name": { + "en": "Payment request for completed units" + } + }, + { + "value": "261", + "name": { + "en": "Self billed credit note" + } + }, + { + "value": "262", + "name": { + "en": "Consolidated credit note - goods and services" + } + }, + { + "value": "295", + "name": { + "en": "Price variation invoice" + } + }, + { + "value": "296", + "name": { + "en": "Credit note for price variation" + } + }, + { + "value": "308", + "name": { + "en": "Delcredere credit note" + } + }, + { + "value": "325", + "name": { + "en": "Proforma invoice" + } + }, + { + "value": "326", + "name": { + "en": "Partial invoice" + } + }, + { + "value": "380", + "name": { + "en": "Standard Invoice" + } + }, + { + "value": "381", + "name": { + "en": "Credit note" + } + }, + { + "value": "382", + "name": { + "en": "Commission note" + } + }, + { + "value": "383", + "name": { + "en": "Debit note" + } + }, + { + "value": "384", + "name": { + "en": "Corrected invoice" + } + }, + { + "value": "385", + "name": { + "en": "Consolidated invoice" + } + }, + { + "value": "386", + "name": { + "en": "Prepayment invoice" + } + }, + { + "value": "387", + "name": { + "en": "Hire invoice" + } + }, + { + "value": "388", + "name": { + "en": "Tax invoice" + } + }, + { + "value": "389", + "name": { + "en": "Self-billed invoice" + } + }, + { + "value": "390", + "name": { + "en": "Delcredere invoice" + } + }, + { + "value": "393", + "name": { + "en": "Factored invoice" + } + }, + { + "value": "394", + "name": { + "en": "Lease invoice" + } + }, + { + "value": "395", + "name": { + "en": "Consignment invoice" + } + }, + { + "value": "396", + "name": { + "en": "Factored credit note" + } + }, + { + "value": "420", + "name": { + "en": "Optical Character Reading (OCR) payment credit note" + } + }, + { + "value": "456", + "name": { + "en": "Debit advice" + } + }, + { + "value": "457", + "name": { + "en": "Reversal of debit" + } + }, + { + "value": "458", + "name": { + "en": "Reversal of credit" + } + }, + { + "value": "527", + "name": { + "en": "Self billed debit note" + } + }, + { + "value": "532", + "name": { + "en": "Forwarder's credit note" + } + }, + { + "value": "553", + "name": { + "en": "Forwarder's invoice discrepancy report" + } + }, + { + "value": "575", + "name": { + "en": "Insurer's invoice" + } + }, + { + "value": "623", + "name": { + "en": "Forwarder's invoice" + } + }, + { + "value": "633", + "name": { + "en": "Port charges documents" + } + }, + { + "value": "751", + "name": { + "en": "Invoice information for accounting purposes" + } + }, + { + "value": "780", + "name": { + "en": "Freight invoice" + } + }, + { + "value": "817", + "name": { + "en": "Claim notification" + } + }, + { + "value": "870", + "name": { + "en": "Consular invoice" + } + }, + { + "value": "875", + "name": { + "en": "Partial construction invoice" + } + }, + { + "value": "876", + "name": { + "en": "Partial final construction invoice" + } + }, + { + "value": "877", + "name": { + "en": "Final construction invoice" + } + }, + { + "value": "935", + "name": { + "en": "Customs invoice" + } + } + ] + }, + { + "key": "untdid-payment-means", + "name": { + "en": "UNTDID 4461 Payment Means" + }, + "desc": { + "en": "UNTDID 4461 code used to describe the means of payment. This list is based on the\n[EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists)\nvalues table which focusses on invoices and payments." + }, + "values": [ + { + "value": "1", + "name": { + "en": "Instrument not defined" + } + }, + { + "value": "2", + "name": { + "en": "Automated clearing house credit" + } + }, + { + "value": "3", + "name": { + "en": "Automated clearing house debit" + } + }, + { + "value": "4", + "name": { + "en": "ACH demand debit reversal" + } + }, + { + "value": "5", + "name": { + "en": "ACH demand credit reversal" + } + }, + { + "value": "6", + "name": { + "en": "ACH demand credit" + } + }, + { + "value": "7", + "name": { + "en": "ACH demand debit" + } + }, + { + "value": "8", + "name": { + "en": "Hold" + } + }, + { + "value": "9", + "name": { + "en": "National or regional clearing" + } + }, + { + "value": "10", + "name": { + "en": "In cash" + } + }, + { + "value": "11", + "name": { + "en": "ACH savings credit reversal" + } + }, + { + "value": "12", + "name": { + "en": "ACH savings debit reversal" + } + }, + { + "value": "13", + "name": { + "en": "ACH savings credit" + } + }, + { + "value": "14", + "name": { + "en": "ACH savings debit" + } + }, + { + "value": "15", + "name": { + "en": "Bookentry credit" + } + }, + { + "value": "16", + "name": { + "en": "Bookentry debit" + } + }, + { + "value": "17", + "name": { + "en": "ACH demand cash concentration/disbursement (CCD) credit" + } + }, + { + "value": "18", + "name": { + "en": "ACH demand cash concentration/disbursement (CCD) debit" + } + }, + { + "value": "19", + "name": { + "en": "ACH demand corporate trade payment (CTP) credit" + } + }, + { + "value": "20", + "name": { + "en": "Cheque" + } + }, + { + "value": "21", + "name": { + "en": "Banker's draft" + } + }, + { + "value": "22", + "name": { + "en": "Certified banker's draft" + } + }, + { + "value": "23", + "name": { + "en": "Bank cheque (issued by a banking or similar establishment)" + } + }, + { + "value": "24", + "name": { + "en": "Bill of exchange awaiting acceptance" + } + }, + { + "value": "25", + "name": { + "en": "Certified cheque" + } + }, + { + "value": "26", + "name": { + "en": "Local cheque" + } + }, + { + "value": "27", + "name": { + "en": "ACH demand corporate trade payment (CTP) debit" + } + }, + { + "value": "28", + "name": { + "en": "ACH demand corporate trade exchange (CTX) credit" + } + }, + { + "value": "29", + "name": { + "en": "ACH demand corporate trade exchange (CTX) debit" + } + }, + { + "value": "30", + "name": { + "en": "Credit transfer" + } + }, + { + "value": "31", + "name": { + "en": "Debit transfer" + } + }, + { + "value": "32", + "name": { + "en": "ACH demand cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "33", + "name": { + "en": "ACH demand cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "34", + "name": { + "en": "ACH prearranged payment and deposit (PPD)" + } + }, + { + "value": "35", + "name": { + "en": "ACH savings cash concentration/disbursement (CCD) credit" + } + }, + { + "value": "36", + "name": { + "en": "ACH savings cash concentration/disbursement (CCD) debit" + } + }, + { + "value": "37", + "name": { + "en": "ACH savings corporate trade payment (CTP) credit" + } + }, + { + "value": "38", + "name": { + "en": "ACH savings corporate trade payment (CTP) debit" + } + }, + { + "value": "39", + "name": { + "en": "ACH savings corporate trade exchange (CTX) credit" + } + }, + { + "value": "40", + "name": { + "en": "ACH savings corporate trade exchange (CTX) debit" + } + }, + { + "value": "41", + "name": { + "en": "ACH savings cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "42", + "name": { + "en": "Payment to bank account" + } + }, + { + "value": "43", + "name": { + "en": "ACH savings cash concentration/disbursement plus (CCD+)" + } + }, + { + "value": "44", + "name": { + "en": "Accepted bill of exchange" + } + }, + { + "value": "45", + "name": { + "en": "Referenced home-banking credit transfer" + } + }, + { + "value": "46", + "name": { + "en": "Interbank debit transfer" + } + }, + { + "value": "47", + "name": { + "en": "Home-banking debit transfer" + } + }, + { + "value": "48", + "name": { + "en": "Bank card" + } + }, + { + "value": "49", + "name": { + "en": "Direct debit" + } + }, + { + "value": "50", + "name": { + "en": "Payment by postgiro" + } + }, + { + "value": "51", + "name": { + "en": "FR, norme 6 97-Telereglement CFONB (French Organisation for" + } + }, + { + "value": "52", + "name": { + "en": "Urgent commercial payment" + } + }, + { + "value": "53", + "name": { + "en": "Urgent Treasury Payment" + } + }, + { + "value": "54", + "name": { + "en": "Credit card" + } + }, + { + "value": "55", + "name": { + "en": "Debit card" + } + }, + { + "value": "56", + "name": { + "en": "Bankgiro" + } + }, + { + "value": "57", + "name": { + "en": "Standing agreement" + } + }, + { + "value": "58", + "name": { + "en": "SEPA credit transfer" + } + }, + { + "value": "59", + "name": { + "en": "SEPA direct debit" + } + }, + { + "value": "60", + "name": { + "en": "Promissory note" + } + }, + { + "value": "61", + "name": { + "en": "Promissory note signed by the debtor" + } + }, + { + "value": "62", + "name": { + "en": "Promissory note signed by the debtor and endorsed by a bank" + } + }, + { + "value": "63", + "name": { + "en": "Promissory note signed by the debtor and endorsed by a" + } + }, + { + "value": "64", + "name": { + "en": "Promissory note signed by a bank" + } + }, + { + "value": "65", + "name": { + "en": "Promissory note signed by a bank and endorsed by another" + } + }, + { + "value": "66", + "name": { + "en": "Promissory note signed by a third party" + } + }, + { + "value": "67", + "name": { + "en": "Promissory note signed by a third party and endorsed by a" + } + }, + { + "value": "68", + "name": { + "en": "Online payment service" + } + }, + { + "value": "69", + "name": { + "en": "Transfer Advice" + } + }, + { + "value": "70", + "name": { + "en": "Bill drawn by the creditor on the debtor" + } + }, + { + "value": "74", + "name": { + "en": "Bill drawn by the creditor on a bank" + } + }, + { + "value": "75", + "name": { + "en": "Bill drawn by the creditor, endorsed by another bank" + } + }, + { + "value": "76", + "name": { + "en": "Bill drawn by the creditor on a bank and endorsed by a" + } + }, + { + "value": "77", + "name": { + "en": "Bill drawn by the creditor on a third party" + } + }, + { + "value": "78", + "name": { + "en": "Bill drawn by creditor on third party, accepted and" + } + }, + { + "value": "91", + "name": { + "en": "Not transferable banker's draft" + } + }, + { + "value": "92", + "name": { + "en": "Not transferable local cheque" + } + }, + { + "value": "93", + "name": { + "en": "Reference giro" + } + }, + { + "value": "94", + "name": { + "en": "Urgent giro" + } + }, + { + "value": "95", + "name": { + "en": "Free format giro" + } + }, + { + "value": "96", + "name": { + "en": "Requested method for payment was not used" + } + }, + { + "value": "97", + "name": { + "en": "Clearing between partners" + } + }, + { + "value": "98", + "name": { + "en": "JP, Electronically Recorded Monetary Claims" + } + }, + { + "value": "ZZZ", + "name": { + "en": "Mutually defined" + } + } + ] + }, + { + "key": "untdid-tax-category", + "name": { + "en": "UNTDID 3505 Tax Category" + }, + "desc": { + "en": "UNTDID 5305 code used to describe the applicable duty/tax/fee category. There are\nmultiple versions and subsets of this table so regimes and addons may need to filter\noptions for a specific subset of values.\n\nData from https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5305.htm." + }, + "values": [ + { + "value": "A", + "name": { + "en": "Mixed tax rate" + } + }, + { + "value": "AA", + "name": { + "en": "Lower rate" + } + }, + { + "value": "AB", + "name": { + "en": "Exempt for resale" + } + }, + { + "value": "AC", + "name": { + "en": "Exempt for resale" + } + }, + { + "value": "AD", + "name": { + "en": "Value Added Tax (VAT) due from a previous invoice" + } + }, + { + "value": "AE", + "name": { + "en": "VAT Reverse Charge" + } + }, + { + "value": "B", + "name": { + "en": "Transferred (VAT)" + } + }, + { + "value": "C", + "name": { + "en": "Duty paid by supplier" + } + }, + { + "value": "D", + "name": { + "en": "Value Added Tax (VAT) margin scheme - travel agents" + } + }, + { + "value": "E", + "name": { + "en": "Exempt from tax" + } + }, + { + "value": "F", + "name": { + "en": "Value Added Tax (VAT) margin scheme - second-hand goods" + } + }, + { + "value": "G", + "name": { + "en": "Free export item, tax not charged" + } + }, + { + "value": "H", + "name": { + "en": "Higher rate" + } + }, + { + "value": "I", + "name": { + "en": "Value Added Tax (VAT) margin scheme - works of art" + } + }, + { + "value": "J", + "name": { + "en": "Value Added Tax (VAT) margin scheme - collector's items and antiques" + } + }, + { + "value": "K", + "name": { + "en": "VAT exempt for EEA intra-community supply of goods and services" + } + }, + { + "value": "L", + "name": { + "en": "Canary Islands general indirect tax" + } + }, + { + "value": "M", + "name": { + "en": "Tax for production, services and importation in Ceuta and Melilla" + } + }, + { + "value": "O", + "name": { + "en": "Services outside scope of tax" + } + }, + { + "value": "S", + "name": { + "en": "Standard Rate" + } + }, + { + "value": "Z", + "name": { + "en": "Zero rated goods" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/data/data.go b/data/data.go index a492ab36..cd5b00e5 100644 --- a/data/data.go +++ b/data/data.go @@ -3,7 +3,7 @@ package data import "embed" -//go:embed currency regimes schemas +//go:embed currency regimes schemas addons catalogues // Content contains the generated regimes and schemes // ready to serve as an embed.FS. diff --git a/data/regimes/de.json b/data/regimes/de.json index 2f36fdbd..1f5867d1 100644 --- a/data/regimes/de.json +++ b/data/regimes/de.json @@ -180,6 +180,18 @@ "percent": "5%" } ] + }, + { + "key": "exempt", + "name": { + "de": "Befreit", + "en": "Exempt" + }, + "desc": { + "de": "Bestimmte Waren und Dienstleistungen sind von der Umsatzsteuer befreit.", + "en": "Certain goods and services are exempt from VAT." + }, + "exempt": true } ], "sources": [ diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index b0bed939..66d3df12 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -254,7 +254,7 @@ }, { "const": "de-xrechnung-v3", - "title": "Germany XRechnung v3.x" + "title": "German XRechnung 3.X" }, { "const": "es-facturae-v3", diff --git a/data/schemas/org/identity.json b/data/schemas/org/identity.json index 31ddde7f..350f43d4 100644 --- a/data/schemas/org/identity.json +++ b/data/schemas/org/identity.json @@ -40,6 +40,11 @@ "type": "string", "title": "Description", "description": "Description adds details about what the code could mean or imply" + }, + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "title": "Extensions", + "description": "Ext provides a way to add additional information to the identity." } }, "type": "object", diff --git a/data/schemas/org/party.json b/data/schemas/org/party.json index 76166db9..dec2f382 100644 --- a/data/schemas/org/party.json +++ b/data/schemas/org/party.json @@ -5,6 +5,10 @@ "$defs": { "Party": { "properties": { + "$regime": { + "$ref": "https://gobl.org/draft-0/l10n/tax-country-code", + "title": "Tax Regime" + }, "uuid": { "type": "string", "format": "uuid", diff --git a/data/schemas/pay/advance.json b/data/schemas/pay/advance.json index 0b7925e3..e6f9b779 100644 --- a/data/schemas/pay/advance.json +++ b/data/schemas/pay/advance.json @@ -34,6 +34,11 @@ "title": "Credit Transfer", "description": "Sender initiated bank or wire transfer." }, + { + "const": "credit-transfer+sepa", + "title": "SEPA Credit Transfer", + "description": "Sender initiated bank or wire transfer via SEPA." + }, { "const": "debit-transfer", "title": "Debit Transfer", @@ -59,6 +64,11 @@ "title": "Direct Debit", "description": "Direct debit from the customers bank account." }, + { + "const": "direct-debit+sepa", + "title": "SEPA Direct Debit", + "description": "Direct debit from the customers bank account via SEPA." + }, { "const": "online", "title": "Online", diff --git a/data/schemas/pay/instructions.json b/data/schemas/pay/instructions.json index 5a98a10d..5e3597d4 100644 --- a/data/schemas/pay/instructions.json +++ b/data/schemas/pay/instructions.json @@ -95,6 +95,11 @@ "title": "Credit Transfer", "description": "Sender initiated bank or wire transfer." }, + { + "const": "credit-transfer+sepa", + "title": "SEPA Credit Transfer", + "description": "Sender initiated bank or wire transfer via SEPA." + }, { "const": "debit-transfer", "title": "Debit Transfer", @@ -120,6 +125,11 @@ "title": "Direct Debit", "description": "Direct debit from the customers bank account." }, + { + "const": "direct-debit+sepa", + "title": "SEPA Direct Debit", + "description": "Direct debit from the customers bank account via SEPA." + }, { "const": "online", "title": "Online", diff --git a/data/schemas/tax/addon-def.json b/data/schemas/tax/addon-def.json index 4d133765..68f9098d 100644 --- a/data/schemas/tax/addon-def.json +++ b/data/schemas/tax/addon-def.json @@ -10,6 +10,14 @@ "title": "Key", "description": "Key that defines how to uniquely idenitfy the add-on." }, + "requires": { + "items": { + "$ref": "https://gobl.org/draft-0/cbc/key" + }, + "type": "array", + "title": "Requires", + "description": "Requires defines any additional addons that this one depends on to operate\ncorrectly." + }, "name": { "$ref": "https://gobl.org/draft-0/i18n/string", "title": "Name", diff --git a/data/schemas/tax/catalogue-def.json b/data/schemas/tax/catalogue-def.json new file mode 100644 index 00000000..b9e70333 --- /dev/null +++ b/data/schemas/tax/catalogue-def.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gobl.org/draft-0/tax/catalogue-def", + "$ref": "#/$defs/CatalogueDef", + "$defs": { + "CatalogueDef": { + "properties": { + "key": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "description": "Key defines a unique identifier for the catalogue." + }, + "name": { + "$ref": "https://gobl.org/draft-0/i18n/string", + "description": "Name is the name of the catalogue." + }, + "description": { + "$ref": "https://gobl.org/draft-0/i18n/string", + "description": "Description is a human readable description of the catalogue." + }, + "extensions": { + "items": { + "$ref": "https://gobl.org/draft-0/cbc/key-definition" + }, + "type": "array", + "description": "Extensions defines all the extensions offered by the catalogue." + } + }, + "type": "object", + "required": [ + "key", + "name", + "description", + "extensions" + ], + "description": "A CatalogueDef contains a set of re-useable extensions, scenarios, and validators that can be used by addons or tax regimes." + } + } +} \ No newline at end of file diff --git a/gobl.go b/gobl.go index d192f42a..8a06b7be 100644 --- a/gobl.go +++ b/gobl.go @@ -5,6 +5,7 @@ import ( // import all the dependencies to ensure all init() methods are called. _ "github.com/invopop/gobl/addons" _ "github.com/invopop/gobl/bill" + _ "github.com/invopop/gobl/catalogues" _ "github.com/invopop/gobl/currency" _ "github.com/invopop/gobl/dsig" _ "github.com/invopop/gobl/i18n" @@ -19,6 +20,7 @@ import ( //go:generate go run ./schema/generate.go //go:generate go run ./regimes/generate.go //go:generate go run ./addons/generate.go +//go:generate go run ./catalogues/generate.go //go:generate go run ./currency/generate.go func init() { diff --git a/i18n/string.go b/i18n/string.go index 326ca15e..d040420e 100644 --- a/i18n/string.go +++ b/i18n/string.go @@ -9,6 +9,14 @@ const ( // String provides a simple map of locales to texts. type String map[Lang]string +// NewString is a convenience method to create a new i18n.String +// using the default language. +func NewString(text string) String { + return String{ + defaultLanguage: text, + } +} + // In provides a single string from the map using the // language requested or resorts to the default. func (s String) In(lang Lang) string { diff --git a/i18n/string_test.go b/i18n/string_test.go index 26dcdcbf..fe560bc3 100644 --- a/i18n/string_test.go +++ b/i18n/string_test.go @@ -23,4 +23,7 @@ func TestI18nString(t *testing.T) { } assert.Equal(t, "Foo", snd.In("en")) assert.Equal(t, "Foo", snd.String()) + + s2 := i18n.NewString("Test") + assert.Equal(t, "Test", s2.In("en")) } diff --git a/internal/cli/bulk_test.go b/internal/cli/bulk_test.go index e46a1318..7adfea77 100644 --- a/internal/cli/bulk_test.go +++ b/internal/cli/bulk_test.go @@ -648,7 +648,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo // Following raw message is copied and pasted! (sorry!) Payload: json.RawMessage(`{ "list": [ - "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/key-definition", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/note", "https://gobl.org/draft-0/cbc/value-definition", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/advance", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/total" + "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/key-definition", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/note", "https://gobl.org/draft-0/cbc/value-definition", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/advance", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/total" ] }`), IsFinal: false, diff --git a/org/identity.go b/org/identity.go index f05cfec3..0f9f7c71 100644 --- a/org/identity.go +++ b/org/identity.go @@ -46,6 +46,18 @@ type Identity struct { Code cbc.Code `json:"code" jsonschema:"title=Code"` // Description adds details about what the code could mean or imply Description string `json:"description,omitempty" jsonschema:"title=Description"` + // Ext provides a way to add additional information to the identity. + Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` +} + +// Normalize will try to clean the identity's data. +func (i *Identity) Normalize(normalizers tax.Normalizers) { + if i == nil { + return + } + uuid.Normalize(&i.UUID) + i.Ext = tax.CleanExtensions(i.Ext) + normalizers.Each(i) } // Validate ensures the identity looks valid. @@ -61,12 +73,13 @@ func (i *Identity) ValidateWithContext(ctx context.Context) error { validation.Field(&i.Key), validation.Field(&i.Type, validation.When(i.Key != "", - validation.Empty, + validation.Empty.Error("must be empty when key is set"), ), ), validation.Field(&i.Code, validation.Required, ), + validation.Field(&i.Ext), ) } @@ -127,7 +140,7 @@ func IdentityForType(in []*Identity, typ cbc.Code) *Identity { return nil } -// IdentityForKey helps return the identity with on of the matching keys. +// IdentityForKey helps return the identity with the first matching key. func IdentityForKey(in []*Identity, key ...cbc.Key) *Identity { for _, v := range in { if v.Key.In(key...) { diff --git a/org/identity_test.go b/org/identity_test.go index 110ebced..005ed7ea 100644 --- a/org/identity_test.go +++ b/org/identity_test.go @@ -3,8 +3,10 @@ package org_test import ( "testing" + "github.com/invopop/gobl/catalogues/iso" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" ) @@ -27,3 +29,55 @@ func TestAddIdentity(t *testing.T) { assert.Len(t, st.Identities, 1) assert.Equal(t, "BARDOM", st.Identities[0].Code.String()) } + +func TestIdentityNormalize(t *testing.T) { + t.Run("with nil", func(t *testing.T) { + var id *org.Identity + assert.NotPanics(t, func() { + id.Normalize(nil) + }) + }) + t.Run("missing extensions", func(t *testing.T) { + id := &org.Identity{ + Type: cbc.Code("FOO"), + Code: "BAR", + Ext: tax.Extensions{}, + } + id.Normalize(nil) + assert.Equal(t, "FOO", id.Type.String()) + assert.Nil(t, id.Ext) + }) + t.Run("with extension", func(t *testing.T) { + id := &org.Identity{ + Code: "BAR", + Ext: tax.Extensions{ + iso.ExtKeySchemeID: "0004", + }, + } + id.Normalize(nil) + assert.Equal(t, "BAR", id.Code.String()) + assert.Equal(t, "0004", id.Ext[iso.ExtKeySchemeID].String()) + }) +} + +func TestIdentityValidate(t *testing.T) { + t.Run("with basics", func(t *testing.T) { + id := &org.Identity{ + Code: "BAR", + Ext: tax.Extensions{ + iso.ExtKeySchemeID: "0004", + }, + } + err := id.Validate() + assert.NoError(t, err) + }) + t.Run("with both key and type", func(t *testing.T) { + id := &org.Identity{ + Key: "fiscal-code", + Type: "NIF", + Code: "1234567890", + } + err := id.Validate() + assert.ErrorContains(t, err, "type: must be empty when key is set") + }) +} diff --git a/org/party.go b/org/party.go index a9188aba..4f4b3c90 100644 --- a/org/party.go +++ b/org/party.go @@ -14,7 +14,9 @@ import ( // Party represents a person or business entity. type Party struct { + tax.Regime uuid.Identify + // Label can be used to provide a custom label for the party in a given // context in a single language, for example "Supplier", "Host", or similar. Label string `json:"label,omitempty" jsonschema:"title=Label,example=Supplier"` @@ -51,7 +53,14 @@ type Party struct { // Calculate will perform basic normalization of the party's data without // using any tax regime or addon. func (p *Party) Calculate() error { - p.Normalize(nil) + p.Normalize(p.normalizers()) + return nil +} + +func (p *Party) normalizers() tax.Normalizers { + if r := p.RegimeDef(); r != nil { + return tax.Normalizers{r.Normalizer} + } return nil } diff --git a/org/party_test.go b/org/party_test.go index bd1ced11..bffbf250 100644 --- a/org/party_test.go +++ b/org/party_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/invopop/gobl/org" _ "github.com/invopop/gobl/regimes" @@ -32,6 +33,7 @@ func TestPartyNormalize(t *testing.T) { }, } party.Normalize(nil) + assert.Empty(t, party.GetRegime()) assert.Equal(t, "ES", party.TaxID.Country.String()) assert.Equal(t, "ES42342912G", party.TaxID.String()) }) @@ -45,6 +47,7 @@ func TestPartyNormalize(t *testing.T) { }, } assert.NoError(t, party.Calculate()) + assert.Empty(t, party.GetRegime()) assert.Equal(t, "ES", party.TaxID.Country.String()) assert.Equal(t, "ES42342912G", party.TaxID.String()) }) @@ -60,6 +63,22 @@ func TestPartyNormalize(t *testing.T) { party.Normalize(nil) // unknown entry should not cause problem assert.Equal(t, "42342912G", party.TaxID.Code.String()) }) + + t.Run("for specific regime", func(t *testing.T) { + party := org.Party{ + Regime: tax.WithRegime("DE"), + Name: "Invopop", + Identities: []*org.Identity{ + { + Key: "de-tax-number", + Code: "123 456 78901", + }, + }, + } + require.NoError(t, party.Calculate()) + assert.Equal(t, "DE", party.GetRegime().String()) + assert.Equal(t, "123/456/78901", party.Identities[0].Code.String()) + }) } func TestPartyAddressNill(t *testing.T) { diff --git a/pay/instructions.go b/pay/instructions.go index ad6ca40d..d7157c33 100644 --- a/pay/instructions.go +++ b/pay/instructions.go @@ -90,16 +90,6 @@ func (i *Instructions) Normalize(normalizers tax.Normalizers) { normalizers.Each(i) } -// UNTDID4461 provides the standard UNTDID 4461 code for the instruction's key. -func (i *Instructions) UNTDID4461() cbc.Code { - for _, v := range MeansKeyDefinitions { - if v.Key == i.Key { - return v.UNTDID4461 - } - } - return cbc.CodeEmpty -} - // Validate ensures the Online method details look correct. func (u *Online) Validate() error { return validation.ValidateStruct(u, diff --git a/pay/means_key.go b/pay/means_key.go index d7a8f2df..4919d08c 100644 --- a/pay/means_key.go +++ b/pay/means_key.go @@ -2,13 +2,13 @@ package pay import ( "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" "github.com/invopop/jsonschema" ) // Standard payment means codes for instructions. // If you require more payment means options, please make a pull request -// and try to include references to the use case. All new means keys should -// map to an existing UNTDID 4461 code. +// and try to include references to the use case. const ( MeansKeyAny cbc.Key = "any" // Use any method available. MeansKeyCard cbc.Key = "card" @@ -21,37 +21,83 @@ const ( MeansKeyBankDraft cbc.Key = "bank-draft" MeansKeyDirectDebit cbc.Key = "direct-debit" // aka. Mandate MeansKeyOnline cbc.Key = "online" // Website from which payment can be made + MeansKeySEPA cbc.Key = "sepa" // extension for SEPA payments MeansKeyOther cbc.Key = "other" ) -// MeansKeyDef is used to define each of the payment means keys -// that can be accepted by GOBL. -type MeansKeyDef struct { - // Key being described - Key cbc.Key `json:"key" jsonschema:"title=Key"` - // Human value of the key - Title string `json:"title" jsonschema:"title=Title"` - // Details about the meaning of the key - Description string `json:"description" jsonschema:"title=Description"` - // UNTDID 4461 Equivalent Code - UNTDID4461 cbc.Code `json:"untdid4461" jsonschema:"title=UNTDID 4461 Code"` -} - // MeansKeyDefinitions includes all the payment means keys that // are accepted by GOBL. -var MeansKeyDefinitions = []MeansKeyDef{ - {MeansKeyAny, "Any", "Any method available, no preference.", "1"}, // Instrument not defined - {MeansKeyCard, "Card", "Payment card.", "48"}, // Bank card - {MeansKeyCreditTransfer, "Credit Transfer", "Sender initiated bank or wire transfer.", "30"}, // credit transfer - {MeansKeyDebitTransfer, "Debit Transfer", "Receiver initiated bank or wire transfer.", "31"}, // debit transfer - {MeansKeyCash, "Cash", "Cash in hand.", "10"}, // in cash - {MeansKeyCheque, "Cheque", "Cheque from bank.", "20"}, // cheque - {MeansKeyBankDraft, "Draft", "Bankers Draft or Bank Cheque.", "21"}, // Banker's draft, - {MeansKeyDirectDebit, "Direct Debit", "Direct debit from the customers bank account.", "49"}, // direct debit - {MeansKeyOnline, "Online", "Online or web payment.", "68"}, // online payment service - {MeansKeyPromissoryNote, "Promissory Note", "Promissory note contract.", "60"}, // Promissory note - {MeansKeyNetting, "Netting", "Intercompany clearing or clearing between partners.", "97"}, // Netting - {MeansKeyOther, "Other", "Other or mutually defined means of payment.", "ZZZ"}, // Other +var MeansKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: MeansKeyAny, + Name: i18n.NewString("Any"), + Desc: i18n.NewString("Any method available, no preference."), + }, + { + Key: MeansKeyCard, + Name: i18n.NewString("Card"), + Desc: i18n.NewString("Payment card."), + }, + { + Key: MeansKeyCreditTransfer, + Name: i18n.NewString("Credit Transfer"), + Desc: i18n.NewString("Sender initiated bank or wire transfer."), + }, + { + Key: MeansKeyCreditTransfer.With(MeansKeySEPA), + Name: i18n.NewString("SEPA Credit Transfer"), + Desc: i18n.NewString("Sender initiated bank or wire transfer via SEPA."), + }, + { + Key: MeansKeyDebitTransfer, + Name: i18n.NewString("Debit Transfer"), + Desc: i18n.NewString("Receiver initiated bank or wire transfer."), + }, + { + Key: MeansKeyCash, + Name: i18n.NewString("Cash"), + Desc: i18n.NewString("Cash in hand."), + }, + { + Key: MeansKeyCheque, + Name: i18n.NewString("Cheque"), + Desc: i18n.NewString("Cheque from bank."), + }, + { + Key: MeansKeyBankDraft, + Name: i18n.NewString("Draft"), + Desc: i18n.NewString("Bankers Draft or Bank Cheque."), + }, + { + Key: MeansKeyDirectDebit, + Name: i18n.NewString("Direct Debit"), + Desc: i18n.NewString("Direct debit from the customers bank account."), + }, + { + Key: MeansKeyDirectDebit.With(MeansKeySEPA), + Name: i18n.NewString("SEPA Direct Debit"), + Desc: i18n.NewString("Direct debit from the customers bank account via SEPA."), + }, + { + Key: MeansKeyOnline, + Name: i18n.NewString("Online"), + Desc: i18n.NewString("Online or web payment."), + }, + { + Key: MeansKeyPromissoryNote, + Name: i18n.NewString("Promissory Note"), + Desc: i18n.NewString("Promissory note contract."), + }, + { + Key: MeansKeyNetting, + Name: i18n.NewString("Netting"), + Desc: i18n.NewString("Intercompany clearing or clearing between partners."), + }, + { + Key: MeansKeyOther, + Name: i18n.NewString("Other"), + Desc: i18n.NewString("Other or mutually defined means of payment."), + }, } // HasValidMeansKey provides a usable validator for the means key @@ -74,8 +120,8 @@ func extendJSONSchemaWithMeansKey(schema *jsonschema.Schema, property string) { for i, v := range MeansKeyDefinitions { anyOf[i] = &jsonschema.Schema{ Const: v.Key, - Title: v.Title, - Description: v.Description, + Title: v.Name.String(), + Description: v.Desc.String(), } } anyOf = append(anyOf, &jsonschema.Schema{ diff --git a/regimes/de/examples/invoice-de-de.yaml b/regimes/de/examples/invoice-de-de.yaml index 161857cf..5e9bf342 100644 --- a/regimes/de/examples/invoice-de-de.yaml +++ b/regimes/de/examples/invoice-de-de.yaml @@ -1,4 +1,6 @@ $schema: "https://gobl.org/draft-0/bill/invoice" +$addons: + - "de-xrechnung-v3" uuid: "3aea7b56-59d8-4beb-90bd-f8f280d852a0" currency: "EUR" issue_date: "2022-02-01" @@ -45,3 +47,10 @@ lines: taxes: - cat: VAT rate: standard + +payment: + instructions: + key: "credit-transfer+sepa" + credit_transfer: + - iban: "DE89370400440532013000" + name: "Random Bank Co." diff --git a/regimes/de/examples/out/invoice-de-de.json b/regimes/de/examples/out/invoice-de-de.json index 236f7088..8ea1e308 100644 --- a/regimes/de/examples/out/invoice-de-de.json +++ b/regimes/de/examples/out/invoice-de-de.json @@ -4,18 +4,27 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "0938466f9583f14ef3c26a29ef8a70d6cf2df20d69346e51be8252d7e15eb063" + "val": "02225106bf1fb373bc48663ad9eec3d50861d313616b8b392e66cdafab047c66" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "DE", + "$addons": [ + "eu-en16931-v2017", + "de-xrechnung-v3" + ], "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", "type": "standard", "series": "SAMPLE", "code": "001", "issue_date": "2022-02-01", "currency": "EUR", + "tax": { + "ext": { + "untdid-document-type": "380" + } + }, "supplier": { "name": "Provide One GmbH", "tax_id": { @@ -78,12 +87,29 @@ { "cat": "VAT", "rate": "standard", - "percent": "19%" + "percent": "19%", + "ext": { + "untdid-tax-category": "S" + } } ], "total": "1620.00" } ], + "payment": { + "instructions": { + "key": "credit-transfer+sepa", + "credit_transfer": [ + { + "iban": "DE89370400440532013000", + "name": "Random Bank Co." + } + ], + "ext": { + "untdid-payment-means": "58" + } + } + }, "totals": { "sum": "1620.00", "total": "1620.00", @@ -94,6 +120,9 @@ "rates": [ { "key": "standard", + "ext": { + "untdid-tax-category": "S" + }, "base": "1620.00", "percent": "19%", "amount": "307.80" diff --git a/regimes/de/identities.go b/regimes/de/identities.go index caf32eaf..533613a8 100644 --- a/regimes/de/identities.go +++ b/regimes/de/identities.go @@ -19,7 +19,6 @@ const ( ) var taxNumberRegexPattern = regexp.MustCompile(`^\d{2,3}/\d{3}/\d{5}$`) -var badCharsRegexPattern = regexp.MustCompile(`[^\d]`) var identityKeyDefinitions = []*cbc.KeyDefinition{ { @@ -36,8 +35,7 @@ func normalizeTaxNumber(id *org.Identity) { if id == nil || id.Key != IdentityKeyTaxNumber { return } - code := id.Code.String() - code = badCharsRegexPattern.ReplaceAllString(code, "") + code := cbc.NormalizeNumericalCode(id.Code).String() if len(code) == 11 { // If 11 digits, return the format 123/456/78901 code = fmt.Sprintf("%s/%s/%s", code[:3], code[3:6], code[6:]) diff --git a/regimes/de/invoices.go b/regimes/de/invoices.go index 15faf7fe..d3dc0ce7 100644 --- a/regimes/de/invoices.go +++ b/regimes/de/invoices.go @@ -28,19 +28,19 @@ func validateInvoiceSupplier(value any) error { } return validation.ValidateStruct(p, validation.Field(&p.TaxID, - validation.Required, validation.When( - !hasTaxNumber(p), + !hasIdentityTaxNumber(p), + validation.Required, tax.RequireIdentityCode, ), - // validation.Skip, + validation.Skip, ), validation.Field(&p.Identities, validation.When( !hasTaxIDCode(p), org.RequireIdentityKey(IdentityKeyTaxNumber), ), - // validation.Skip, + validation.Skip, ), ) } @@ -53,8 +53,8 @@ func hasTaxIDCode(party *org.Party) bool { return party != nil && party.TaxID != nil && party.TaxID.Code != "" } -func hasTaxNumber(party *org.Party) bool { - if party == nil || party.TaxID == nil { +func hasIdentityTaxNumber(party *org.Party) bool { + if party == nil || len(party.Identities) == 0 { return false } return org.IdentityForKey(party.Identities, IdentityKeyTaxNumber) != nil diff --git a/regimes/de/invoices_test.go b/regimes/de/invoices_test.go index ed4bf8a0..83536bc5 100644 --- a/regimes/de/invoices_test.go +++ b/regimes/de/invoices_test.go @@ -83,4 +83,17 @@ func TestInvoiceValidation(t *testing.T) { require.NoError(t, inv.Calculate()) assert.NoError(t, inv.Validate()) }) + + t.Run("regular invoice - only tax number nil tax ID", func(t *testing.T) { + inv := validInvoice() + inv.Supplier.TaxID = nil + inv.Supplier.Identities = []*org.Identity{ + { + Key: "de-tax-number", + Code: "92/345/67894", + }, + } + require.NoError(t, inv.Calculate()) + assert.NoError(t, inv.Validate()) + }) } diff --git a/regimes/de/tax_categories.go b/regimes/de/tax_categories.go index f373b4c4..617d961f 100644 --- a/regimes/de/tax_categories.go +++ b/regimes/de/tax_categories.go @@ -103,6 +103,18 @@ var taxCategories = []*tax.CategoryDef{ }, }, }, + { + Key: tax.RateExempt, + Name: i18n.String{ + i18n.EN: "Exempt", + i18n.DE: "Befreit", + }, + Exempt: true, + Description: i18n.String{ + i18n.EN: "Certain goods and services are exempt from VAT.", + i18n.DE: "Bestimmte Waren und Dienstleistungen sind von der Umsatzsteuer befreit.", + }, + }, }, }, } diff --git a/tax/addons.go b/tax/addons.go index fcd24f00..521f44be 100644 --- a/tax/addons.go +++ b/tax/addons.go @@ -23,6 +23,10 @@ type AddonDef struct { // Key that defines how to uniquely idenitfy the add-on. Key cbc.Key `json:"key" jsonschema:"title=Key"` + // Requires defines any additional addons that this one depends on to operate + // correctly. + Requires []cbc.Key `json:"requires,omitempty" jsonschema:"title=Requires"` + // Name of the add-on Name i18n.String `json:"name" jsonschema:"title=Name"` @@ -74,8 +78,9 @@ func (as *Addons) GetAddons() []cbc.Key { return as.List } -// GetAddonDefs provides a slice of Addon Definition instances. -func (as Addons) GetAddonDefs() []*AddonDef { +// AddonDefs provides a slice of Addon Definition instances including +// any dependencies. +func (as Addons) AddonDefs() []*AddonDef { list := make([]*AddonDef, 0, len(as.List)) for _, ak := range as.List { if a := AddonForKey(ak); a != nil { @@ -85,6 +90,19 @@ func (as Addons) GetAddonDefs() []*AddonDef { return list } +// normalizeAddons ensures that the list of addons is normalized and is normally +// performed internally when preparing the list of normalizers to use. +func (as *Addons) normalizeAddons() { + list := make([]cbc.Key, 0, len(as.List)) + for _, ak := range as.List { + if ad := AddonForKey(ak); ad != nil { + list = cbc.AppendUniqueKeys(list, ad.Requires...) + list = cbc.AppendUniqueKeys(list, ad.Key) + } + } + as.List = list +} + // Validate ensures that the list of addons is valid. This struct is designed to be // embedded, so we don't perform a regular validation on the struct itself. func (as Addons) Validate() error { diff --git a/tax/addons_test.go b/tax/addons_test.go index aa6e840e..c795bc9e 100644 --- a/tax/addons_test.go +++ b/tax/addons_test.go @@ -27,7 +27,7 @@ func TestEmbeddingAddons(t *testing.T) { assert.Equal(t, []cbc.Key{"mx-cfdi-v4"}, ts.GetAddons()) - defs := ts.GetAddonDefs() + defs := ts.AddonDefs() assert.Len(t, defs, 1) assert.Equal(t, "mx-cfdi-v4", defs[0].Key.String()) @@ -35,6 +35,12 @@ func TestEmbeddingAddons(t *testing.T) { err := ts.Addons.Validate() assert.ErrorContains(t, err, "1: addon 'invalid-addon' not registered") + + t.Run("test addon normalization", func(t *testing.T) { + ts.Addons.List = []cbc.Key{"mx-cfdi-v4", "mx-cfdi-v4", "de-xrechnung-v3"} + _ = tax.ExtractNormalizers(ts) + assert.Equal(t, []cbc.Key{"mx-cfdi-v4", "eu-en16931-v2017", "de-xrechnung-v3"}, ts.Addons.List) + }) } func TestAddonForKey(t *testing.T) { diff --git a/tax/catalogue.go b/tax/catalogue.go new file mode 100644 index 00000000..9d561481 --- /dev/null +++ b/tax/catalogue.go @@ -0,0 +1,62 @@ +package tax + +import ( + "sort" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" +) + +// A CatalogueDef contains a set of re-useable extensions, scenarios, and validators that +// can be used by addons or tax regimes. This structure is useful for serializing the +// data into JSON for use in external libraries. +type CatalogueDef struct { + // Key defines a unique identifier for the catalogue. + Key cbc.Key `json:"key"` + // Name is the name of the catalogue. + Name i18n.String `json:"name"` + // Description is a human readable description of the catalogue. + Description i18n.String `json:"description"` + // Extensions defines all the extensions offered by the catalogue. + Extensions []*cbc.KeyDefinition `json:"extensions"` +} + +// RegisterCatalogueDef will register the catalogue in the global list of catalogues +// and ensure the extensions it contains are available in GOBL. +func RegisterCatalogueDef(catalogue *CatalogueDef) { + for _, ext := range catalogue.Extensions { + RegisterExtension(ext) + } + catalogues.add(catalogue) +} + +// AllCatalogueDefs provides a slice of all the addons defined. +func AllCatalogueDefs() []*CatalogueDef { + all := make([]*CatalogueDef, len(catalogues.list)) + for i, ao := range catalogues.keys { + all[i] = catalogues.list[ao] + } + return all +} + +type catalogueCollection struct { + keys []cbc.Key // ordered list + list map[cbc.Key]*CatalogueDef +} + +var catalogues = newCatalogueCollection() + +func newCatalogueCollection() *catalogueCollection { + return &catalogueCollection{ + list: make(map[cbc.Key]*CatalogueDef), + } +} + +// add will register the catalogye in the collection +func (c *catalogueCollection) add(cd *CatalogueDef) { + c.keys = append(c.keys, cd.Key) + sort.Slice(c.keys, func(i, j int) bool { + return c.keys[i].String() < c.keys[j].String() + }) + c.list[cd.Key] = cd +} diff --git a/tax/catalogue_test.go b/tax/catalogue_test.go new file mode 100644 index 00000000..08ae05e5 --- /dev/null +++ b/tax/catalogue_test.go @@ -0,0 +1,22 @@ +package tax_test + +import ( + "testing" + + _ "github.com/invopop/gobl" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestAllCatalogueDefs(t *testing.T) { + cds := tax.AllCatalogueDefs() + assert.GreaterOrEqual(t, len(cds), 1) + match := true + for _, cd := range cds { + if cd.Key == "untdid" { + match = true + break + } + } + assert.True(t, match) +} diff --git a/tax/extensions.go b/tax/extensions.go index 8016d520..35c6b5de 100644 --- a/tax/extensions.go +++ b/tax/extensions.go @@ -202,24 +202,26 @@ func CleanExtensions(em Extensions) Extensions { // ExtensionsHas returns a validation rule that ensures the extension map's // keys match those provided. func ExtensionsHas(keys ...cbc.Key) validation.Rule { - return validateCodeMap{keys: keys} + return validateExtCodeMap{ + keys: keys, + } } // ExtensionsRequires returns a validation rule that ensures all the // extension map's keys match those provided in the list. func ExtensionsRequires(keys ...cbc.Key) validation.Rule { - return validateCodeMap{ + return validateExtCodeMap{ required: true, keys: keys, } } -type validateCodeMap struct { +type validateExtCodeMap struct { keys []cbc.Key required bool } -func (v validateCodeMap) Validate(value interface{}) error { +func (v validateExtCodeMap) Validate(value interface{}) error { em, ok := value.(Extensions) if !ok { return nil @@ -246,6 +248,45 @@ func (v validateCodeMap) Validate(value interface{}) error { return nil } +// ExtensionsHasValues +func ExtensionsHasValues(key cbc.Key, values ...ExtValue) validation.Rule { + return validateExtCodeValues{ + key: key, + values: values, + } +} + +type validateExtCodeValues struct { + key cbc.Key + values []ExtValue +} + +func (v validateExtCodeValues) Validate(value interface{}) error { + em, ok := value.(Extensions) + if !ok { + return nil + } + err := make(validation.Errors) + + if ev, ok := em[v.key]; ok { + match := false + for _, val := range v.values { + if ev == val { + match = true + break + } + } + if !match { + err[v.key.String()] = errors.New("invalid value") + } + } + + if len(err) > 0 { + return err + } + return nil +} + // JSONSchemaExtend provides extra details about the extension map which are // not automatically determined. In this case we add validation for the map's // keys. diff --git a/tax/extensions_test.go b/tax/extensions_test.go index 5e7e443d..a1881e3c 100644 --- a/tax/extensions_test.go +++ b/tax/extensions_test.go @@ -6,8 +6,11 @@ import ( "github.com/invopop/gobl/addons/es/tbai" "github.com/invopop/gobl/addons/gr/mydata" "github.com/invopop/gobl/addons/mx/cfdi" // this will also prepare registers + "github.com/invopop/gobl/catalogues/iso" + "github.com/invopop/gobl/catalogues/untdid" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/tax" + "github.com/invopop/validation" "github.com/stretchr/testify/assert" ) @@ -148,6 +151,127 @@ func TestExtValidation(t *testing.T) { }) } +func TestExtensionsHasValidation(t *testing.T) { + t.Run("nil", func(t *testing.T) { + err := validation.Validate(nil, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("empty", func(t *testing.T) { + em := tax.Extensions{} + err := validation.Validate(em, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("correct", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + } + err := validation.Validate(em, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("missing", func(t *testing.T) { + em := tax.Extensions{ + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsHas(untdid.ExtKeyDocumentType), + ) + assert.ErrorContains(t, err, "iso-scheme-id: invalid") + }) +} + +func TestExtensionsRequiresValidation(t *testing.T) { + t.Run("nil", func(t *testing.T) { + err := validation.Validate(nil, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("empty", func(t *testing.T) { + em := tax.Extensions{} + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.ErrorContains(t, err, "untdid-document-type: required") + }) + t.Run("correct", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + } + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("correct with extras", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("missing", func(t *testing.T) { + em := tax.Extensions{ + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsRequires(untdid.ExtKeyDocumentType), + ) + assert.ErrorContains(t, err, "untdid-document-type: required") + }) +} + +func TestExtensionsHasValues(t *testing.T) { + t.Run("nil", func(t *testing.T) { + err := validation.Validate(nil, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("empty", func(t *testing.T) { + em := tax.Extensions{} + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("different extensions", func(t *testing.T) { + em := tax.Extensions{ + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("has codes", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + } + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.NoError(t, err) + }) + t.Run("invalid code", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "102", + } + err := validation.Validate(em, + tax.ExtensionsHasValues(untdid.ExtKeyDocumentType, "326", "389"), + ) + assert.ErrorContains(t, err, "untdid-document-type: invalid value") + }) +} + func TestExtensionsHas(t *testing.T) { em := tax.Extensions{ "key": "value", diff --git a/tax/identity.go b/tax/identity.go index ddcba831..c6385266 100644 --- a/tax/identity.go +++ b/tax/identity.go @@ -139,7 +139,7 @@ func (id *Identity) Validate() error { func (v validateTaxID) Validate(value interface{}) error { id, ok := value.(*Identity) - if !ok { + if id == nil || !ok { return nil } rules := []*validation.FieldRules{ diff --git a/tax/regime_def.go b/tax/regime_def.go index 90273dbe..eec39f12 100644 --- a/tax/regime_def.go +++ b/tax/regime_def.go @@ -320,11 +320,11 @@ func (r *RegimeDef) TimeLocation() *time.Location { // ensure a rate key is defined inside a category. func (r *RegimeDef) InCategoryRates(cat cbc.Code) validation.Rule { if r == nil { - return validation.Empty + return validation.Empty.Error("must be blank when regime is undefined") } c := r.CategoryDef(cat) if c == nil { - return validation.Empty + return validation.Empty.Error("must be blank when category is undefined") } keys := make([]cbc.Key, len(c.Rates)) for i, x := range c.Rates { diff --git a/tax/tax.go b/tax/tax.go index 03cbb63e..53437b59 100644 --- a/tax/tax.go +++ b/tax/tax.go @@ -11,12 +11,13 @@ import ( func init() { schema.Register(schema.GOBL.Add("tax"), + Identity{}, Set{}, Extensions{}, Total{}, RegimeDef{}, AddonDef{}, - Identity{}, + CatalogueDef{}, ) } @@ -60,6 +61,36 @@ func Validators(ctx context.Context) []Validator { return list } +// ExtractNormalizers will extract the normalizers from the provided object +// that is using either the regime or addons. +func ExtractNormalizers(obj any) Normalizers { + if obj == nil { + return nil + } + normalizers := make(Normalizers, 0) + if n, ok := obj.(regimeImpl); ok { + if r := n.RegimeDef(); r != nil { + normalizers = normalizers.Append(r.Normalizer) + } + } + if n, ok := obj.(addonsImpl); ok { + n.normalizeAddons() + for _, a := range n.AddonDefs() { + normalizers = normalizers.Append(a.Normalizer) + } + } + return normalizers +} + +type regimeImpl interface { + RegimeDef() *RegimeDef +} + +type addonsImpl interface { + normalizeAddons() + AddonDefs() []*AddonDef +} + type normalizeImpl interface { Normalize(Normalizers) } From da3c6b1ffa5e2c97ba49af2a2fb95a88ae7c8294 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 09:15:51 +0000 Subject: [PATCH 19/27] Fixing linter issues --- addons/de/xrechnung/xrechnung.go | 7 +------ tax/extensions.go | 3 ++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/addons/de/xrechnung/xrechnung.go b/addons/de/xrechnung/xrechnung.go index 604a97c5..bb918fce 100644 --- a/addons/de/xrechnung/xrechnung.go +++ b/addons/de/xrechnung/xrechnung.go @@ -39,15 +39,10 @@ func newAddon() *tax.AddonDef { For more information on XRechnung, visit [www.xrechnung.de](https://www.xrechnung.de/). `), }, - Normalizer: normalize, - Validator: validate, + Validator: validate, } } -func normalize(doc any) { - // nothing to normalize yet -} - func validate(doc any) error { switch obj := doc.(type) { case *bill.Invoice: diff --git a/tax/extensions.go b/tax/extensions.go index 35c6b5de..3a2b4350 100644 --- a/tax/extensions.go +++ b/tax/extensions.go @@ -248,7 +248,8 @@ func (v validateExtCodeMap) Validate(value interface{}) error { return nil } -// ExtensionsHasValues +// 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, From d31742d5ddb999dd1a0b6d351c2abc45bc4a1d6a Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 09:30:33 +0000 Subject: [PATCH 20/27] Updating CHANGELOG, extra test --- CHANGELOG.md | 5 ++++- tax/regime_def_test.go | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdbf228b..40de9ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- 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`, and `untdid-tax-category`. - `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. ### Removed 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") +} From 34c696b9429437322e9b1cc41d030b285c3f8078 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 09:33:42 +0000 Subject: [PATCH 21/27] CHANGELOG update --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40de9ac7..557f8457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `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. +### Modified + +- `tax`: Addons can now depend on other addons, whose keys will be automatically added during normalization. + ### Removed - `pay`: UNTDID 4461 mappings from payment means table, now provided by catalogues From c8f364bac0d0024cec84f6dda135d45e2bc2ffb9 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 10:35:15 +0000 Subject: [PATCH 22/27] Allowing extended tax rate keys --- CHANGELOG.md | 2 + data/addons/eu-en16931-v2017.json | 81 ++++++++++ data/schemas/bill/invoice.json | 4 + data/schemas/tax/regime-def.json | 2 +- regimes/de/examples/invoice-de-es-b2b.yaml | 59 +++++++ .../de/examples/out/invoice-de-es-b2b.json | 151 ++++++++++++++++++ regimes/pt/migrations_test.go | 7 - tax/regime_def.go | 34 +++- tax/set_test.go | 13 +- 9 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 data/addons/eu-en16931-v2017.json create mode 100644 regimes/de/examples/invoice-de-es-b2b.yaml create mode 100644 regimes/de/examples/out/invoice-de-es-b2b.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 557f8457..62c67e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `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` support for extensions. ### Modified +- `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. ### Removed 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/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index 66d3df12..348cde88 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -264,6 +264,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" diff --git a/data/schemas/tax/regime-def.json b/data/schemas/tax/regime-def.json index 6c05cf62..86306c84 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": { 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-es-b2b.json b/regimes/de/examples/out/invoice-de-es-b2b.json new file mode 100644 index 00000000..c595a695 --- /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": [ + { + "percent": "10%", + "amount": "180.00", + "reason": "Special discount" + } + ], + "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/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/tax/regime_def.go b/tax/regime_def.go index eec39f12..111d5d39 100644 --- a/tax/regime_def.go +++ b/tax/regime_def.go @@ -3,6 +3,7 @@ package tax import ( "context" "errors" + "fmt" "strings" "time" @@ -116,7 +117,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 @@ -316,8 +317,26 @@ 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.Error("must be blank when regime is undefined") @@ -330,7 +349,7 @@ func (r *RegimeDef) InCategoryRates(cat cbc.Code) validation.Rule { 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 +472,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/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", From e59a6961ffa2eefb751244daa08ea7487d78f801 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 10:35:35 +0000 Subject: [PATCH 23/27] Additional test case for EN16931 rate key with reverse charge --- addons/eu/en16931/tax_combo_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/addons/eu/en16931/tax_combo_test.go b/addons/eu/en16931/tax_combo_test.go index 943588f0..ec323f7a 100644 --- a/addons/eu/en16931/tax_combo_test.go +++ b/addons/eu/en16931/tax_combo_test.go @@ -67,11 +67,10 @@ func TestTaxComboNormalization(t *testing.T) { func TestTaxComboValidation(t *testing.T) { ad := tax.AddonForKey(en16931.V2017) t.Run("standard VAT rate", func(t *testing.T) { - p := num.MakePercentage(19, 2) c := &tax.Combo{ Category: tax.CategoryVAT, Rate: tax.RateStandard, - Percent: &p, + Percent: num.NewPercentage(19, 2), } ad.Normalizer(c) assert.NoError(t, ad.Validator(c)) @@ -79,6 +78,17 @@ func TestTaxComboValidation(t *testing.T) { 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("nil", func(t *testing.T) { var tc *tax.Combo err := ad.Validator(tc) From 2777bb53646d42ec9a000ccbe87b85af884796cf Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Fri, 25 Oct 2024 11:11:41 +0000 Subject: [PATCH 24/27] Defining additional export rates for EN16931 --- CHANGELOG.md | 1 + addons/eu/en16931/tax_combo.go | 10 +++++++++- addons/eu/en16931/tax_combo_test.go | 22 ++++++++++++++++++++++ tax/constants.go | 2 ++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c67e69..3c227658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `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` support for extensions. +- `tax`: tags for `export` and `eea` (european economic area) for use with rates. ### Modified diff --git a/addons/eu/en16931/tax_combo.go b/addons/eu/en16931/tax_combo.go index 06a03aff..c30ce493 100644 --- a/addons/eu/en16931/tax_combo.go +++ b/addons/eu/en16931/tax_combo.go @@ -12,7 +12,14 @@ var taxCategoryMap = tax.Extensions{ tax.RateReduced: "S", // Same as standard tax.RateZero: "Z", tax.RateExempt: "E", - tax.RateExempt.With(tax.TagReverseCharge): "AE", + 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) { @@ -46,6 +53,7 @@ func validateTaxCombo(tc *tax.Combo) error { 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 index ec323f7a..e18b2053 100644 --- a/addons/eu/en16931/tax_combo_test.go +++ b/addons/eu/en16931/tax_combo_test.go @@ -89,6 +89,28 @@ func TestTaxComboValidation(t *testing.T) { 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) 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 ) From 8bbfb2516c7cd71ecf21db5250e390fceda654fc Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Wed, 30 Oct 2024 22:15:49 +0000 Subject: [PATCH 25/27] Refining charges, discounts, with keys, removing outlays --- CHANGELOG.md | 13 +- addons/co/dian/invoices.go | 4 - addons/eu/en16931/bill.go | 65 + addons/eu/en16931/bill_test.go | 84 ++ addons/eu/en16931/en16931.go | 8 + bill/charges.go | 129 +- bill/discounts.go | 139 +- bill/invoice.go | 15 +- bill/invoice_convert.go | 17 - bill/invoice_convert_test.go | 11 +- bill/invoice_test.go | 20 +- bill/line.go | 2 + bill/line_charge.go | 52 + bill/line_charge_test.go | 59 + bill/line_discount.go | 50 + bill/line_discount_test.go | 59 + bill/ordering.go | 6 +- bill/outlay.go | 78 -- bill/outlay_test.go | 44 - catalogues/iso/extensions.go | 28 - catalogues/iso/iso.go | 9 +- catalogues/iso/scheme_id.go | 26 + catalogues/untdid/allowance.go | 105 ++ catalogues/untdid/charge.go | 753 ++++++++++ catalogues/untdid/document_type.go | 246 ++++ catalogues/untdid/extensions.go | 746 ---------- catalogues/untdid/payment_means.go | 364 +++++ catalogues/untdid/tax_category.go | 156 +++ catalogues/untdid/untdid.go | 13 +- cbc/code.go | 12 +- cbc/code_test.go | 9 + cmd/gobl/testdata/Test_build_do_not_envelop | 8 - cmd/gobl/testdata/Test_build_envelop | 10 +- cmd/gobl/testdata/Test_build_explicit_stdout | 4 +- cmd/gobl/testdata/Test_build_input_file | 4 +- cmd/gobl/testdata/Test_build_merge_values | 4 +- .../testdata/Test_build_output_file_outfile | 4 +- .../Test_build_overwrite_input_file_outfile | 10 +- .../Test_build_overwrite_output_file_outfile | 4 +- cmd/gobl/testdata/Test_build_recalculate | 10 +- cmd/gobl/testdata/Test_build_success | 10 +- cmd/gobl/testdata/Test_build_valid_file | 10 +- cmd/gobl/testdata/Test_sign_explicit_stdout | 4 +- cmd/gobl/testdata/Test_sign_input_file | 4 +- cmd/gobl/testdata/Test_sign_merge_values | 4 +- .../testdata/Test_sign_output_file_outfile | 4 +- .../Test_sign_overwrite_input_file_outfile | 10 +- .../Test_sign_overwrite_output_file_outfile | 4 +- cmd/gobl/testdata/Test_sign_recalculate | 10 +- cmd/gobl/testdata/Test_sign_success | 10 +- cmd/gobl/testdata/Test_sign_valid_file | 10 +- data/catalogues/untdid.json | 1228 +++++++++++++++++ data/regimes/it.json | 13 - data/schemas/bill/invoice.json | 416 ++++-- data/schemas/cbc/code.json | 2 +- data/schemas/org/inbox.json | 27 +- data/schemas/pay/advance.json | 6 + data/schemas/pay/instructions.json | 10 +- data/schemas/tax/regime-def.json | 8 - internal/cli/testdata/TestBuild_draft | 10 +- internal/cli/testdata/TestBuild_explicit_type | 10 +- internal/cli/testdata/TestBuild_merge_YAML | 10 +- internal/cli/testdata/TestBuild_success | 10 +- .../TestBuild_template_with_empty_input | 10 +- internal/cli/testdata/TestBuild_with_template | 10 +- internal/cli/testdata/TestSign_draft_envelope | 12 +- internal/cli/testdata/TestSign_success | 12 +- internal/cli/testdata/draft.json | 13 +- org/inbox.go | 59 +- org/inbox_test.go | 104 ++ org/party.go | 1 + pay/instructions.go | 10 +- pay/instructions_test.go | 2 + regimes/it/charges.go | 25 - regimes/it/it.go | 1 - tax/regime_def.go | 4 - 76 files changed, 4063 insertions(+), 1410 deletions(-) create mode 100644 bill/line_charge.go create mode 100644 bill/line_charge_test.go create mode 100644 bill/line_discount.go create mode 100644 bill/line_discount_test.go delete mode 100644 bill/outlay.go delete mode 100644 bill/outlay_test.go delete mode 100644 catalogues/iso/extensions.go create mode 100644 catalogues/iso/scheme_id.go create mode 100644 catalogues/untdid/allowance.go create mode 100644 catalogues/untdid/charge.go create mode 100644 catalogues/untdid/document_type.go delete mode 100644 catalogues/untdid/extensions.go create mode 100644 catalogues/untdid/payment_means.go create mode 100644 catalogues/untdid/tax_category.go create mode 100644 org/inbox_test.go delete mode 100644 regimes/it/charges.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c227658..bfeb3591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,21 +10,28 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `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`, and `untdid-tax-category`. +- `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` support for extensions. +- `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. -### Modified +### 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/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/eu/en16931/bill.go b/addons/eu/en16931/bill.go index 1e41ebff..1a81a54b 100644 --- a/addons/eu/en16931/bill.go +++ b/addons/eu/en16931/bill.go @@ -7,6 +7,71 @@ import ( "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, diff --git a/addons/eu/en16931/bill_test.go b/addons/eu/en16931/bill_test.go index a54abaec..1d819671 100644 --- a/addons/eu/en16931/bill_test.go +++ b/addons/eu/en16931/bill_test.go @@ -114,3 +114,87 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { } 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 index 5048ab4e..2c150e05 100644 --- a/addons/eu/en16931/en16931.go +++ b/addons/eu/en16931/en16931.go @@ -48,6 +48,14 @@ func normalize(doc any) { 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) } } 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 4842ea79..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() } @@ -502,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_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/iso/extensions.go b/catalogues/iso/extensions.go deleted file mode 100644 index 6497ebc7..00000000 --- a/catalogues/iso/extensions.go +++ /dev/null @@ -1,28 +0,0 @@ -package iso - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" -) - -const ( - // ExtKeySchemeID is used by the ISO 6523 scheme identifier. - ExtKeySchemeID cbc.Key = "iso-scheme-id" -) - -var extensions = []*cbc.KeyDefinition{ - { - Key: ExtKeySchemeID, - Name: i18n.NewString("ISO/IEC 6523 Identifier scheme code"), - Desc: i18n.NewString(here.Doc(` - Defines a global structure for uniquely identifying organizations or entities. - This standard is essential in environments where electronic communications require - unambiguous identification of organizations, especially in automated systems or - electronic data interchange (EDI). - - The ISO 6523 set of identifies is used by the EN16931 standard for electronic invoicing. - `)), - Pattern: `^\d{4}$`, - }, -} diff --git a/catalogues/iso/iso.go b/catalogues/iso/iso.go index 1c6b4cfe..d6e0210c 100644 --- a/catalogues/iso/iso.go +++ b/catalogues/iso/iso.go @@ -3,6 +3,7 @@ package iso import ( + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/tax" ) @@ -13,8 +14,10 @@ func init() { func newCatalogue() *tax.CatalogueDef { return &tax.CatalogueDef{ - Key: "iso", - Name: i18n.NewString("ISO/IEC Data Elements"), - Extensions: extensions, + Key: "iso", + Name: i18n.NewString("ISO/IEC Data Elements"), + Extensions: []*cbc.KeyDefinition{ + extSchemeID, + }, } } 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/extensions.go b/catalogues/untdid/extensions.go deleted file mode 100644 index ed2b66d8..00000000 --- a/catalogues/untdid/extensions.go +++ /dev/null @@ -1,746 +0,0 @@ -package untdid - -import ( - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/pkg/here" -) - -const ( - // ExtKeyDocumentType is used to identify the UNTDID 1001 document type code. - ExtKeyDocumentType cbc.Key = "untdid-document-type" - // ExtKeyPaymentMeans is used to identify the UNTDID 4461 payment means code. - ExtKeyPaymentMeans cbc.Key = "untdid-payment-means" - // ExtKeyTaxCategory is used to identify the UNTDID 5305 duty/tax/fee category code. - ExtKeyTaxCategory cbc.Key = "untdid-tax-category" -) - -var extensions = []*cbc.KeyDefinition{ - { - Key: ExtKeyDocumentType, - Name: i18n.String{ - i18n.EN: "UNTDID 1001 Document Type", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - UNTDID 1001 code used to describe the type of document. Ths list is based - on the [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists) - values table which focusses on invoices and payments. - - Other tax regimes and addons may use their own subset of codes. - `), - }, - Values: []*cbc.ValueDefinition{ - { - Value: "71", - Name: i18n.NewString("Request for payment"), - }, - { - Value: "80", - Name: i18n.NewString("Debit note related to goods or services"), - }, - { - Value: "81", - Name: i18n.NewString("Credit note related to goods or services"), - }, - { - Value: "82", - Name: i18n.NewString("Metered services invoice"), - }, - { - Value: "83", - Name: i18n.NewString("Credit note related to financial adjustments"), - }, - { - Value: "84", - Name: i18n.NewString("Debit note related to financial adjustments"), - }, - { - Value: "102", - Name: i18n.NewString("Tax notification"), - }, - { - Value: "130", - Name: i18n.NewString("Invoicing data sheet"), - }, - { - Value: "202", - Name: i18n.NewString("Direct payment valuation"), - }, - { - Value: "203", - Name: i18n.NewString("Provisional payment valuation"), - }, - { - Value: "204", - Name: i18n.NewString("Payment valuation"), - }, - { - Value: "211", - Name: i18n.NewString("Interim application for payment"), - }, - { - Value: "218", - Name: i18n.NewString("Final payment request based on completion of work"), - }, - { - Value: "219", - Name: i18n.NewString("Payment request for completed units"), - }, - { - Value: "261", - Name: i18n.NewString("Self billed credit note"), - }, - { - Value: "262", - Name: i18n.NewString("Consolidated credit note - goods and services"), - }, - { - Value: "295", - Name: i18n.NewString("Price variation invoice"), - }, - { - Value: "296", - Name: i18n.NewString("Credit note for price variation"), - }, - { - Value: "308", - Name: i18n.NewString("Delcredere credit note"), - }, - { - Value: "325", - Name: i18n.NewString("Proforma invoice"), - }, - { - Value: "326", - Name: i18n.NewString("Partial invoice"), - }, - { - Value: "380", - Name: i18n.NewString("Standard Invoice"), - }, - { - Value: "381", - Name: i18n.NewString("Credit note"), - }, - { - Value: "382", - Name: i18n.NewString("Commission note"), - }, - { - Value: "383", - Name: i18n.NewString("Debit note"), - }, - { - Value: "384", - Name: i18n.NewString("Corrected invoice"), - }, - { - Value: "385", - Name: i18n.NewString("Consolidated invoice"), - }, - { - Value: "386", - Name: i18n.NewString("Prepayment invoice"), - }, - { - Value: "387", - Name: i18n.NewString("Hire invoice"), - }, - { - Value: "388", - Name: i18n.NewString("Tax invoice"), - }, - { - Value: "389", - Name: i18n.NewString("Self-billed invoice"), - }, - { - Value: "390", - Name: i18n.NewString("Delcredere invoice"), - }, - { - Value: "393", - Name: i18n.NewString("Factored invoice"), - }, - { - Value: "394", - Name: i18n.NewString("Lease invoice"), - }, - { - Value: "395", - Name: i18n.NewString("Consignment invoice"), - }, - { - Value: "396", - Name: i18n.NewString("Factored credit note"), - }, - { - Value: "420", - Name: i18n.NewString("Optical Character Reading (OCR) payment credit note"), - }, - { - Value: "456", - Name: i18n.NewString("Debit advice"), - }, - { - Value: "457", - Name: i18n.NewString("Reversal of debit"), - }, - { - Value: "458", - Name: i18n.NewString("Reversal of credit"), - }, - { - Value: "527", - Name: i18n.NewString("Self billed debit note"), - }, - { - Value: "532", - Name: i18n.NewString("Forwarder's credit note"), - }, - { - Value: "553", - Name: i18n.NewString("Forwarder's invoice discrepancy report"), - }, - { - Value: "575", - Name: i18n.NewString("Insurer's invoice"), - }, - { - Value: "623", - Name: i18n.NewString("Forwarder's invoice"), - }, - { - Value: "633", - Name: i18n.NewString("Port charges documents"), - }, - { - Value: "751", - Name: i18n.NewString("Invoice information for accounting purposes"), - }, - { - Value: "780", - Name: i18n.NewString("Freight invoice"), - }, - { - Value: "817", - Name: i18n.NewString("Claim notification"), - }, - { - Value: "870", - Name: i18n.NewString("Consular invoice"), - }, - { - Value: "875", - Name: i18n.NewString("Partial construction invoice"), - }, - { - Value: "876", - Name: i18n.NewString("Partial final construction invoice"), - }, - { - Value: "877", - Name: i18n.NewString("Final construction invoice"), - }, - { - Value: "935", - Name: i18n.NewString("Customs invoice"), - }, - }, - }, - { - Key: ExtKeyPaymentMeans, - Name: i18n.String{ - i18n.EN: "UNTDID 4461 Payment Means", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - UNTDID 4461 code used to describe the means of payment. This list is based on the - [EN16931 code list](https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists) - values table which focusses on invoices and payments. - `), - }, - Values: []*cbc.ValueDefinition{ - { - Value: "1", - Name: i18n.NewString("Instrument not defined"), - }, - { - Value: "2", - Name: i18n.NewString("Automated clearing house credit"), - }, - { - Value: "3", - Name: i18n.NewString("Automated clearing house debit"), - }, - { - Value: "4", - Name: i18n.NewString("ACH demand debit reversal"), - }, - { - Value: "5", - Name: i18n.NewString("ACH demand credit reversal"), - }, - { - Value: "6", - Name: i18n.NewString("ACH demand credit"), - }, - { - Value: "7", - Name: i18n.NewString("ACH demand debit"), - }, - { - Value: "8", - Name: i18n.NewString("Hold"), - }, - { - Value: "9", - Name: i18n.NewString("National or regional clearing"), - }, - { - Value: "10", - Name: i18n.NewString("In cash"), - }, - { - Value: "11", - Name: i18n.NewString("ACH savings credit reversal"), - }, - { - Value: "12", - Name: i18n.NewString("ACH savings debit reversal"), - }, - { - Value: "13", - Name: i18n.NewString("ACH savings credit"), - }, - { - Value: "14", - Name: i18n.NewString("ACH savings debit"), - }, - { - Value: "15", - Name: i18n.NewString("Bookentry credit"), - }, - { - Value: "16", - Name: i18n.NewString("Bookentry debit"), - }, - { - Value: "17", - Name: i18n.NewString("ACH demand cash concentration/disbursement (CCD) credit"), - }, - { - Value: "18", - Name: i18n.NewString("ACH demand cash concentration/disbursement (CCD) debit"), - }, - { - Value: "19", - Name: i18n.NewString("ACH demand corporate trade payment (CTP) credit"), - }, - { - Value: "20", - Name: i18n.NewString("Cheque"), - }, - { - Value: "21", - Name: i18n.NewString("Banker's draft"), - }, - { - Value: "22", - Name: i18n.NewString("Certified banker's draft"), - }, - { - Value: "23", - Name: i18n.NewString("Bank cheque (issued by a banking or similar establishment)"), - }, - { - Value: "24", - Name: i18n.NewString("Bill of exchange awaiting acceptance"), - }, - { - Value: "25", - Name: i18n.NewString("Certified cheque"), - }, - { - Value: "26", - Name: i18n.NewString("Local cheque"), - }, - { - Value: "27", - Name: i18n.NewString("ACH demand corporate trade payment (CTP) debit"), - }, - { - Value: "28", - Name: i18n.NewString("ACH demand corporate trade exchange (CTX) credit"), - }, - { - Value: "29", - Name: i18n.NewString("ACH demand corporate trade exchange (CTX) debit"), - }, - { - Value: "30", - Name: i18n.NewString("Credit transfer"), - }, - { - Value: "31", - Name: i18n.NewString("Debit transfer"), - }, - { - Value: "32", - Name: i18n.NewString("ACH demand cash concentration/disbursement plus (CCD+)"), - }, - { - Value: "33", - Name: i18n.NewString("ACH demand cash concentration/disbursement plus (CCD+)"), - }, - { - Value: "34", - Name: i18n.NewString("ACH prearranged payment and deposit (PPD)"), - }, - { - Value: "35", - Name: i18n.NewString("ACH savings cash concentration/disbursement (CCD) credit"), - }, - { - Value: "36", - Name: i18n.NewString("ACH savings cash concentration/disbursement (CCD) debit"), - }, - { - Value: "37", - Name: i18n.NewString("ACH savings corporate trade payment (CTP) credit"), - }, - { - Value: "38", - Name: i18n.NewString("ACH savings corporate trade payment (CTP) debit"), - }, - { - Value: "39", - Name: i18n.NewString("ACH savings corporate trade exchange (CTX) credit"), - }, - { - Value: "40", - Name: i18n.NewString("ACH savings corporate trade exchange (CTX) debit"), - }, - { - Value: "41", - Name: i18n.NewString("ACH savings cash concentration/disbursement plus (CCD+)"), - }, - { - Value: "42", - Name: i18n.NewString("Payment to bank account"), - }, - { - Value: "43", - Name: i18n.NewString("ACH savings cash concentration/disbursement plus (CCD+)"), - }, - { - Value: "44", - Name: i18n.NewString("Accepted bill of exchange"), - }, - { - Value: "45", - Name: i18n.NewString("Referenced home-banking credit transfer"), - }, - { - Value: "46", - Name: i18n.NewString("Interbank debit transfer"), - }, - { - Value: "47", - Name: i18n.NewString("Home-banking debit transfer"), - }, - { - Value: "48", - Name: i18n.NewString("Bank card"), - }, - { - Value: "49", - Name: i18n.NewString("Direct debit"), - }, - { - Value: "50", - Name: i18n.NewString("Payment by postgiro"), - }, - { - Value: "51", - Name: i18n.NewString("FR, norme 6 97-Telereglement CFONB (French Organisation for"), - }, - { - Value: "52", - Name: i18n.NewString("Urgent commercial payment"), - }, - { - Value: "53", - Name: i18n.NewString("Urgent Treasury Payment"), - }, - { - Value: "54", - Name: i18n.NewString("Credit card"), - }, - { - Value: "55", - Name: i18n.NewString("Debit card"), - }, - { - Value: "56", - Name: i18n.NewString("Bankgiro"), - }, - { - Value: "57", - Name: i18n.NewString("Standing agreement"), - }, - { - Value: "58", - Name: i18n.NewString("SEPA credit transfer"), - }, - { - Value: "59", - Name: i18n.NewString("SEPA direct debit"), - }, - { - Value: "60", - Name: i18n.NewString("Promissory note"), - }, - { - Value: "61", - Name: i18n.NewString("Promissory note signed by the debtor"), - }, - { - Value: "62", - Name: i18n.NewString("Promissory note signed by the debtor and endorsed by a bank"), - }, - { - Value: "63", - Name: i18n.NewString("Promissory note signed by the debtor and endorsed by a"), - }, - { - Value: "64", - Name: i18n.NewString("Promissory note signed by a bank"), - }, - { - Value: "65", - Name: i18n.NewString("Promissory note signed by a bank and endorsed by another"), - }, - { - Value: "66", - Name: i18n.NewString("Promissory note signed by a third party"), - }, - { - Value: "67", - Name: i18n.NewString("Promissory note signed by a third party and endorsed by a"), - }, - { - Value: "68", - Name: i18n.NewString("Online payment service"), - }, - { - Value: "69", - Name: i18n.NewString("Transfer Advice"), - }, - { - Value: "70", - Name: i18n.NewString("Bill drawn by the creditor on the debtor"), - }, - { - Value: "74", - Name: i18n.NewString("Bill drawn by the creditor on a bank"), - }, - { - Value: "75", - Name: i18n.NewString("Bill drawn by the creditor, endorsed by another bank"), - }, - { - Value: "76", - Name: i18n.NewString("Bill drawn by the creditor on a bank and endorsed by a"), - }, - { - Value: "77", - Name: i18n.NewString("Bill drawn by the creditor on a third party"), - }, - { - Value: "78", - Name: i18n.NewString("Bill drawn by creditor on third party, accepted and"), - }, - { - Value: "91", - Name: i18n.NewString("Not transferable banker's draft"), - }, - { - Value: "92", - Name: i18n.NewString("Not transferable local cheque"), - }, - { - Value: "93", - Name: i18n.NewString("Reference giro"), - }, - { - Value: "94", - Name: i18n.NewString("Urgent giro"), - }, - { - Value: "95", - Name: i18n.NewString("Free format giro"), - }, - { - Value: "96", - Name: i18n.NewString("Requested method for payment was not used"), - }, - { - Value: "97", - Name: i18n.NewString("Clearing between partners"), - }, - { - Value: "98", - Name: i18n.NewString("JP, Electronically Recorded Monetary Claims"), - }, - { - Value: "ZZZ", - Name: i18n.NewString("Mutually defined"), - }, - }, - }, - { - Key: ExtKeyTaxCategory, - Name: i18n.String{ - i18n.EN: "UNTDID 3505 Tax Category", - }, - Desc: i18n.String{ - i18n.EN: here.Doc(` - UNTDID 5305 code used to describe the applicable duty/tax/fee category. There are - multiple versions and subsets of this table so regimes and addons may need to filter - options for a specific subset of values. - - Data from https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5305.htm. - `), - }, - Values: []*cbc.ValueDefinition{ - { - Value: "A", - Name: i18n.String{ - i18n.EN: "Mixed tax rate", - }, - }, - { - Value: "AA", - Name: i18n.String{ - i18n.EN: "Lower rate", - }, - }, - { - Value: "AB", - Name: i18n.String{ - i18n.EN: "Exempt for resale", - }, - }, - { - Value: "AC", - Name: i18n.String{ - i18n.EN: "Exempt for resale", - }, - }, - { - Value: "AD", - Name: i18n.String{ - i18n.EN: "Value Added Tax (VAT) due from a previous invoice", - }, - }, - { - Value: "AE", - Name: i18n.String{ - i18n.EN: "VAT Reverse Charge", - }, - }, - { - Value: "B", - Name: i18n.String{ - i18n.EN: "Transferred (VAT)", - }, - }, - { - Value: "C", - Name: i18n.String{ - i18n.EN: "Duty paid by supplier", - }, - }, - { - Value: "D", - Name: i18n.String{ - i18n.EN: "Value Added Tax (VAT) margin scheme - travel agents", - }, - }, - { - Value: "E", - Name: i18n.String{ - i18n.EN: "Exempt from tax", - }, - }, - { - Value: "F", - Name: i18n.String{ - i18n.EN: "Value Added Tax (VAT) margin scheme - second-hand goods", - }, - }, - { - Value: "G", - Name: i18n.String{ - i18n.EN: "Free export item, tax not charged", - }, - }, - { - Value: "H", - Name: i18n.String{ - i18n.EN: "Higher rate", - }, - }, - { - Value: "I", - Name: i18n.String{ - i18n.EN: "Value Added Tax (VAT) margin scheme - works of art", - }, - }, - { - Value: "J", - Name: i18n.String{ - i18n.EN: "Value Added Tax (VAT) margin scheme - collector's items and antiques", - }, - }, - { - Value: "K", - Name: i18n.String{ - i18n.EN: "VAT exempt for EEA intra-community supply of goods and services", - }, - }, - { - Value: "L", - Name: i18n.String{ - i18n.EN: "Canary Islands general indirect tax", - }, - }, - { - Value: "M", - Name: i18n.String{ - i18n.EN: "Tax for production, services and importation in Ceuta and Melilla", - }, - }, - { - Value: "O", - Name: i18n.String{ - i18n.EN: "Services outside scope of tax", - }, - }, - { - Value: "S", - Name: i18n.String{ - i18n.EN: "Standard Rate", - }, - }, - { - Value: "Z", - Name: i18n.String{ - i18n.EN: "Zero rated goods", - }, - }, - }, - }, -} diff --git a/catalogues/untdid/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 index 57cfdf67..1f79c807 100644 --- a/catalogues/untdid/untdid.go +++ b/catalogues/untdid/untdid.go @@ -2,6 +2,7 @@ package untdid import ( + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/tax" ) @@ -12,8 +13,14 @@ func init() { func newCatalogue() *tax.CatalogueDef { return &tax.CatalogueDef{ - Key: "untdid", - Name: i18n.NewString("UN/EDIFACT Data Elements"), - Extensions: extensions, + 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/cbc/code.go b/cbc/code.go index 34a67f57..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,15 @@ 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]`) ) diff --git a/cbc/code_test.go b/cbc/code_test.go index 45505643..ab4100c9 100644 --- a/cbc/code_test.go +++ b/cbc/code_test.go @@ -103,6 +103,11 @@ 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) { @@ -281,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/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/catalogues/untdid.json b/data/catalogues/untdid.json index b3074f37..677a46c7 100644 --- a/data/catalogues/untdid.json +++ b/data/catalogues/untdid.json @@ -856,6 +856,131 @@ } ] }, + { + "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": { @@ -992,6 +1117,1109 @@ } } ] + }, + { + "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/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 348cde88..cc5574fc 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", @@ -398,7 +515,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", @@ -429,14 +546,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", @@ -573,6 +682,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", @@ -584,15 +758,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", @@ -603,26 +772,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", @@ -654,12 +895,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": { @@ -721,59 +962,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/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/pay/advance.json b/data/schemas/pay/advance.json index e6f9b779..b3e92362 100644 --- a/data/schemas/pay/advance.json +++ b/data/schemas/pay/advance.json @@ -157,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", @@ -170,6 +175,7 @@ }, "type": "object", "required": [ + "first6", "last4", "holder" ], diff --git a/data/schemas/pay/instructions.json b/data/schemas/pay/instructions.json index 5e3597d4..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" ], @@ -164,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/regime-def.json b/data/schemas/tax/regime-def.json index 86306c84..0f7ba3d0 100644 --- a/data/schemas/tax/regime-def.json +++ b/data/schemas/tax/regime-def.json @@ -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/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/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 4f4b3c90..867b7af1 100644 --- a/org/party.go +++ b/org/party.go @@ -80,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/pay/instructions.go b/pay/instructions.go index d7157c33..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,6 +90,7 @@ 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) } @@ -131,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/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/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/tax/regime_def.go b/tax/regime_def.go index 111d5d39..94d68308 100644 --- a/tax/regime_def.go +++ b/tax/regime_def.go @@ -70,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"` @@ -286,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), From 3e42b81624eb7760ce26a0a5583479428717e74b Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 31 Oct 2024 12:04:15 +0000 Subject: [PATCH 26/27] MX: Fix RFC checks with symbols --- addons/mx/cfdi/food_vouchers.go | 5 +++- addons/mx/cfdi/fuel_account_balance.go | 6 ++++- addons/mx/cfdi/fuel_account_balance_test.go | 8 ++++++- regimes/br/examples/out/invoice-br-br.json | 4 ++-- regimes/ca/examples/out/invoice-ca-ca.json | 4 ++-- .../de/examples/out/invoice-de-de-stnr.json | 4 ++-- regimes/de/examples/out/invoice-de-de.json | 4 ++-- .../de/examples/out/invoice-de-es-b2b.json | 4 ++-- .../examples/out/invoice-de-simplified.json | 4 ++-- .../examples/out/credit-note-es-es-tbai.json | 4 ++-- .../examples/out/invoice-es-es-freelance.json | 4 ++-- regimes/es/examples/out/invoice-es-es.json | 4 ++-- .../examples/out/invoice-es-nl-tbai-b2c.json | 4 ++-- .../out/invoice-es-nl-tbai-exempt.json | 4 ++-- .../examples/out/invoice-es-simplified.json | 4 ++-- regimes/es/examples/out/invoice-es-usd.json | 4 ++-- regimes/fr/examples/out/invoice-fr-fr.json | 4 ++-- regimes/gb/examples/out/invoice-b2b.json | 4 ++-- regimes/gr/examples/out/invoice-el-el.json | 4 ++-- regimes/gr/examples/out/invoice-islands.json | 4 ++-- regimes/it/examples/out/flat-rate.json | 6 ++--- regimes/it/examples/out/freelance.json | 4 ++-- regimes/mx/examples/out/food-vouchers.json | 4 +++- .../mx/examples/out/fuel-account-balance.json | 4 +++- regimes/mx/mx.go | 2 +- regimes/mx/tax_identity.go | 23 +++++++++++++++++-- regimes/mx/tax_identity_test.go | 5 ++++ regimes/us/examples/out/invoice-us-us.json | 4 ++-- 28 files changed, 90 insertions(+), 49 deletions(-) diff --git a/addons/mx/cfdi/food_vouchers.go b/addons/mx/cfdi/food_vouchers.go index 903cbb61..b231ac23 100644 --- a/addons/mx/cfdi/food_vouchers.go +++ b/addons/mx/cfdi/food_vouchers.go @@ -47,6 +47,8 @@ type FoodVouchers struct { // one of the customer's employees. It maps to one `Concepto` node in the CFDI's // complement. type FoodVouchersLine struct { + // Line number starting from 1 (calculated). + Index int `json:"i" jsonschema:"title=Index" jsonschema_extras:"calculated=true"` // Identifier of the e-wallet that received the food voucher (maps to `Identificador`). EWalletID cbc.Code `json:"e_wallet_id" jsonschema:"title=E-wallet Identifier"` // Date and time of the food voucher's issue (maps to `Fecha`). @@ -123,7 +125,8 @@ func (fve *FoodVouchersEmployee) Validate() error { func (fvc *FoodVouchers) Calculate() error { fvc.Total = num.MakeAmount(0, FoodVouchersFinalPrecision) - for _, l := range fvc.Lines { + for i, l := range fvc.Lines { + l.Index = i + 1 l.Amount = l.Amount.Rescale(FoodVouchersFinalPrecision) fvc.Total = fvc.Total.Add(l.Amount) diff --git a/addons/mx/cfdi/fuel_account_balance.go b/addons/mx/cfdi/fuel_account_balance.go index 4972e6fe..921691e6 100644 --- a/addons/mx/cfdi/fuel_account_balance.go +++ b/addons/mx/cfdi/fuel_account_balance.go @@ -45,6 +45,8 @@ type FuelAccountBalance struct { // issued by the invoice's supplier. It maps to one // `ConceptoEstadoDeCuentaCombustible` node in the CFDI's complement. type FuelAccountLine struct { + // Index of the line starting from 1 (calculated) + Index int `json:"i" jsonschema:"title=Index" jsonschema_extras:"calculated=true"` // Identifier of the e-wallet used to make the purchase (maps to `Identificador`). EWalletID cbc.Code `json:"e_wallet_id" jsonschema:"title=E-wallet Identifier"` // Date and time of the purchase (maps to `Fecha`). @@ -113,6 +115,7 @@ func (fal *FuelAccountLine) Validate() error { validation.Field(&fal.VendorTaxCode, validation.Required, validation.By(mx.ValidateTaxCode), + validation.Skip, // don't use default code validations ), validation.Field(&fal.ServiceStationCode, validation.Required, @@ -179,7 +182,8 @@ func (fab *FuelAccountBalance) Calculate() error { taxtotal := num.MakeAmount(0, FuelAccountTotalsPrecision) fab.Subtotal = num.MakeAmount(0, FuelAccountTotalsPrecision) - for _, l := range fab.Lines { + for i, l := range fab.Lines { + l.Index = i + 1 // Normalise amounts to the expected precision l.Quantity = l.Quantity.RescaleUp(FuelAccountPriceMinimumPrecision) if l.Item != nil { diff --git a/addons/mx/cfdi/fuel_account_balance_test.go b/addons/mx/cfdi/fuel_account_balance_test.go index 8aea9049..63bf758f 100644 --- a/addons/mx/cfdi/fuel_account_balance_test.go +++ b/addons/mx/cfdi/fuel_account_balance_test.go @@ -23,7 +23,7 @@ func TestInvalidComplement(t *testing.T) { assert.Contains(t, err.Error(), "lines: cannot be blank") } -func TestInvalidLine(t *testing.T) { +func TestFuelAccountInvalidLine(t *testing.T) { fab := &cfdi.FuelAccountBalance{Lines: []*cfdi.FuelAccountLine{{}}} err := fab.Validate() @@ -47,6 +47,10 @@ func TestInvalidLine(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "vendor_tax_code: invalid tax identity code") assert.Contains(t, err.Error(), "total: must be quantity x unit_price") + + fab.Lines[0].VendorTaxCode = "K&A010301I16" // with symbols + err = fab.Validate() + assert.NotContains(t, err.Error(), "vendor_tax_code") } func TestInvalidItem(t *testing.T) { @@ -270,6 +274,7 @@ func TestCalculate(t *testing.T) { "total": "12.34", "lines": [ { + "i": 1, "e_wallet_id": "", "purchase_date_time": "0000-00-00T00:00:00", "vendor_tax_code": "", @@ -349,6 +354,7 @@ func TestCalculate(t *testing.T) { "total": "3832.93", "lines": [ { + "i": 1, "e_wallet_id": "", "purchase_date_time": "0000-00-00T00:00:00", "vendor_tax_code": "", diff --git a/regimes/br/examples/out/invoice-br-br.json b/regimes/br/examples/out/invoice-br-br.json index eebde428..8874104e 100644 --- a/regimes/br/examples/out/invoice-br-br.json +++ b/regimes/br/examples/out/invoice-br-br.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/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/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 8ea1e308..83b22fc3 100644 --- a/regimes/de/examples/out/invoice-de-de.json +++ b/regimes/de/examples/out/invoice-de-de.json @@ -78,9 +78,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-es-b2b.json b/regimes/de/examples/out/invoice-de-es-b2b.json index c595a695..91c7c301 100644 --- a/regimes/de/examples/out/invoice-de-es-b2b.json +++ b/regimes/de/examples/out/invoice-de-es-b2b.json @@ -83,9 +83,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-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/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/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/mx/examples/out/food-vouchers.json b/regimes/mx/examples/out/food-vouchers.json index cdf730d9..b1a20129 100644 --- a/regimes/mx/examples/out/food-vouchers.json +++ b/regimes/mx/examples/out/food-vouchers.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "ea065afce50358819fe68587f573dbad7c0d6ea1626d075b91faa924ebeb07db" + "val": "67be6a62c56ead0addad478296d30fb268c4300fd243be53b14e0f255bdb363a" } }, "doc": { @@ -107,6 +107,7 @@ "total": "30.52", "lines": [ { + "i": 1, "e_wallet_id": "ABC1234", "issue_date_time": "2022-07-19T10:20:30", "employee": { @@ -118,6 +119,7 @@ "amount": "10.12" }, { + "i": 2, "e_wallet_id": "BCD4321", "issue_date_time": "2022-08-20T11:20:30", "employee": { diff --git a/regimes/mx/examples/out/fuel-account-balance.json b/regimes/mx/examples/out/fuel-account-balance.json index 13ae6e58..1e43723f 100644 --- a/regimes/mx/examples/out/fuel-account-balance.json +++ b/regimes/mx/examples/out/fuel-account-balance.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "d6590300e024e94dd50400b2c016c574222ca1f5259e3da6132e66ebb9d514c9" + "val": "4cc59566b3bb62c784b54b7c938034bb0b5e90d492ba37f76afc975ab8e55384" } }, "doc": { @@ -107,6 +107,7 @@ "total": "400.00", "lines": [ { + "i": 1, "e_wallet_id": "1234", "purchase_date_time": "2022-07-19T10:20:30", "vendor_tax_code": "RWT860605OF5", @@ -134,6 +135,7 @@ ] }, { + "i": 2, "e_wallet_id": "1234", "purchase_date_time": "2022-08-19T10:20:30", "vendor_tax_code": "DJV320816JT1", diff --git a/regimes/mx/mx.go b/regimes/mx/mx.go index 8074ac23..e372cfe2 100644 --- a/regimes/mx/mx.go +++ b/regimes/mx/mx.go @@ -59,7 +59,7 @@ func Normalize(doc any) { case *bill.Invoice: normalizeInvoice(obj) case *tax.Identity: - tax.NormalizeIdentity(obj) + NormalizeTaxIdentity(obj) case *org.Party: normalizeParty(obj) } diff --git a/regimes/mx/tax_identity.go b/regimes/mx/tax_identity.go index fe9e794a..d30e6d71 100644 --- a/regimes/mx/tax_identity.go +++ b/regimes/mx/tax_identity.go @@ -2,6 +2,7 @@ package mx import ( "regexp" + "strings" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/tax" @@ -30,14 +31,15 @@ const ( // Tax Identity Patterns const ( - TaxIdentityPatternPerson = `^([A-ZÑ&]{4})([0-9]{6})([A-Z0-9]{3})$` - TaxIdentityPatternCompany = `^([A-ZÑ&]{3})([0-9]{6})([A-Z0-9]{3})$` + TaxIdentityPatternPerson = `^([A-ZÑ\&]{4})([0-9]{6})([A-Z0-9]{3})$` + TaxIdentityPatternCompany = `^([A-ZÑ\&]{3})([0-9]{6})([A-Z0-9]{3})$` ) // Tax Identity Regexp var ( TaxIdentityRegexpPerson = regexp.MustCompile(TaxIdentityPatternPerson) TaxIdentityRegexpCompany = regexp.MustCompile(TaxIdentityPatternCompany) + TaxCodeBadCharsRegexp = regexp.MustCompile(`[^A-ZÑ\&0-9]+`) ) // ValidateTaxIdentity validates a tax identity for SAT. @@ -45,10 +47,27 @@ func ValidateTaxIdentity(tID *tax.Identity) error { return validation.ValidateStruct(tID, validation.Field(&tID.Code, validation.By(ValidateTaxCode), + validation.Skip, // don't apply regular code validation ), ) } +// NormalizeTaxIdentity ensures the tax code is good for mexico +func NormalizeTaxIdentity(tID *tax.Identity) { + if tID == nil { + return + } + tID.Code = NormalizeTaxCode(tID.Code) +} + +// NormalizeTaxCode normalizes a tax code for SAT using the special +// rules it requires. +func NormalizeTaxCode(code cbc.Code) cbc.Code { + c := strings.ToUpper(code.String()) + c = TaxCodeBadCharsRegexp.ReplaceAllString(c, "") + return cbc.Code(c) +} + // ValidateTaxCode validates a tax code according to the rules // defined by the Mexican SAT. func ValidateTaxCode(value interface{}) error { diff --git a/regimes/mx/tax_identity_test.go b/regimes/mx/tax_identity_test.go index e43b14ba..9021081a 100644 --- a/regimes/mx/tax_identity_test.go +++ b/regimes/mx/tax_identity_test.go @@ -28,6 +28,10 @@ func TestTaxIdentityNormalization(t *testing.T) { Code: "GHI-701231-23Z", Expected: "GHI70123123Z", }, + { + Code: "K&A010301I16", + Expected: "K&A010301I16", + }, } for _, ts := range tests { tID := &tax.Identity{Country: "MX", Code: ts.Code} @@ -47,6 +51,7 @@ func TestTaxIdentityValidation(t *testing.T) { {name: "valid code 1", code: "MNOP8201019HJ"}, {name: "valid code 2", code: "UVWX610715JKL"}, {name: "valid code 3", code: "STU760612MN1"}, + {name: "with symbol", code: "K&A010301I16"}, { name: "invalid code 1", code: "STU760612MN", 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": [ From 57ed5b549f9a7f0f23c1a32e955966cf129b3816 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 31 Oct 2024 13:50:54 +0000 Subject: [PATCH 27/27] Updating CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfeb3591..438a202a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `tax`: Regime `ChargeKeys` removed. Keys now provided in `bill` package. - `it`: Charge keys no longer defined, no migration required, already supported. +### Fixed + +- `mx`: Tax ID validation now correctly supports `&` and `Ñ` symbols in codes. + ## [v0.203.0] ### Added