diff --git a/bill/invoice_correct.go b/bill/invoice_correct.go index 6e62d938..e37c0cba 100644 --- a/bill/invoice_correct.go +++ b/bill/invoice_correct.go @@ -4,12 +4,16 @@ import ( "encoding/json" "errors" "fmt" + "strings" + "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 +119,93 @@ 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 + } + + 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) + } + + // Add our regime to the schema ID + code := strings.ToLower(r.Code().String()) + id := fmt.Sprintf("%s?tax_regime=%s", schema.ID.String(), code) + schema.ID = jsonschema.ID(id) + + cos := schema.Definitions["CorrectionOptions"] + + // Improve the quality of the schema + cos.Required = append(cos.Required, "credit") + + cd := r.CorrectionDefinitionFor(ShortSchemaInvoice) + if cd == nil { + return schema, nil + } + + if cd.ReasonRequired { + cos.Required = append(cos.Required, "reason") + } + + // These methods are quite ugly as the jsonschema was not designed + // for being able to load documents. + if len(cd.Methods) > 0 { + cos.Required = append(cos.Required, "method") + if prop, ok := cos.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) + cos.Properties.Set("method", ps) + } + } + + if len(cd.Keys) > 0 { + cos.Required = append(cos.Required, "changes") + if prop, ok := cos.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) + cos.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..34302694 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,34 @@ 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) + + cos := schema.Definitions["CorrectionOptions"] + assert.Len(t, cos.Properties.Keys(), 7) + + mtd, ok := cos.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)) + } + + data, err = json.Marshal(schema) + require.NoError(t, err) + assert.Contains(t, string(data), `"$id":"https://gobl.org/draft-0/bill/correction-options?tax_regime=es"`) +} + func TestCorrectWithData(t *testing.T) { i := testInvoiceESForCorrection(t) data := []byte(`{"credit":true,"reason":"test refund"}`) diff --git a/build/regimes/co.json b/build/regimes/co.json index 52f25ac7..e3c86f1a 100644 --- a/build/regimes/co.json +++ b/build/regimes/co.json @@ -14741,7 +14741,10 @@ "methods": [ { "key": "partial", - "name": null, + "name": { + "en": "Partial refund", + "es": "Devolución parcial" + }, "desc": { "en": "Partial refund of part of the goods or services.", "es": "Devolución de parte de los bienes; no aceptación de partes del servicio." @@ -14752,7 +14755,10 @@ }, { "key": "revoked", - "name": null, + "name": { + "en": "Revoked", + "es": "Anulación" + }, "desc": { "en": "Previous document has been completely cancelled.", "es": "Anulación de la factura anterior." @@ -14763,7 +14769,10 @@ }, { "key": "discount", - "name": null, + "name": { + "en": "Discount", + "es": "Descuento" + }, "desc": { "en": "Partial or total discount.", "es": "Rebaja o descuento parcial o total." @@ -14774,10 +14783,13 @@ }, { "key": "price-adjustment", - "name": null, + "name": { + "en": "Adjustment", + "es": "Ajuste" + }, "desc": { - "en": "Ajuste de precio.", - "es": "Price adjustment." + "en": "Price adjustment.", + "es": "Ajuste de precio." }, "map": { "dian": "4" 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 { diff --git a/tax/regime.go b/tax/regime.go index cceb6092..65a3c5ec 100644 --- a/tax/regime.go +++ b/tax/regime.go @@ -254,6 +254,11 @@ type CodeDefinition struct { Desc i18n.String `json:"desc,omitempty" jsonschema:"title=Description"` } +// Code provides a unique code for this tax regime based on the country. +func (r *Regime) Code() cbc.Code { + return cbc.Code(r.Country) +} + // ValidateObject performs validation on the provided object in the context // of the regime. func (r *Regime) ValidateObject(value interface{}) error { diff --git a/version.go b/version.go index ba8c4918..96acf8c7 100644 --- a/version.go +++ b/version.go @@ -8,7 +8,7 @@ import ( type Version string // VERSION is the current version of the GOBL library. -const VERSION Version = "v0.58.0" +const VERSION Version = "v0.58.1" // Semver parses and returns semver func (v Version) Semver() *semver.Version {