Skip to content

Commit

Permalink
Merge pull request #198 from invopop/regime-corrections
Browse files Browse the repository at this point in the history
Refactoring regime correction handling and copy
  • Loading branch information
samlown authored Sep 18, 2023
2 parents 2ca043b + 97c9fbe commit 18b6b68
Show file tree
Hide file tree
Showing 35 changed files with 767 additions and 520 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ The complexity around invoicing and in particular electronic invoicing can quick

- [Documentation](https://docs.gobl.org) contains details on how to use GOBL, and the schema.
- [Builder](https://build.gobl.org) helps try out GOBL and quickly figure out what is possible, all from your browser.
- [Blog](https://gobl.org/posts/) for news and general updates about what is being worked on.
- [Issues](https://github.com/invopop/gobl/issues) if you have a specific problem with GOBL related to code or usage.
- [Discussions](https://github.com/invopop/gobl/discussions) for open discussions about the future of GOBL, complications with a specific country, or any open ended issues.
- [Pull Requests](https://github.com/invopop/gobl/pulls) are very welcome, especially if you'd like to see a new local country or features.
Expand Down
153 changes: 101 additions & 52 deletions bill/invoice_correct.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/head"
"github.com/invopop/gobl/schema"
"github.com/invopop/gobl/tax"
)

// CorrectionOptions defines a structure used to pass configuration options
Expand All @@ -28,9 +29,9 @@ type CorrectionOptions struct {
// Human readable reason for the corrective operation.
Reason string `json:"reason,omitempty" jsonschema:"title=Reason"`
// Correction method as defined by the tax regime.
CorrectionMethod cbc.Key `json:"correction_method,omitempty" jsonschema:"title=Correction Method"`
// Correction keys that describe the specific changes according to the tax regime.
Corrections []cbc.Key `json:"corrections,omitempty" jsonschema:"title=Corrections"`
Method cbc.Key `json:"method,omitempty" jsonschema:"title=Method"`
// Changes keys that describe the specific changes according to the tax regime.
Changes []cbc.Key `json:"changes,omitempty" jsonschema:"title=Changes"`

// In case we want to use a raw json object as a source of the options.
data json.RawMessage `json:"-"`
Expand Down Expand Up @@ -74,20 +75,20 @@ func WithReason(reason string) schema.Option {
}
}

// WithCorrectionMethod defines the method used to correct the previous invoice.
func WithCorrectionMethod(method cbc.Key) schema.Option {
// WithMethod defines the method used to correct the previous invoice.
func WithMethod(method cbc.Key) schema.Option {
return func(o interface{}) {
opts := o.(*CorrectionOptions)
opts.CorrectionMethod = method
opts.Method = method
}
}

// WithCorrection adds a single correction key to the invoice preceding data,
// use multiple times for multiple entries.
func WithCorrection(correction cbc.Key) schema.Option {
// WithChanges adds the set of change keys to the invoice's preceding data,
// can be called multiple times.
func WithChanges(changes ...cbc.Key) schema.Option {
return func(o interface{}) {
opts := o.(*CorrectionOptions)
opts.Corrections = append(opts.Corrections, correction)
opts.Changes = append(opts.Changes, changes...)
}
}

Expand Down Expand Up @@ -121,24 +122,8 @@ var Debit schema.Option = func(o interface{}) {
// most use cases this will prevent looping over the same invoice.
func (inv *Invoice) Correct(opts ...schema.Option) error {
o := new(CorrectionOptions)
for _, row := range opts {
row(o)
}

// Copy over the stamps from the previous header
if o.Head != nil && len(o.Head.Stamps) > 0 {
o.Stamps = append(o.Stamps, o.Head.Stamps...)
}

// If we have a raw json object, this will override any of the other options
if len(o.data) > 0 {
if err := json.Unmarshal(o.data, o); err != nil {
return fmt.Errorf("failed to unmarshal correction options: %w", err)
}
}

if o.Credit && o.Debit {
return errors.New("cannot use both credit and debit options")
if err := prepareCorrectionOptions(o, opts...); err != nil {
return err
}
if inv.Code == "" {
return errors.New("cannot correct an invoice without a code")
Expand All @@ -156,8 +141,8 @@ func (inv *Invoice) Correct(opts ...schema.Option) error {
Code: inv.Code,
IssueDate: inv.IssueDate.Clone(),
Reason: o.Reason,
Corrections: o.Corrections,
CorrectionMethod: o.CorrectionMethod,
CorrectionMethod: o.Method,
Changes: o.Changes,
}
inv.UUID = nil
inv.Series = ""
Expand All @@ -168,12 +153,55 @@ func (inv *Invoice) Correct(opts ...schema.Option) error {
inv.IssueDate = cal.Today()
}

cd := r.CorrectionDefinitionFor(ShortSchemaInvoice)

if err := inv.prepareCorrectionType(o, cd); err != nil {
return err
}

if err := inv.validatePrecedingData(o, cd, pre); err != nil {
return err
}

// Replace all previous preceding data
inv.Preceding = []*Preceding{pre}

// Running a Calculate feels a bit out of place, but not performing
// this operation on the corrected invoice results in potentially
// conflicting or incomplete data.
return inv.Calculate()
}

func prepareCorrectionOptions(o *CorrectionOptions, opts ...schema.Option) error {
for _, row := range opts {
row(o)
}

// Copy over the stamps from the previous header
if o.Head != nil && len(o.Head.Stamps) > 0 {
o.Stamps = append(o.Stamps, o.Head.Stamps...)
}

// If we have a raw json object, this will override any of the other options
if len(o.data) > 0 {
if err := json.Unmarshal(o.data, o); err != nil {
return fmt.Errorf("failed to unmarshal correction options: %w", err)
}
}

if o.Credit && o.Debit {
return errors.New("cannot use both credit and debit options")
}
return nil
}

func (inv *Invoice) prepareCorrectionType(o *CorrectionOptions, cd *tax.CorrectionDefinition) error {
// Take the regime def to figure out what needs to be copied
if o.Credit {
if r.Preceding.HasType(InvoiceTypeCreditNote) {
if cd.HasType(InvoiceTypeCreditNote) {
// regular credit note
inv.Type = InvoiceTypeCreditNote
} else if r.Preceding.HasType(InvoiceTypeCorrective) {
} else if cd.HasType(InvoiceTypeCorrective) {
// corrective invoice with negative values
inv.Type = InvoiceTypeCorrective
inv.Invert()
Expand All @@ -182,7 +210,7 @@ func (inv *Invoice) Correct(opts ...schema.Option) error {
}
inv.Payment.ResetAdvances()
} else if o.Debit {
if r.Preceding.HasType(InvoiceTypeDebitNote) {
if cd.HasType(InvoiceTypeDebitNote) {
// regular debit note, implies no rows as new ones
// will be added
inv.Type = InvoiceTypeDebitNote
Expand All @@ -191,35 +219,56 @@ func (inv *Invoice) Correct(opts ...schema.Option) error {
return errors.New("debit note not supported by regime")
}
} else {
if r.Preceding.HasType(InvoiceTypeCorrective) {
if cd.HasType(InvoiceTypeCorrective) {
inv.Type = InvoiceTypeCorrective
} else {
return fmt.Errorf("corrective invoice type not supported by regime, try credit or debit")
}
}
return nil
}

// Make sure the stamps are there too
if r.Preceding != nil {
for _, k := range r.Preceding.Stamps {
var s *head.Stamp
for _, row := range o.Stamps {
if row.Provider == k {
s = row
break
}
func (inv *Invoice) validatePrecedingData(o *CorrectionOptions, cd *tax.CorrectionDefinition, pre *Preceding) error {
if cd == nil {
return nil
}
for _, k := range cd.Stamps {
var s *head.Stamp
for _, row := range o.Stamps {
if row.Provider == k {
s = row
break
}
if s == nil {
return fmt.Errorf("missing stamp: %v", k)
}
if s == nil {
return fmt.Errorf("missing stamp: %v", k)
}
pre.Stamps = append(pre.Stamps, s)
}

if len(cd.Methods) > 0 {
if pre.CorrectionMethod == cbc.KeyEmpty {
return errors.New("missing correction method")
}
if !cd.HasMethod(pre.CorrectionMethod) {
return fmt.Errorf("invalid correction method: %v", pre.CorrectionMethod)
}
}

if len(cd.Keys) > 0 {
if len(pre.Changes) == 0 {
return errors.New("missing changes")
}
for _, k := range pre.Changes {
if !cd.HasKey(k) {
return fmt.Errorf("invalid change key: '%v'", k)
}
pre.Stamps = append(pre.Stamps, s)
}
}

// Replace all previous preceding data
inv.Preceding = []*Preceding{pre}
if cd.ReasonRequired && pre.Reason == "" {
return errors.New("missing corrective reason")
}

// Running a Calculate feels a bit out of place, but not performing
// this operation on the corrected invoice results in potentially
// conflicting or incomplete data.
return inv.Calculate()
return nil
}
38 changes: 31 additions & 7 deletions bill/invoice_correct_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (

"github.com/invopop/gobl/bill"
"github.com/invopop/gobl/cal"
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/head"
"github.com/invopop/gobl/l10n"
"github.com/invopop/gobl/num"
"github.com/invopop/gobl/org"
"github.com/invopop/gobl/regimes/co"
"github.com/invopop/gobl/regimes/common"
"github.com/invopop/gobl/regimes/es"
"github.com/invopop/gobl/tax"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -24,7 +26,11 @@ func TestInvoiceCorrect(t *testing.T) {
assert.Contains(t, err.Error(), "cannot use both credit and debit options")

i = testInvoiceESForCorrection(t)
err = i.Correct(bill.Credit, bill.WithReason("test refund"))
err = i.Correct(bill.Credit,
bill.WithReason("test refund"),
bill.WithMethod(es.CorrectionMethodKeyComplete),
bill.WithChanges(es.CorrectionKeyLine),
)
require.NoError(t, err)
assert.Equal(t, bill.InvoiceTypeCorrective, i.Type)
assert.Equal(t, i.Lines[0].Quantity.String(), "-10")
Expand All @@ -47,14 +53,21 @@ func TestInvoiceCorrect(t *testing.T) {
assert.Contains(t, err.Error(), "debit note not supported by regime")

i = testInvoiceESForCorrection(t)
err = i.Correct()
err = i.Correct(
bill.WithMethod(es.CorrectionMethodKeyComplete),
bill.WithChanges(es.CorrectionKeyLine),
)
require.NoError(t, err)
assert.Equal(t, i.Type, bill.InvoiceTypeCorrective)

// With preset date
i = testInvoiceESForCorrection(t)
d := cal.MakeDate(2023, 6, 13)
err = i.Correct(bill.WithIssueDate(d))
err = i.Correct(
bill.WithIssueDate(d),
bill.WithMethod(es.CorrectionMethodKeyComplete),
bill.WithChanges(es.CorrectionKeyLine),
)
require.NoError(t, err)
assert.Equal(t, i.IssueDate, d)

Expand Down Expand Up @@ -93,7 +106,12 @@ func TestInvoiceCorrect(t *testing.T) {
assert.Contains(t, err.Error(), "corrective invoice type not supported by regime, try credit or debit")

i = testInvoiceCOForCorrection(t)
err = i.Correct(bill.Credit, bill.WithStamps(stamps), bill.WithCorrectionMethod(co.CorrectionMethodKeyRevoked))
err = i.Correct(
bill.Credit,
bill.WithStamps(stamps),
bill.WithMethod(co.CorrectionMethodKeyRevoked),
bill.WithReason("test refund"),
)
require.NoError(t, err)
assert.Equal(t, i.Type, bill.InvoiceTypeCreditNote)
pre = i.Preceding[0]
Expand All @@ -105,8 +123,10 @@ func TestInvoiceCorrect(t *testing.T) {
func TestCorrectWithOptions(t *testing.T) {
i := testInvoiceESForCorrection(t)
opts := &bill.CorrectionOptions{
Credit: true,
Reason: "test refund",
Credit: true,
Reason: "test refund",
Method: es.CorrectionMethodKeyComplete,
Changes: []cbc.Key{es.CorrectionKeyLine},
}
err := i.Correct(bill.WithOptions(opts))
require.NoError(t, err)
Expand All @@ -125,7 +145,11 @@ func TestCorrectWithData(t *testing.T) {
i := testInvoiceESForCorrection(t)
data := []byte(`{"credit":true,"reason":"test refund"}`)

err := i.Correct(bill.WithData(data))
err := i.Correct(
bill.WithData(data),
bill.WithMethod(es.CorrectionMethodKeyComplete),
bill.WithChanges(es.CorrectionKeyLine),
)
assert.NoError(t, err)
assert.Equal(t, i.Lines[0].Quantity.String(), "-10") // implies credit was made

Expand Down
28 changes: 24 additions & 4 deletions bill/preceding.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package bill

import (
"encoding/json"

"github.com/invopop/gobl/cal"
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/head"
Expand All @@ -24,16 +26,34 @@ type Preceding struct {
Reason string `json:"reason,omitempty" jsonschema:"title=Reason"`
// Seals of approval from other organisations that may need to be listed.
Stamps []*head.Stamp `json:"stamps,omitempty" jsonschema:"title=Stamps"`
// Tax regime specific keys reflecting why the preceding invoice is being replaced.
Corrections []cbc.Key `json:"corrections,omitempty" jsonschema:"title=Corrections"`
// Tax regime specific keys reflecting the method used to correct the preceding invoice.
// Tax regime specific key reflecting the method used to correct the preceding invoice.
CorrectionMethod cbc.Key `json:"correction_method,omitempty" jsonschema:"title=Correction Method"`
// Tax regime specific keys reflecting what has been changed from the previous invoice.
Changes []cbc.Key `json:"changes,omitempty" jsonschema:"title=Changes"`
// Tax period in which the previous invoice had an effect required by some tax regimes and formats.
Period *cal.Period `json:"period,omitempty" jsonschema:"title=Period"`
// Additional semi-structured data that may be useful in specific regions
Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"`
}

// UnmarshalJSON is used to handle the refactor away from "corrections" to "changes"
func (p *Preceding) UnmarshalJSON(data []byte) error {
type Alias Preceding
aux := &struct {
Corrections []cbc.Key `json:"corrections,omitempty"`
*Alias
}{
Alias: (*Alias)(p),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if len(aux.Corrections) != 0 {
p.Changes = aux.Corrections
}
return nil
}

// Validate ensures the preceding details look okay
func (p *Preceding) Validate() error {
return validation.ValidateStruct(p,
Expand All @@ -42,8 +62,8 @@ func (p *Preceding) Validate() error {
validation.Field(&p.Code, validation.Required),
validation.Field(&p.IssueDate, cal.DateNotZero()),
validation.Field(&p.Stamps),
validation.Field(&p.Corrections),
validation.Field(&p.CorrectionMethod),
validation.Field(&p.Changes),
validation.Field(&p.Period),
validation.Field(&p.Meta),
)
Expand Down
9 changes: 9 additions & 0 deletions bill/preceding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,12 @@ func TestPrecedingValidation(t *testing.T) {
err := p.Validate()
assert.NoError(t, err)
}

func TestPrecedingJSONMigration(t *testing.T) {
data := []byte(`{"correction_method":"foo","corrections":["bar"]}`)
p := new(bill.Preceding)
err := p.UnmarshalJSON(data)
assert.NoError(t, err)
assert.Equal(t, "foo", p.CorrectionMethod.String())
assert.Equal(t, "bar", p.Changes[0].String())
}
Loading

0 comments on commit 18b6b68

Please sign in to comment.