diff --git a/bill/invoice_correct.go b/bill/invoice_correct.go index 6e62d938..f43e258b 100644 --- a/bill/invoice_correct.go +++ b/bill/invoice_correct.go @@ -5,11 +5,14 @@ import ( "errors" "fmt" + "github.com/iancoleman/orderedmap" + "github.com/invopop/gobl/build" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/head" "github.com/invopop/gobl/schema" "github.com/invopop/gobl/tax" + "github.com/invopop/jsonschema" ) // CorrectionOptions defines a structure used to pass configuration options @@ -115,6 +118,84 @@ var Debit schema.Option = func(o interface{}) { opts.Debit = true } +// CorrectionOptionsSchema provides a dynamic JSON schema of the options +// that can be used on the invoice in order to correct it. Data is +// extracted from the tax regime associated with the supplier. +func (inv *Invoice) CorrectionOptionsSchema() (interface{}, error) { + r := taxRegimeFor(inv.Supplier) + if r == nil { + return nil, nil + } + cd := r.CorrectionDefinitionFor(ShortSchemaInvoice) + + schema := new(jsonschema.Schema) + + // try to load the pre-generated schema, this is just way more efficient + // than trying to generate the configuration options manually. + data, err := build.Content.ReadFile("schemas/bill/correction-options.json") + if err != nil { + return nil, fmt.Errorf("loading schema option data: %w", err) + } + if err := json.Unmarshal(data, schema); err != nil { + return nil, fmt.Errorf("unmarshalling options schema: %w", err) + } + schema = schema.Definitions["CorrectionOptions"] + + // Improve the quality of the schema + schema.Required = append(schema.Required, "credit") + if cd != nil { + if cd.ReasonRequired { + schema.Required = append(schema.Required, "reason") + } + + // These methods are quite ugly as the jsonschema was not designed + // for being able to load documents. + if len(cd.Methods) > 0 { + schema.Required = append(schema.Required, "method") + if prop, ok := schema.Properties.Get("method"); ok { + ps := prop.(orderedmap.OrderedMap) + oneOf := make([]*jsonschema.Schema, len(cd.Methods)) + for i, v := range cd.Methods { + oneOf[i] = &jsonschema.Schema{ + Const: v.Key.String(), + Title: v.Name.String(), + } + if !v.Desc.IsEmpty() { + oneOf[i].Description = v.Desc.String() + } + } + ps.Set("oneOf", oneOf) + schema.Properties.Set("method", ps) + } + } + + if len(cd.Keys) > 0 { + schema.Required = append(schema.Required, "changes") + if prop, ok := schema.Properties.Get("changes"); ok { + ps := prop.(orderedmap.OrderedMap) + items, _ := ps.Get("items") + pi := items.(orderedmap.OrderedMap) + + oneOf := make([]*jsonschema.Schema, len(cd.Keys)) + for i, v := range cd.Keys { + oneOf[i] = &jsonschema.Schema{ + Const: v.Key.String(), + Title: v.Name.String(), + } + if !v.Desc.IsEmpty() { + oneOf[i].Description = v.Desc.String() + } + } + pi.Set("oneOf", oneOf) + ps.Set("items", pi) + schema.Properties.Set("changes", ps) + } + } + } + + return schema, nil +} + // Correct moves key fields of the current invoice to the preceding // structure and performs any regime specific actions defined by the // regime's configuration. diff --git a/bill/invoice_correct_test.go b/bill/invoice_correct_test.go index a426e763..b14edcd2 100644 --- a/bill/invoice_correct_test.go +++ b/bill/invoice_correct_test.go @@ -1,8 +1,10 @@ package bill_test import ( + "encoding/json" "testing" + "github.com/iancoleman/orderedmap" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/cbc" @@ -14,6 +16,7 @@ import ( "github.com/invopop/gobl/regimes/common" "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" + "github.com/invopop/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -141,6 +144,30 @@ func TestCorrectWithOptions(t *testing.T) { assert.Equal(t, i.Totals.Payable.String(), "-900.00") } +func TestCorrectionOptionsSchema(t *testing.T) { + inv := testInvoiceESForCorrection(t) + out, err := inv.CorrectionOptionsSchema() + require.NoError(t, err) + + schema, ok := out.(*jsonschema.Schema) + require.True(t, ok) + + assert.Len(t, schema.Properties.Keys(), 7) + + mtd, ok := schema.Properties.Get("method") + require.True(t, ok) + pm := mtd.(orderedmap.OrderedMap) + assert.Len(t, pm.Keys(), 4) + + exp := `{"$ref":"https://gobl.org/draft-0/cbc/key","title":"Method","description":"Correction method as defined by the tax regime.","oneOf":[{"const":"complete","title":"Complete"},{"const":"partial","title":"Corrected items only"},{"const":"discount","title":"Bulk deal in a given period"},{"const":"authorized","title":"Authorized by the Tax Agency"}]` + + data, err := json.Marshal(pm) + require.NoError(t, err) + if !assert.JSONEq(t, exp, string(data)) { + t.Log(string(data)) + } +} + func TestCorrectWithData(t *testing.T) { i := testInvoiceESForCorrection(t) data := []byte(`{"credit":true,"reason":"test refund"}`) diff --git a/envelope.go b/envelope.go index a41b88d7..42ea4f02 100644 --- a/envelope.go +++ b/envelope.go @@ -216,3 +216,17 @@ func (e *Envelope) Correct(opts ...schema.Option) (*Envelope, error) { // Create a completely new envelope with a new set of data. return Envelop(nd) } + +// CorrectionOptionsSchema will attempt to provide a corrective options JSON Schema +// that can be used to generate a JSON object to send when correcting a document. +// If none are available, the result will be nil. +func (e *Envelope) CorrectionOptionsSchema() (interface{}, error) { + if e.Document == nil { + return nil, ErrNoDocument + } + opts, err := e.Document.CorrectionOptionsSchema() + if err != nil { + return nil, err + } + return opts, nil +} diff --git a/i18n/string.go b/i18n/string.go index 85c98e29..326ca15e 100644 --- a/i18n/string.go +++ b/i18n/string.go @@ -9,12 +9,17 @@ const ( // String provides a simple map of locales to texts. type String map[Lang]string -// String provides a single string from the map using the -// language requested or resorting to the default. -func (s String) String(lang Lang) string { +// In provides a single string from the map using the +// language requested or resorts to the default. +func (s String) In(lang Lang) string { if v, ok := s[lang]; ok { return v } + return s.String() +} + +// String returns the default language string or first entry found. +func (s String) String() string { if v, ok := s[defaultLanguage]; ok { return v } @@ -24,6 +29,11 @@ func (s String) String(lang Lang) string { return "" } +// IsEmpty returns true if the string map is empty. +func (s String) IsEmpty() bool { + return len(s) == 0 +} + // JSONSchema returns the json schema definition func (String) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ diff --git a/i18n/string_test.go b/i18n/string_test.go index 09449da3..26dcdcbf 100644 --- a/i18n/string_test.go +++ b/i18n/string_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/invopop/gobl/i18n" + "github.com/stretchr/testify/assert" ) func TestI18nString(t *testing.T) { @@ -12,20 +13,14 @@ func TestI18nString(t *testing.T) { "es": "Prueba", } - if x := s.String("en"); x != "Test" { - t.Errorf("Unexpected string result: %v", x) - } - if x := s.String("es"); x != "Prueba" { - t.Errorf("Unexpected string result: %v", x) - } - if x := s.String("fo"); x != "Test" { - t.Errorf("Unexpected string result: %v", x) - } + assert.Equal(t, "Test", s.In("en")) + assert.Equal(t, "Prueba", s.In("es")) + assert.Equal(t, "Test", s.In("fo")) + assert.Equal(t, "Test", s.String()) snd := i18n.String{ i18n.AA: "Foo", } - if x := snd.String("en"); x != "Foo" { - t.Errorf("Unexpected string result: %v", x) - } + assert.Equal(t, "Foo", snd.In("en")) + assert.Equal(t, "Foo", snd.String()) } diff --git a/regimes/co/preceding.go b/regimes/co/corrections.go similarity index 82% rename from regimes/co/preceding.go rename to regimes/co/corrections.go index c982570b..7ad4124d 100644 --- a/regimes/co/preceding.go +++ b/regimes/co/corrections.go @@ -19,6 +19,10 @@ const ( var correctionMethodList = []*tax.KeyDefinition{ { Key: CorrectionMethodKeyPartial, + Name: i18n.String{ + i18n.EN: "Partial refund", + i18n.ES: "Devolución parcial", + }, Desc: i18n.String{ i18n.EN: "Partial refund of part of the goods or services.", i18n.ES: "Devolución de parte de los bienes; no aceptación de partes del servicio.", @@ -29,6 +33,10 @@ var correctionMethodList = []*tax.KeyDefinition{ }, { Key: CorrectionMethodKeyRevoked, + Name: i18n.String{ + i18n.EN: "Revoked", + i18n.ES: "Anulación", + }, Desc: i18n.String{ i18n.EN: "Previous document has been completely cancelled.", i18n.ES: "Anulación de la factura anterior.", @@ -39,6 +47,10 @@ var correctionMethodList = []*tax.KeyDefinition{ }, { Key: CorrectionMethodKeyDiscount, + Name: i18n.String{ + i18n.EN: "Discount", + i18n.ES: "Descuento", + }, Desc: i18n.String{ i18n.EN: "Partial or total discount.", i18n.ES: "Rebaja o descuento parcial o total.", @@ -49,9 +61,13 @@ var correctionMethodList = []*tax.KeyDefinition{ }, { Key: CorrectionMethodKeyPriceAdjustment, + Name: i18n.String{ + i18n.EN: "Adjustment", + i18n.ES: "Ajuste", + }, Desc: i18n.String{ - i18n.EN: "Ajuste de precio.", - i18n.ES: "Price adjustment.", + i18n.EN: "Price adjustment.", + i18n.ES: "Ajuste de precio.", }, Map: cbc.CodeMap{ KeyDIAN: "4", diff --git a/regimes/co/tax_identity.go b/regimes/co/tax_identity.go index 8c0867b7..4dce140d 100644 --- a/regimes/co/tax_identity.go +++ b/regimes/co/tax_identity.go @@ -210,8 +210,8 @@ func normalizePartyWithTaxIdentity(p *org.Party) error { return nil } a := p.Addresses[0] - a.Locality = z.Locality.String(i18n.ES) - a.Region = z.Region.String(i18n.ES) + a.Locality = z.Locality.In(i18n.ES) + a.Region = z.Region.In(i18n.ES) } } return nil diff --git a/schema/object.go b/schema/object.go index 2eecf0e1..99dfc854 100644 --- a/schema/object.go +++ b/schema/object.go @@ -42,6 +42,7 @@ type Calculable interface { // corrected. type Correctable interface { Correct(...Option) error + CorrectionOptionsSchema() (interface{}, error) } // NewObject instantiates an Object wrapper around the provided payload. @@ -107,6 +108,20 @@ func (d *Object) Correct(opts ...Option) error { return nil } +// CorrectionOptionsSchema provides a schema with the correction options available +// for the schema, if available. +func (d *Object) CorrectionOptionsSchema() (interface{}, error) { + pl, ok := d.payload.(Correctable) + if !ok { + return nil, nil + } + res, err := pl.CorrectionOptionsSchema() + if err != nil { + return nil, err + } + return res, nil +} + // Insert places the provided object inside the document and looks up the schema // information to ensure it is known. func (d *Object) insert(payload interface{}) error {