Skip to content

Commit

Permalink
Refactor correction options to expect specific type
Browse files Browse the repository at this point in the history
  • Loading branch information
samlown committed Feb 1, 2024
1 parent 655041c commit d8e8ae3
Show file tree
Hide file tree
Showing 19 changed files with 194 additions and 251 deletions.
122 changes: 35 additions & 87 deletions bill/invoice_correct.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,14 @@ import (
type CorrectionOptions struct {
head.CorrectionOptions

// The type of corrective invoice to produce.
Type cbc.Key `json:"type" jsonschema:"title=Type"`
// When the new corrective invoice's issue date should be set to.
IssueDate *cal.Date `json:"issue_date,omitempty" jsonschema:"title=Issue Date"`
// Stamps of the previous document to include in the preceding data.
Stamps []*head.Stamp `json:"stamps,omitempty" jsonschema:"title=Stamps"`
// Credit when true indicates that the corrective document should cancel the previous document.
Credit bool `json:"credit,omitempty" jsonschema:"title=Credit"`
// Debit when true indicates that the corrective document should add new items to the previous document.
Debit bool `json:"debit,omitempty" jsonschema:"title=Debit"`
// Human readable reason for the corrective operation.
Reason string `json:"reason,omitempty" jsonschema:"title=Reason"`
// Correction method as defined by the tax regime.
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"`

Expand Down Expand Up @@ -78,14 +74,6 @@ func WithReason(reason string) 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.Method = method
}
}

// 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 {
Expand All @@ -104,18 +92,25 @@ func WithIssueDate(date cal.Date) schema.Option {
}
}

// Corrective is used for creating corrective or rectified invoices
// that completely replace a previous document.
var Corrective schema.Option = func(o interface{}) {
opts := o.(*CorrectionOptions)
opts.Type = InvoiceTypeCorrective
}

// Credit indicates that the corrective operation requires a credit note
// or equivalent.
var Credit schema.Option = func(o interface{}) {
opts := o.(*CorrectionOptions)
opts.Credit = true
opts.Type = InvoiceTypeCreditNote
}

// Debit indicates that the corrective operation is to append
// new items to the previous invoice, usually as a debit note.
var Debit schema.Option = func(o interface{}) {
opts := o.(*CorrectionOptions)
opts.Debit = true
opts.Type = InvoiceTypeDebitNote
}

// CorrectionOptionsSchema provides a dynamic JSON schema of the options
Expand Down Expand Up @@ -147,29 +142,18 @@ func (inv *Invoice) CorrectionOptionsSchema() (interface{}, error) {

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")
}

if len(cd.Methods) > 0 {
cos.Required = append(cos.Required, "method")
if ps, ok := cos.Properties.Get("method"); ok {
ps.OneOf = make([]*jsonschema.Schema, len(cd.Methods))
for i, v := range cd.Methods {
if len(cd.Types) > 0 {
if ps, ok := cos.Properties.Get("type"); ok {
ps.OneOf = make([]*jsonschema.Schema, len(cd.Types))
for i, v := range cd.Types {
ps.OneOf[i] = &jsonschema.Schema{
Const: v.Key.String(),
Title: v.Name.String(),
}
if !v.Desc.IsEmpty() {
ps.OneOf[i].Description = v.Desc.String()
Const: v.String(),
Title: v.String(),
}
}
}
Expand All @@ -192,6 +176,10 @@ func (inv *Invoice) CorrectionOptionsSchema() (interface{}, error) {
}
}

if cd.ReasonRequired {
cos.Required = append(cos.Required, "reason")
}

return schema, nil
}

Expand All @@ -216,15 +204,16 @@ func (inv *Invoice) Correct(opts ...schema.Option) error {

// Copy and prepare the basic fields
pre := &Preceding{
UUID: inv.UUID,
Series: inv.Series,
Code: inv.Code,
IssueDate: inv.IssueDate.Clone(),
Reason: o.Reason,
CorrectionMethod: o.Method,
Changes: o.Changes,
UUID: inv.UUID,
Type: inv.Type,
Series: inv.Series,
Code: inv.Code,
IssueDate: inv.IssueDate.Clone(),
Reason: o.Reason,
Changes: o.Changes,
}
inv.UUID = nil
inv.Type = o.Type
inv.Series = ""
inv.Code = ""
if o.IssueDate != nil {
Expand All @@ -235,10 +224,6 @@ func (inv *Invoice) Correct(opts ...schema.Option) error {

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
}
Expand Down Expand Up @@ -269,42 +254,10 @@ func prepareCorrectionOptions(o *CorrectionOptions, opts ...schema.Option) error
}
}

if o.Credit && o.Debit {
return errors.New("cannot use both credit and debit options")
if o.Type == cbc.KeyEmpty {
return errors.New("missing correction type")
}
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 cd.HasType(InvoiceTypeCreditNote) {
// regular credit note
inv.Type = InvoiceTypeCreditNote
} else if cd.HasType(InvoiceTypeCorrective) {
// corrective invoice with negative values
inv.Type = InvoiceTypeCorrective
inv.Invert()
} else {
return errors.New("credit note not supported by regime")
}
inv.Payment.ResetAdvances()
} else if o.Debit {
if cd.HasType(InvoiceTypeDebitNote) {
// regular debit note, implies no rows as new ones
// will be added
inv.Type = InvoiceTypeDebitNote
inv.Empty()
} else {
return errors.New("debit note not supported by regime")
}
} else {
if cd.HasType(InvoiceTypeCorrective) {
inv.Type = InvoiceTypeCorrective
} else {
return fmt.Errorf("corrective invoice type not supported by regime, try credit or debit")
}
}
return nil
}

Expand All @@ -326,22 +279,17 @@ func (inv *Invoice) validatePrecedingData(o *CorrectionOptions, cd *tax.Correcti
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 !o.Type.In(cd.Types...) {
return fmt.Errorf("invalid correction type: %v", o.Type.String())
}

if len(cd.Changes) > 0 {
if len(pre.Changes) == 0 {
return errors.New("missing changes")
return errors.New("missing correction changes")
}
for _, k := range pre.Changes {
if !cd.HasChange(k) {
return fmt.Errorf("invalid change key: '%v'", k)
return fmt.Errorf("invalid correction change key: '%v'", k)
}
}
}
Expand Down
59 changes: 32 additions & 27 deletions bill/invoice_correct_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,47 @@ import (

func TestInvoiceCorrect(t *testing.T) {
// Spanish Case (only corrective)

// debit note not supported in Spain
i := testInvoiceESForCorrection(t)
err := i.Correct(bill.Credit, bill.Debit)
err := i.Correct(bill.Debit)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot use both credit and debit options")
assert.Contains(t, err.Error(), "invalid correction type: debit-note")

i = testInvoiceESForCorrection(t)
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")
assert.Equal(t, bill.InvoiceTypeCreditNote, i.Type)
assert.Equal(t, i.Lines[0].Quantity.String(), "10")
assert.Equal(t, i.IssueDate, cal.Today())
pre := i.Preceding[0]
assert.Equal(t, pre.Series, "TEST")
assert.Equal(t, pre.Code, "123")
assert.Equal(t, pre.IssueDate, cal.NewDate(2022, 6, 13))
assert.Equal(t, pre.Reason, "test refund")
assert.Equal(t, i.Totals.Payable.String(), "-900.00")
assert.Equal(t, i.Totals.Payable.String(), "900.00")

// can't run twice
err = i.Correct()
err = i.Correct(bill.Corrective)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot correct an invoice without a code")

i = testInvoiceESForCorrection(t)
err = i.Correct()
require.Error(t, err)
assert.Contains(t, err.Error(), "missing correction type")

i = testInvoiceESForCorrection(t)
err = i.Correct(bill.Debit, bill.WithReason("should fail"))
require.Error(t, err)
assert.Contains(t, err.Error(), "debit note not supported by regime")
assert.Contains(t, err.Error(), "invalid correction type: debit-note")

i = testInvoiceESForCorrection(t)
err = i.Correct(
bill.WithMethod(es.CorrectionMethodKeyComplete),
bill.Corrective,
bill.WithChanges(es.CorrectionKeyLine),
)
require.NoError(t, err)
Expand All @@ -65,16 +71,16 @@ func TestInvoiceCorrect(t *testing.T) {
i = testInvoiceESForCorrection(t)
d := cal.MakeDate(2023, 6, 13)
err = i.Correct(
bill.Credit,
bill.WithIssueDate(d),
bill.WithMethod(es.CorrectionMethodKeyComplete),
bill.WithChanges(es.CorrectionKeyLine),
)
require.NoError(t, err)
assert.Equal(t, i.IssueDate, d)

// France case (both corrective and credit note)
i = testInvoiceFRForCorrection(t)
err = i.Correct()
err = i.Correct(bill.Corrective)
require.NoError(t, err)
assert.Equal(t, i.Type, bill.InvoiceTypeCorrective)

Expand Down Expand Up @@ -102,44 +108,43 @@ func TestInvoiceCorrect(t *testing.T) {
}

i = testInvoiceCOForCorrection(t)
err = i.Correct(bill.WithStamps(stamps))
err = i.Correct(bill.Corrective, bill.WithStamps(stamps))
require.Error(t, err)
assert.Contains(t, err.Error(), "corrective invoice type not supported by regime, try credit or debit")
assert.Contains(t, err.Error(), "invalid correction type: corrective")

i = testInvoiceCOForCorrection(t)
err = i.Correct(
bill.Credit,
bill.WithStamps(stamps),
bill.WithMethod(co.CorrectionMethodKeyRevoked),
bill.WithReason("test refund"),
bill.WithChanges(co.CorrectionKeyRevoked),
)
require.NoError(t, err)
assert.Equal(t, i.Type, bill.InvoiceTypeCreditNote)
pre = i.Preceding[0]
require.Len(t, pre.Stamps, 1)
assert.Equal(t, pre.Stamps[0].Provider, co.StampProviderDIANCUDE)
assert.Equal(t, pre.CorrectionMethod, co.CorrectionMethodKeyRevoked)
// assert.Equal(t, pre.CorrectionMethod, co.CorrectionMethodKeyRevoked)
}

func TestCorrectWithOptions(t *testing.T) {
i := testInvoiceESForCorrection(t)
opts := &bill.CorrectionOptions{
Credit: true,
Type: bill.InvoiceTypeCreditNote,
Reason: "test refund",
Method: es.CorrectionMethodKeyComplete,
Changes: []cbc.Key{es.CorrectionKeyLine},
}
err := i.Correct(bill.WithOptions(opts))
require.NoError(t, err)
assert.Equal(t, bill.InvoiceTypeCorrective, i.Type)
assert.Equal(t, i.Lines[0].Quantity.String(), "-10")
assert.Equal(t, bill.InvoiceTypeCreditNote, i.Type)
assert.Equal(t, i.Lines[0].Quantity.String(), "10")
assert.Equal(t, i.IssueDate, cal.Today())
pre := i.Preceding[0]
assert.Equal(t, pre.Series, "TEST")
assert.Equal(t, pre.Code, "123")
assert.Equal(t, pre.IssueDate, cal.NewDate(2022, 6, 13))
assert.Equal(t, pre.Reason, "test refund")
assert.Equal(t, i.Totals.Payable.String(), "-900.00")
assert.Equal(t, i.Totals.Payable.String(), "900.00")
}

func TestCorrectionOptionsSchema(t *testing.T) {
Expand All @@ -151,13 +156,13 @@ func TestCorrectionOptionsSchema(t *testing.T) {
require.True(t, ok)

cos := schema.Definitions["CorrectionOptions"]
assert.Equal(t, cos.Properties.Len(), 7)
assert.Equal(t, cos.Properties.Len(), 5)

pm, ok := cos.Properties.Get("method")
pm, ok := cos.Properties.Get("changes")
require.True(t, ok)
assert.Len(t, pm.OneOf, 4)
assert.Len(t, pm.Items.OneOf, 22)

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"}]}`
exp := `{"items":{"$ref":"https://gobl.org/draft-0/cbc/key","oneOf":[{"const":"code","title":"Invoice code"},{"const":"series","title":"Invoice series"},{"const":"issue-date","title":"Issue date"},{"const":"supplier-name","title":"Name and surnames/Corporate name - Issuer (Sender)"},{"const":"customer-name","title":"Name and surnames/Corporate name - Receiver"},{"const":"supplier-tax-id","title":"Issuer's Tax Identification Number"},{"const":"customer-tax-id","title":"Receiver's Tax Identification Number"},{"const":"supplier-addr","title":"Issuer's address"},{"const":"customer-addr","title":"Receiver's address"},{"const":"line","title":"Item line"},{"const":"tax-rate","title":"Applicable Tax Rate"},{"const":"tax-amount","title":"Applicable Tax Amount"},{"const":"period","title":"Applicable Date/Period"},{"const":"type","title":"Invoice Class"},{"const":"legal-details","title":"Legal literals"},{"const":"tax-base","title":"Taxable Base"},{"const":"tax","title":"Calculation of tax outputs"},{"const":"tax-retained","title":"Calculation of tax inputs"},{"const":"refund","title":"Taxable Base modified due to return of packages and packaging materials"},{"const":"discount","title":"Taxable Base modified due to discounts and rebates"},{"const":"judicial","title":"Taxable Base modified due to firm court ruling or administrative decision"},{"const":"insolvency","title":"Taxable Base modified due to unpaid outputs where there is a judgement opening insolvency proceedings"}]},"type":"array","title":"Changes","description":"Changes keys that describe the specific changes according to the tax regime."}`
data, err := json.Marshal(pm)
require.NoError(t, err)
if !assert.JSONEq(t, exp, string(data)) {
Expand All @@ -171,15 +176,15 @@ func TestCorrectionOptionsSchema(t *testing.T) {

func TestCorrectWithData(t *testing.T) {
i := testInvoiceESForCorrection(t)
data := []byte(`{"credit":true,"reason":"test refund"}`)
data := []byte(`{"type":"credit-note","reason":"test refund"}`)

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
assert.Equal(t, i.Type, bill.InvoiceTypeCreditNote)
assert.Equal(t, i.Lines[0].Quantity.String(), "10") // implies credit was made

data = []byte(`{"credit": true`) // invalid json
err = i.Correct(bill.WithData(data))
Expand Down
6 changes: 6 additions & 0 deletions bill/invoice_type_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,11 @@ func TestInvoiceType(t *testing.T) {

assert.True(t, c.In("bar", "foo"))
assert.False(t, c.In("bar", "dom"))
}

func TestInvoiceUNTDID1001(t *testing.T) {
inv := testInvoiceESForCorrection(t)
assert.Equal(t, cbc.CodeEmpty, inv.UNTDID1001())
inv.Type = bill.InvoiceTypeStandard
assert.Equal(t, cbc.Code("380"), inv.UNTDID1001())
}
Loading

0 comments on commit d8e8ae3

Please sign in to comment.