diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec66d5c..a0290bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ Finally, the `draft` flag has been removed from the header, and much more emphas - `pt`: moved SAF-T specific extensions to `addons/pt/saft`. - `it`: moved SDI and FatturaPA extensions to `addons/it/sdi` with key `it-sdi-v1`. - `gr`: moved MyDATA to `addons/gr/mydata`, key `gr-mydata-v1`. +- `bill.Preceding`: replaced with `org.DocumentRef`. +- `bill.Invoice`: Ordering now using arrays of `org.DocumentRef`. +- `bill.Invoice`: `series` and `code` now use `cbc.Code` and normalization instead of the independent invoice code. ### Added @@ -39,6 +42,7 @@ Finally, the `draft` flag has been removed from the header, and much more emphas - `tax`: `Scenario` now has `Filter` property to set a code function. - `tax`: `AddonDef` provides support for defining addon extension packs. - `gr`: `gr-mydata-invoice-type` extension with related tags and scenarios. +- `org`: `DocumentRef` consolidates references to previous documents in a single place. ## [v0.115.1] diff --git a/addons/es/facturae/invoice.go b/addons/es/facturae/invoice.go index fe071431..9fc769fb 100644 --- a/addons/es/facturae/invoice.go +++ b/addons/es/facturae/invoice.go @@ -3,6 +3,7 @@ package facturae import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" "github.com/invopop/validation" @@ -57,7 +58,7 @@ func validateInvoiceTax(val any) error { } func validateInvoicePreceding(val any) error { - p, ok := val.(*bill.Preceding) + p, ok := val.(*org.DocumentRef) if !ok { return nil } diff --git a/addons/es/facturae/invoice_test.go b/addons/es/facturae/invoice_test.go index 8e52dd40..f9f679e3 100644 --- a/addons/es/facturae/invoice_test.go +++ b/addons/es/facturae/invoice_test.go @@ -31,7 +31,7 @@ func TestInvoicePrecedingValidation(t *testing.T) { err := inv.Validate() assert.ErrorContains(t, err, "preceding: cannot be blank.") - inv.Preceding = []*bill.Preceding{ + inv.Preceding = []*org.DocumentRef{ { Code: "123TEST", }, diff --git a/addons/es/tbai/invoice.go b/addons/es/tbai/invoice.go index e9d76acb..875d51a5 100644 --- a/addons/es/tbai/invoice.go +++ b/addons/es/tbai/invoice.go @@ -3,6 +3,7 @@ package tbai import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" "github.com/invopop/validation" @@ -38,7 +39,7 @@ func validateInvoice(inv *bill.Invoice) error { } func validateInvoicePreceding(val any) error { - p, ok := val.(*bill.Preceding) + p, ok := val.(*org.DocumentRef) if !ok { return nil } diff --git a/addons/gr/mydata/invoices.go b/addons/gr/mydata/invoices.go index c5eceaf3..4af7d9f6 100644 --- a/addons/gr/mydata/invoices.go +++ b/addons/gr/mydata/invoices.go @@ -58,7 +58,7 @@ func validateInvoice(inv *bill.Invoice) error { inv.Type.In(bill.InvoiceTypeCreditNote), validation.Required, ), - validation.Each(validation.By(validatePreceding)), + validation.Each(validation.By(validateInvoicePreceding)), validation.Skip, ), ) @@ -169,8 +169,8 @@ func validateInvoicePayment(value any) error { ) } -func validatePreceding(value any) error { - p, ok := value.(*bill.Preceding) +func validateInvoicePreceding(value any) error { + p, ok := value.(*org.DocumentRef) if !ok || p == nil { return nil } diff --git a/addons/gr/mydata/invoices_test.go b/addons/gr/mydata/invoices_test.go index 64e06bd4..9a7c2986 100644 --- a/addons/gr/mydata/invoices_test.go +++ b/addons/gr/mydata/invoices_test.go @@ -105,7 +105,7 @@ func TestSimplifiedInvoiceValidation(t *testing.T) { func TestPrecedingValidation(t *testing.T) { inv := validInvoice() - inv.Preceding = []*bill.Preceding{ + inv.Preceding = []*org.DocumentRef{ { Code: "123", Stamps: []*head.Stamp{ diff --git a/addons/mx/cfdi/food_vouchers.go b/addons/mx/cfdi/food_vouchers.go index 8fcd270b..903cbb61 100644 --- a/addons/mx/cfdi/food_vouchers.go +++ b/addons/mx/cfdi/food_vouchers.go @@ -6,7 +6,7 @@ import ( "github.com/invopop/gobl/cal" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" - "github.com/invopop/gobl/regimes/mx/sat" + "github.com/invopop/gobl/regimes/mx" "github.com/invopop/validation" ) @@ -103,7 +103,7 @@ func (fve *FoodVouchersEmployee) Validate() error { return validation.ValidateStruct(fve, validation.Field(&fve.TaxCode, validation.Required, - validation.By(sat.ValidateTaxCode), + validation.By(mx.ValidateTaxCode), ), validation.Field(&fve.CURP, validation.Required, diff --git a/addons/mx/cfdi/fuel_account_balance.go b/addons/mx/cfdi/fuel_account_balance.go index 08a38cf2..4972e6fe 100644 --- a/addons/mx/cfdi/fuel_account_balance.go +++ b/addons/mx/cfdi/fuel_account_balance.go @@ -5,7 +5,7 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" - "github.com/invopop/gobl/regimes/mx/sat" + "github.com/invopop/gobl/regimes/mx" "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) @@ -19,7 +19,7 @@ const ( // FuelAccountValidTaxCodes lists of the complement's allowed tax codes var FuelAccountValidTaxCodes = []any{ tax.CategoryVAT, - sat.TaxCategoryIEPS, + mx.TaxCategoryIEPS, } // FuelAccountBalance carries the data to produce a CFDI's "Complemento de @@ -112,7 +112,7 @@ func (fal *FuelAccountLine) Validate() error { validation.Field(&fal.PurchaseDateTime, cal.DateTimeNotZero()), validation.Field(&fal.VendorTaxCode, validation.Required, - validation.By(sat.ValidateTaxCode), + validation.By(mx.ValidateTaxCode), ), validation.Field(&fal.ServiceStationCode, validation.Required, diff --git a/addons/mx/cfdi/fuel_account_balance_test.go b/addons/mx/cfdi/fuel_account_balance_test.go index b82e9452..8aea9049 100644 --- a/addons/mx/cfdi/fuel_account_balance_test.go +++ b/addons/mx/cfdi/fuel_account_balance_test.go @@ -7,7 +7,7 @@ import ( "github.com/invopop/gobl/addons/mx/cfdi" "github.com/invopop/gobl/num" - "github.com/invopop/gobl/regimes/mx/sat" + "github.com/invopop/gobl/regimes/mx" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -139,7 +139,7 @@ func TestCalculate(t *testing.T) { Percent: num.NewPercentage(16, 2), }, { - Category: sat.TaxCategoryIEPS, + Category: mx.TaxCategoryIEPS, Rate: num.NewAmount(59195, 4), }, }, @@ -153,7 +153,7 @@ func TestCalculate(t *testing.T) { Percent: num.NewPercentage(16, 2), }, { - Category: sat.TaxCategoryIEPS, + Category: mx.TaxCategoryIEPS, Rate: num.NewAmount(59195, 4), }, }, @@ -187,7 +187,7 @@ func TestCalculate(t *testing.T) { Percent: num.NewPercentage(16, 2), }, { - Category: sat.TaxCategoryIEPS, + Category: mx.TaxCategoryIEPS, Rate: num.NewAmount(5451, 4), }, }, @@ -201,7 +201,7 @@ func TestCalculate(t *testing.T) { Percent: num.NewPercentage(16, 2), }, { - Category: sat.TaxCategoryIEPS, + Category: mx.TaxCategoryIEPS, Rate: num.NewAmount(5451, 4), }, }, @@ -250,7 +250,7 @@ func TestCalculate(t *testing.T) { Percent: num.NewPercentage(int64(vat*1000), 3), }, { - Category: sat.TaxCategoryIEPS, + Category: mx.TaxCategoryIEPS, Rate: num.NewAmount(int64(ieps*10000), 4), }, }, @@ -329,7 +329,7 @@ func TestCalculate(t *testing.T) { Percent: num.NewPercentage(int64(vat*1000), 3), }, { - Category: sat.TaxCategoryIEPS, + Category: mx.TaxCategoryIEPS, Rate: num.NewAmount(int64(ieps*1000), 3), }, }, diff --git a/addons/mx/cfdi/invoice.go b/addons/mx/cfdi/invoice.go index a32e2a9d..23394650 100644 --- a/addons/mx/cfdi/invoice.go +++ b/addons/mx/cfdi/invoice.go @@ -5,7 +5,7 @@ import ( "github.com/invopop/gobl/head" "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" - "github.com/invopop/gobl/regimes/mx/sat" + "github.com/invopop/gobl/regimes/mx" "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) @@ -17,27 +17,6 @@ func normalizeInvoice(inv *bill.Invoice) { normalizeItem(line.Item) } - // 2024-04-26: copy suppliers post code to invoice, if not already - // set. - if inv.Tax == nil { - inv.Tax = new(bill.Tax) - } - if inv.Tax.Ext == nil { - inv.Tax.Ext = make(tax.Extensions) - } - if inv.Tax.Ext.Has(ExtKeyIssuePlace) { - return - } - if inv.Supplier.Ext.Has(ExtKeyPostCode) { - inv.Tax.Ext[ExtKeyIssuePlace] = inv.Supplier.Ext[ExtKeyPostCode] - return - } - if len(inv.Supplier.Addresses) > 0 { - addr := inv.Supplier.Addresses[0] - if addr.Code != "" { - inv.Tax.Ext[ExtKeyIssuePlace] = tax.ExtValue(addr.Code) - } - } } func validateInvoice(inv *bill.Invoice) error { @@ -76,7 +55,7 @@ func validateInvoice(inv *bill.Invoice) error { ) } -func validateInvoiceTax(preceding []*bill.Preceding) validation.RuleFunc { +func validateInvoiceTax(preceding []*org.DocumentRef) validation.RuleFunc { return func(value any) error { obj, _ := value.(*bill.Tax) if obj == nil { @@ -156,14 +135,14 @@ func validateInvoiceLine(value any) error { } func validateInvoicePreceding(value interface{}) error { - entry, _ := value.(*bill.Preceding) + entry, _ := value.(*org.DocumentRef) if entry == nil { return nil } return validation.ValidateStruct(entry, validation.Field( &entry.Stamps, - head.StampsHas(sat.StampUUID), + head.StampsHas(mx.StampSATUUID), validation.Skip, ), ) diff --git a/addons/mx/cfdi/invoice_test.go b/addons/mx/cfdi/invoice_test.go index 27aa6eee..a8cd80c9 100644 --- a/addons/mx/cfdi/invoice_test.go +++ b/addons/mx/cfdi/invoice_test.go @@ -190,7 +190,7 @@ func TestUsoCFDIScenarioValidation(t *testing.T) { func TestPrecedingValidation(t *testing.T) { inv := validInvoice() - inv.Preceding = []*bill.Preceding{ + inv.Preceding = []*org.DocumentRef{ { Code: "123", Stamps: []*head.Stamp{ diff --git a/addons/mx/cfdi/party.go b/addons/mx/cfdi/party.go index 9c47f84e..51e6ea23 100644 --- a/addons/mx/cfdi/party.go +++ b/addons/mx/cfdi/party.go @@ -23,13 +23,4 @@ func normalizeParty(p *org.Party) { } } p.Identities = idents - - // 2024-03-14: Migrate Tax ID Zone to extensions "mx-cfdi-post-code" - if p.TaxID != nil && p.TaxID.Zone != "" { //nolint:staticcheck - if p.Ext == nil { - p.Ext = make(tax.Extensions) - } - p.Ext[ExtKeyPostCode] = tax.ExtValue(p.TaxID.Zone) //nolint:staticcheck - p.TaxID.Zone = "" //nolint:staticcheck - } } diff --git a/addons/mx/cfdi/party_test.go b/addons/mx/cfdi/party_test.go index 67bc7344..1b50cc12 100644 --- a/addons/mx/cfdi/party_test.go +++ b/addons/mx/cfdi/party_test.go @@ -22,20 +22,13 @@ func TestMigratePartyIdentities(t *testing.T) { Code: "G01", }, }, - TaxID: &tax.Identity{ - Country: "MX", - Code: "ZZZ010101ZZZ", - Zone: "65000", - }, } addon := tax.AddonForKey(cfdi.V4) addon.Normalizer(customer) assert.Empty(t, customer.Identities) - assert.Len(t, customer.Ext, 3) + assert.Len(t, customer.Ext, 2) assert.Equal(t, "608", customer.Ext[cfdi.ExtKeyFiscalRegime].String()) assert.Equal(t, "G01", customer.Ext[cfdi.ExtKeyUse].String()) - assert.Equal(t, "65000", customer.Ext[cfdi.ExtKeyPostCode].String()) - assert.Empty(t, customer.TaxID.Zone) //nolint:staticcheck } diff --git a/addons/mx/cfdi/tax_combo.go b/addons/mx/cfdi/tax_combo.go index dd71845a..44726734 100644 --- a/addons/mx/cfdi/tax_combo.go +++ b/addons/mx/cfdi/tax_combo.go @@ -1,18 +1,18 @@ package cfdi import ( - "github.com/invopop/gobl/regimes/mx/sat" + "github.com/invopop/gobl/regimes/mx" "github.com/invopop/gobl/tax" ) func normalizeTaxCombo(tc *tax.Combo) { var k tax.ExtValue switch tc.Category { - case sat.TaxCategoryISR: + case mx.TaxCategoryISR: k = "001" - case tax.CategoryVAT, sat.TaxCategoryRVAT: + case tax.CategoryVAT, mx.TaxCategoryRVAT: k = "002" - case sat.TaxCategoryIEPS, sat.TaxCategoryRIEPS: + case mx.TaxCategoryIEPS, mx.TaxCategoryRIEPS: k = "003" default: return diff --git a/bill/invoice.go b/bill/invoice.go index 70953592..e7b44cf5 100644 --- a/bill/invoice.go +++ b/bill/invoice.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "regexp" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/cbc" @@ -31,18 +30,6 @@ const ( defaultCurrencyConversionAccuracy uint32 = 2 ) -const ( - // InvoiceCodePattern defines what we expect from codes - // and series in an invoice. - InvoiceCodePattern = `^([A-Za-z0-9][A-Za-z0-9 /\._-]?)*[A-Za-z0-9]$` -) - -var ( - // InvoiceCodeRegexp is used to validate invoice codes and series - // to something that is compatible with most tax regimes. - InvoiceCodeRegexp = regexp.MustCompile(InvoiceCodePattern) -) - // Invoice represents a payment claim for goods or services supplied under // conditions agreed between the supplier and the customer. In most cases // the resulting document describes the actual financial commitment of goods @@ -57,9 +44,9 @@ type Invoice struct { // Type of invoice document subject to the requirements of the local tax regime. Type cbc.Key `json:"type" jsonschema:"title=Type" jsonschema_extras:"calculated=true"` // Used as a prefix to group codes. - Series string `json:"series,omitempty" jsonschema:"title=Series"` + Series cbc.Code `json:"series,omitempty" jsonschema:"title=Series"` // Sequential code used to identify this invoice in tax declarations. - Code string `json:"code" jsonschema:"title=Code"` + Code cbc.Code `json:"code" jsonschema:"title=Code"` // When the invoice was created. IssueDate cal.Date `json:"issue_date" jsonschema:"title=Issue Date" jsonschema_extras:"calculated=true"` // Date when the operation defined by the invoice became effective. @@ -73,7 +60,7 @@ type Invoice struct { // Key information regarding previous invoices and potentially details as to why they // were corrected. - Preceding []*Preceding `json:"preceding,omitempty" jsonschema:"title=Preceding Details"` + Preceding []*org.DocumentRef `json:"preceding,omitempty" jsonschema:"title=Preceding Details"` // Special tax configuration for billing. Tax *Tax `json:"tax,omitempty" jsonschema:"title=Tax"` @@ -139,11 +126,8 @@ func (inv *Invoice) ValidateWithContext(ctx context.Context) error { validation.Required, isValidInvoiceType, ), - validation.Field(&inv.Series, - validation.Match(InvoiceCodeRegexp), - ), + validation.Field(&inv.Series), validation.Field(&inv.Code, - validation.Match(InvoiceCodeRegexp), validation.When( internal.IsSigned(ctx), validation.Required.Error("required to sign invoice"), @@ -301,6 +285,9 @@ func (inv *Invoice) Normalize(normalizers tax.Normalizers) { if inv.Type == cbc.KeyEmpty { inv.Type = InvoiceTypeStandard } + inv.Series = cbc.NormalizeCode(inv.Series) + inv.Code = cbc.NormalizeCode(inv.Code) + normalizers.Each(inv) tax.Normalize(normalizers, inv.Tax) @@ -310,6 +297,7 @@ func (inv *Invoice) Normalize(normalizers tax.Normalizers) { tax.Normalize(normalizers, inv.Lines) tax.Normalize(normalizers, inv.Discounts) tax.Normalize(normalizers, inv.Charges) + tax.Normalize(normalizers, inv.Ordering) tax.Normalize(normalizers, inv.Payment) } @@ -318,7 +306,7 @@ func (inv *Invoice) normalizers() tax.Normalizers { if r := inv.RegimeDef(); r != nil { normalizers = normalizers.Append(r.Normalizer) } - for _, a := range inv.GetAddons() { + for _, a := range inv.GetAddonDefs() { normalizers = normalizers.Append(a.Normalizer) } return normalizers @@ -329,7 +317,7 @@ func (inv *Invoice) supportedTags() []cbc.Key { if r := inv.RegimeDef(); r != nil { ts = ts.Merge(tax.TagSetForSchema(r.Tags, ShortSchemaInvoice)) } - for _, a := range inv.GetAddons() { + for _, a := range inv.GetAddonDefs() { ts = ts.Merge(tax.TagSetForSchema(a.Tags, ShortSchemaInvoice)) } return ts.Keys() @@ -341,7 +329,7 @@ func (inv *Invoice) ValidationContext(ctx context.Context) context.Context { if r := inv.RegimeDef(); r != nil { ctx = r.WithContext(ctx) } - for _, a := range inv.GetAddons() { + for _, a := range inv.GetAddonDefs() { ctx = a.WithContext(ctx) } return ctx @@ -602,10 +590,10 @@ func (inv *Invoice) UnmarshalJSON(data []byte) error { func (Invoice) JSONSchemaExtend(js *jsonschema.Schema) { props := js.Properties if prop, ok := props.Get("series"); ok { - prop.Pattern = InvoiceCodePattern + prop.Pattern = cbc.CodePattern } if prop, ok := props.Get("code"); ok { - prop.Pattern = InvoiceCodePattern + prop.Pattern = cbc.CodePattern } // Extend type list if its, ok := props.Get("type"); ok { diff --git a/bill/invoice_correct.go b/bill/invoice_correct.go index 5e996c34..01af9304 100644 --- a/bill/invoice_correct.go +++ b/bill/invoice_correct.go @@ -10,8 +10,10 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/data" "github.com/invopop/gobl/head" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/schema" "github.com/invopop/gobl/tax" + "github.com/invopop/gobl/uuid" "github.com/invopop/jsonschema" ) @@ -26,7 +28,7 @@ type CorrectionOptions struct { // When the new corrective invoice's issue date should be set to. IssueDate *cal.Date `json:"issue_date,omitempty" jsonschema:"title=Issue Date"` // Series to assign to the new corrective invoice. - Series string `json:"series,omitempty" jsonschema:"title=Series"` + Series cbc.Code `json:"series,omitempty" jsonschema:"title=Series"` // Stamps of the previous document to include in the preceding data. Stamps []*head.Stamp `json:"stamps,omitempty" jsonschema:"title=Stamps"` // Human readable reason for the corrective operation. @@ -69,7 +71,7 @@ func WithStamps(stamps []*head.Stamp) schema.Option { } // WithSeries assigns a new series to the corrective document. -func WithSeries(value string) schema.Option { +func WithSeries(value cbc.Code) schema.Option { return func(o interface{}) { opts := o.(*CorrectionOptions) opts.Series = value @@ -265,8 +267,8 @@ func (inv *Invoice) Correct(opts ...schema.Option) error { } // Copy and prepare the basic fields - pre := &Preceding{ - UUID: inv.UUID, + pre := &org.DocumentRef{ + Identify: uuid.Identify{UUID: inv.UUID}, Type: inv.Type, Series: inv.Series, Code: inv.Code, @@ -292,7 +294,7 @@ func (inv *Invoice) Correct(opts ...schema.Option) error { } // Replace all previous preceding data - inv.Preceding = []*Preceding{pre} + inv.Preceding = []*org.DocumentRef{pre} // Running a Calculate feels a bit out of place, but not performing // this operation on the corrected invoice results in potentially @@ -312,7 +314,7 @@ func (inv *Invoice) correctionDef() *tax.CorrectionDefinition { if r != nil { cd = cd.Merge(r.Corrections.Def(ShortSchemaInvoice)) } - for _, a := range inv.GetAddons() { + for _, a := range inv.GetAddonDefs() { cd = cd.Merge(a.Corrections.Def(ShortSchemaInvoice)) } @@ -343,7 +345,7 @@ func prepareCorrectionOptions(o *CorrectionOptions, opts ...schema.Option) error return nil } -func (inv *Invoice) validatePrecedingData(o *CorrectionOptions, cd *tax.CorrectionDefinition, pre *Preceding) error { +func (inv *Invoice) validatePrecedingData(o *CorrectionOptions, cd *tax.CorrectionDefinition, pre *org.DocumentRef) error { if cd == nil { return nil } diff --git a/bill/invoice_correct_test.go b/bill/invoice_correct_test.go index d10da827..7e936b5e 100644 --- a/bill/invoice_correct_test.go +++ b/bill/invoice_correct_test.go @@ -8,6 +8,7 @@ import ( "github.com/invopop/gobl/addons/es/tbai" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/head" "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" @@ -34,11 +35,11 @@ func TestInvoiceCorrect(t *testing.T) { assert.Equal(t, bill.InvoiceTypeCreditNote, i.Type) assert.Equal(t, i.Lines[0].Quantity.String(), "10") assert.Equal(t, i.IssueDate, cal.Today()) - assert.Equal(t, i.Series, "TEST") + assert.Equal(t, i.Series, cbc.Code("TEST")) assert.Empty(t, i.Code) pre := i.Preceding[0] - assert.Equal(t, pre.Series, "TEST") - assert.Equal(t, pre.Code, "123") + assert.Equal(t, pre.Series.String(), "TEST") + assert.Equal(t, pre.Code.String(), "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") @@ -81,8 +82,8 @@ func TestInvoiceCorrect(t *testing.T) { inv := testInvoiceESForCorrection(t) err := inv.Correct(bill.Credit, bill.WithSeries("R-TEST")) require.NoError(t, err) - assert.Equal(t, inv.Series, "R-TEST") - assert.Equal(t, inv.Preceding[0].Series, "TEST") + assert.Equal(t, inv.Series, cbc.Code("R-TEST")) + assert.Equal(t, inv.Preceding[0].Series.String(), "TEST") }) // France case (both corrective and credit note) @@ -149,11 +150,11 @@ func TestCorrectWithOptions(t *testing.T) { assert.Equal(t, bill.InvoiceTypeCreditNote, i.Type) assert.Equal(t, i.Lines[0].Quantity.String(), "10") assert.Equal(t, i.IssueDate, cal.Today()) - assert.Equal(t, i.Series, "R-TEST") + assert.Equal(t, i.Series.String(), "R-TEST") assert.Empty(t, i.Code) pre := i.Preceding[0] - assert.Equal(t, pre.Series, "TEST") - assert.Equal(t, pre.Code, "123") + assert.Equal(t, pre.Series.String(), "TEST") + assert.Equal(t, pre.Code.String(), "123") assert.Equal(t, pre.IssueDate, cal.NewDate(2022, 6, 13)) assert.Equal(t, pre.Reason, "test refund") assert.Equal(t, pre.Ext[facturae.ExtKeyCorrection], tax.ExtValue("01")) diff --git a/bill/invoice_scenarios.go b/bill/invoice_scenarios.go index 404f2f32..dddd3f50 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.GetAddons() { + for _, a := range inv.GetAddonDefs() { ss.Merge(a.Scenarios) } diff --git a/bill/invoice_test.go b/bill/invoice_test.go index 5335fe8f..82d12560 100644 --- a/bill/invoice_test.go +++ b/bill/invoice_test.go @@ -9,6 +9,7 @@ import ( _ "github.com/invopop/gobl" // load regions "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/currency" "github.com/invopop/gobl/internal" "github.com/invopop/gobl/l10n" @@ -20,34 +21,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestInvoiceCodeRegexp(t *testing.T) { - tests := []struct { - code string - ok bool - }{ - // Good - {"1", true}, - {"A", true}, - {"123", true}, - {"123TEST", true}, - {"123-TEST", true}, - {"FR F-01/37", true}, - {"MultiCase", true}, - {"F.01_21", true}, - // Bad - {"F101-", false}, - {" 123 ", false}, - {"F--01", false}, - {"\n", false}, - {"FOO\n", false}, - } - for _, ts := range tests { - t.Run(ts.code, func(t *testing.T) { - assert.Equal(t, ts.ok, bill.InvoiceCodeRegexp.MatchString(ts.code)) - }) - } -} - func TestInvoiceRegimeCurrency(t *testing.T) { lines := []*bill.Line{ { @@ -1074,6 +1047,15 @@ func TestInvoiceForUnknownRegime(t *testing.T) { require.NoError(t, inv.Validate()) } +func TestNormalization(t *testing.T) { + inv := baseInvoiceWithLines(t) + inv.Series = " bar 2024 " + inv.Code = " 123 Test " + require.NoError(t, inv.Calculate()) + assert.Equal(t, cbc.Code("BAR-2024"), inv.Series) + assert.Equal(t, cbc.Code("123-TEST"), inv.Code) +} + func TestValidation(t *testing.T) { t.Run("basic validation", func(t *testing.T) { inv := baseInvoiceWithLines(t) diff --git a/bill/ordering.go b/bill/ordering.go index 01bbd74c..558c0620 100644 --- a/bill/ordering.go +++ b/bill/ordering.go @@ -2,67 +2,73 @@ package bill import ( "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" - "github.com/invopop/gobl/uuid" + "github.com/invopop/gobl/tax" "github.com/invopop/validation" - "github.com/invopop/validation/is" ) // Ordering provides additional information about the ordering process including references // to other documents and alternative parties involved in the order-to-delivery process. type Ordering struct { // Identifier assigned by the customer or buyer for internal routing purposes. - Code string `json:"code,omitempty" jsonschema:"title=Code"` + Code cbc.Code `json:"code,omitempty" jsonschema:"title=Code"` // Any additional Codes, IDs, SKUs, or other regional or custom // identifiers that may be used to identify the order. Identities []*org.Identity `json:"identities,omitempty" jsonschema:"title=Identities"` - // 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"` - // Project this invoice refers to. - Project *DocumentReference `json:"project,omitempty" jsonschema:"title=Project"` - // The identification of a contract. - Contract *DocumentReference `json:"contract,omitempty" jsonschema:"title=Contract"` - // Purchase order issued by the customer or buyer. - Purchase *DocumentReference `json:"purchase,omitempty" jsonschema:"title=Purchase Order"` - // Sales order issued by the supplier or seller. - Sale *DocumentReference `json:"sale,omitempty" jsonschema:"title=Sales Order"` - // Receiving Advice. - Receiving *DocumentReference `json:"receiving,omitempty" jsonschema:"title=Receiving Advice"` - // Despatch advice. - Despatch *DocumentReference `json:"despatch,omitempty" jsonschema:"title=Despatch Advice"` - // Tender advice, the identification of the call for tender or lot the invoice relates to. - Tender *DocumentReference `json:"tender,omitempty" jsonschema:"title=Tender Advice"` - // Party who is responsible for making the purchase, but is not responsible // for handling taxes. 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 *org.Party `json:"seller,omitempty" jsonschema:"title=Seller"` + // Projects this invoice refers to. + Projects []*org.DocumentRef `json:"projects,omitempty" jsonschema:"title=Projects"` + // The identification of contracts. + Contracts []*org.DocumentRef `json:"contracts,omitempty" jsonschema:"title=Contracts"` + // Purchase orders issued by the customer or buyer. + Purchases []*org.DocumentRef `json:"purchases,omitempty" jsonschema:"title=Purchase Orders"` + // Sales orders issued by the supplier or seller. + Sales []*org.DocumentRef `json:"sales,omitempty" jsonschema:"title=Sales Orders"` + // Receiving Advice. + Receiving []*org.DocumentRef `json:"receiving,omitempty" jsonschema:"title=Receiving Advice"` + // Despatch advice. + Despatch []*org.DocumentRef `json:"despatch,omitempty" jsonschema:"title=Despatch Advice"` + // Tender advice, the identification of the call for tender or lot the invoice relates to. + Tender []*org.DocumentRef `json:"tender,omitempty" jsonschema:"title=Tender Advice"` } -// DocumentReference provides a link to a existing document. -type DocumentReference struct { - // Unique ID copied from the source document. - UUID uuid.UUID `json:"uuid,omitempty" jsonschema:"title=UUID"` - // Series the reference document belongs to. - Series string `json:"series,omitempty" jsonschema:"title=Series"` - // Source document's code or other identifier. - Code string `json:"code,omitempty" jsonschema:"title=Code"` - // Link to the source document. - URL string `json:"url,omitempty" jsonschema:"title=URL,format=uri"` +// Normalize attempts to clean and normalize the Ordering data. +func (o *Ordering) Normalize(normalizers tax.Normalizers) { + if o == nil { + return + } + o.Code = cbc.NormalizeCode(o.Code) + normalizers.Each(o) + tax.Normalize(normalizers, o.Identities) + tax.Normalize(normalizers, o.Projects) + tax.Normalize(normalizers, o.Contracts) + tax.Normalize(normalizers, o.Purchases) + tax.Normalize(normalizers, o.Sales) + tax.Normalize(normalizers, o.Receiving) + tax.Normalize(normalizers, o.Despatch) + tax.Normalize(normalizers, o.Tender) + tax.Normalize(normalizers, o.Buyer) + tax.Normalize(normalizers, o.Seller) } // Validate the ordering details. func (o *Ordering) Validate() error { return validation.ValidateStruct(o, + validation.Field(&o.Code), validation.Field(&o.Identities), - validation.Field(&o.Project), - validation.Field(&o.Contract), - validation.Field(&o.Purchase), - validation.Field(&o.Sale), + validation.Field(&o.Projects), + validation.Field(&o.Contracts), + validation.Field(&o.Purchases), + validation.Field(&o.Sales), validation.Field(&o.Receiving), validation.Field(&o.Despatch), validation.Field(&o.Tender), @@ -70,14 +76,3 @@ func (o *Ordering) Validate() error { validation.Field(&o.Seller), ) } - -// Validate ensures the Document Reference looks correct. -func (dr *DocumentReference) Validate() error { - return validation.ValidateStruct(dr, - validation.Field(&dr.UUID), - validation.Field(&dr.Code, - validation.Match(InvoiceCodeRegexp), - ), - validation.Field(&dr.URL, is.URL), - ) -} diff --git a/bill/ordering_test.go b/bill/ordering_test.go new file mode 100644 index 00000000..2384bc18 --- /dev/null +++ b/bill/ordering_test.go @@ -0,0 +1,42 @@ +package bill_test + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestOrderingNormalize(t *testing.T) { + o := &bill.Ordering{ + Code: " Foo ", + Projects: []*org.DocumentRef{ + { + Code: " Bar ", + Ext: tax.Extensions{ + "missing": "", + }, + }, + }, + } + o.Normalize(nil) + assert.Equal(t, "FOO", o.Code.String()) + assert.Equal(t, "BAR", o.Projects[0].Code.String()) + assert.Empty(t, o.Projects[0].Ext) +} + +func TestOrderingValidate(t *testing.T) { + o := &bill.Ordering{ + Code: "123", + } + err := o.Validate() + assert.NoError(t, err) + + o.Projects = []*org.DocumentRef{ + {}, + } + err = o.Validate() + assert.ErrorContains(t, err, "projects: (0: (code: cannot be blank.).)") +} diff --git a/bill/preceding.go b/bill/preceding.go deleted file mode 100644 index eebc2a51..00000000 --- a/bill/preceding.go +++ /dev/null @@ -1,91 +0,0 @@ -package bill - -import ( - "context" - - "github.com/invopop/gobl/cal" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/head" - "github.com/invopop/gobl/tax" - "github.com/invopop/gobl/uuid" - "github.com/invopop/jsonschema" - "github.com/invopop/validation" -) - -// Preceding allows for information to be provided about a previous invoice that this one -// will replace, subtract from, or add to. If this is used, the invoice type code will most likely need -// to be set to `corrective`, `credit-note`, or similar. -type Preceding struct { - // Preceding document's UUID. - UUID uuid.UUID `json:"uuid,omitempty" jsonschema:"title=UUID"` - // Type of the preceding document - Type cbc.Key `json:"type,omitempty" jsonschema:"title=Type"` - // Series identification code - Series string `json:"series,omitempty" jsonschema:"title=Series"` - // Code of the previous document. - Code string `json:"code" jsonschema:"title=Code"` - // The issue date of the previous document. - IssueDate *cal.Date `json:"issue_date,omitempty" jsonschema:"title=Issue Date"` - // Human readable description on why the preceding invoice is being replaced. - 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 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"` - // Extensions for region specific requirements. - Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` - // Additional semi-structured data that may be useful in specific regions - Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"` -} - -// Validate ensures the preceding details look okay -func (p *Preceding) Validate() error { - return p.ValidateWithContext(context.Background()) -} - -// Normalize tries to normalize the preceding data. -func (p *Preceding) Normalize(normalizers tax.Normalizers) { - if p == nil { - return - } - p.Stamps = head.NormalizeStamps(p.Stamps) - p.Ext = tax.CleanExtensions(p.Ext) - normalizers.Each(p) -} - -// ValidateWithContext ensures the preceding details look okay -func (p *Preceding) ValidateWithContext(ctx context.Context) error { - return validation.ValidateStructWithContext(ctx, p, - validation.Field(&p.UUID), - validation.Field(&p.Type), - validation.Field(&p.Series), - validation.Field(&p.Code, validation.Required), - validation.Field(&p.IssueDate, cal.DateNotZero()), - validation.Field(&p.Stamps), - validation.Field(&p.Period), - validation.Field(&p.Ext), - validation.Field(&p.Meta), - ) -} - -// JSONSchemaExtend extends the schema with additional property details -func (Preceding) JSONSchemaExtend(schema *jsonschema.Schema) { - props := schema.Properties - if prop, ok := props.Get("series"); ok { - prop.Pattern = InvoiceCodePattern - } - if prop, ok := props.Get("code"); ok { - prop.Pattern = InvoiceCodePattern - } - // Extend type list - if its, ok := props.Get("type"); ok { - its.OneOf = make([]*jsonschema.Schema, len(InvoiceTypes)) - for i, kd := range InvoiceTypes { - its.OneOf[i] = &jsonschema.Schema{ - Const: kd.Key.String(), - Title: kd.Name.String(), - Description: kd.Desc.String(), - } - } - } -} diff --git a/bill/preceding_test.go b/bill/preceding_test.go deleted file mode 100644 index 74471d03..00000000 --- a/bill/preceding_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package bill_test - -import ( - "testing" - - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cal" - "github.com/stretchr/testify/assert" -) - -func TestPrecedingValidation(t *testing.T) { - p := new(bill.Preceding) - p.Code = "FOO" - p.IssueDate = cal.NewDate(2022, 11, 6) - - err := p.Validate() - assert.NoError(t, err) -} diff --git a/cbc/code.go b/cbc/code.go index deccccf9..b1c4f631 100644 --- a/cbc/code.go +++ b/cbc/code.go @@ -3,6 +3,7 @@ package cbc import ( "errors" "regexp" + "strings" "github.com/invopop/jsonschema" "github.com/invopop/validation" @@ -12,7 +13,7 @@ import ( // at. We use "code" instead of "id", to reenforce the fact that codes should // 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 24 inclusive upper-case letters or numbers with optional periods (`.`), +// 1 and 32 inclusive upper-case letters or numbers with optional periods (`.`), // dashes (`-`), or forward slashes (`/`) to separate blocks. type Code string @@ -22,23 +23,35 @@ type CodeMap map[Key]Code // Basic code constants. var ( - CodePattern = `^[A-Z0-9]+([\.\-\/]?[A-Z0-9]+)*$` - CodeMinLength uint64 = 1 - CodeMaxLength uint64 = 24 + CodePattern = `^[A-Z0-9]+([\.\-\/]?[A-Z0-9]+)*$` + CodePatternRegexp = regexp.MustCompile(CodePattern) + CodeMinLength uint64 = 1 + CodeMaxLength uint64 = 32 ) var ( - codeValidationRegexp = regexp.MustCompile(CodePattern) + codeUnderscoreOrSpaceRegexp = regexp.MustCompile(`[_ ]`) + codeInvalidCharsRegexp = regexp.MustCompile(`[^A-Z0-9\.\-\/]`) ) // CodeEmpty is used when no code is defined. const CodeEmpty Code = "" +// NormalizeCode attempts to clean and normalize the provided code so that +// it matches what we'd expect instead of raising validation errors. +func NormalizeCode(c Code) Code { + code := strings.ToUpper(c.String()) + code = strings.TrimSpace(code) + code = codeUnderscoreOrSpaceRegexp.ReplaceAllString(code, "-") + code = codeInvalidCharsRegexp.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), validation.Length(1, int(CodeMaxLength)), - validation.Match(codeValidationRegexp), + validation.Match(CodePatternRegexp), ) } diff --git a/cbc/code_test.go b/cbc/code_test.go index 10271114..8a97433a 100644 --- a/cbc/code_test.go +++ b/cbc/code_test.go @@ -14,6 +14,66 @@ func TestCodeIn(t *testing.T) { assert.False(t, c.In("BAR", "DOM")) } +func TestNormalizeCode(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("FOO-BAR"), + }, + { + name: "empty", + code: cbc.Code(""), + want: cbc.Code(""), + }, + { + name: "underscore", + code: cbc.Code("FOO_BAR"), + want: cbc.Code("FOO-BAR"), + }, + { + name: "whitespace", + code: cbc.Code(" foo-bar1 "), + want: cbc.Code("FOO-BAR1"), + }, + { + name: "invalid chars", + code: cbc.Code("f$oo-bar1!"), + want: cbc.Code("FOO-BAR1"), + }, + { + name: "multiple spaces", + code: cbc.Code("foo bar dome"), + want: cbc.Code("FOO-BAR-DOME"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, cbc.NormalizeCode(tt.code)) + }) + } + +} + func TestCode_Validate(t *testing.T) { tests := []struct { name string @@ -52,6 +112,10 @@ func TestCode_Validate(t *testing.T) { name: "empty", code: cbc.Code(""), }, + { + name: "almost too long", + code: cbc.Code("123456789012345678901234567890AB"), + }, { name: "dot at start", code: cbc.Code(".B123"), @@ -82,9 +146,24 @@ func TestCode_Validate(t *testing.T) { code: cbc.Code("AB/.CD"), wantErr: "valid format", }, + { + name: "character return", + code: cbc.Code("AB\nCD"), + wantErr: "valid format", + }, + { + name: "character return", + code: cbc.Code("\n"), + wantErr: "valid format", + }, + { + name: "multi-dash", + code: cbc.Code("AB--CD"), + wantErr: "valid format", + }, { name: "too long", - code: cbc.Code("12345678901234567890ABCDE"), + code: cbc.Code("123456789012345678901234567890ABC"), wantErr: "length must be between", }, } diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index f1138515..9de7e2a7 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -168,34 +168,6 @@ ], "description": "Discount represents an allowance applied to the complete document independent from the individual lines." }, - "DocumentReference": { - "properties": { - "uuid": { - "type": "string", - "format": "uuid", - "title": "UUID", - "description": "Unique ID copied from the source document." - }, - "series": { - "type": "string", - "title": "Series", - "description": "Series the reference document belongs to." - }, - "code": { - "type": "string", - "title": "Code", - "description": "Source document's code or other identifier." - }, - "url": { - "type": "string", - "format": "uri", - "title": "URL", - "description": "Link to the source document." - } - }, - "type": "object", - "description": "DocumentReference provides a link to a existing document." - }, "Invoice": { "properties": { "$regime": { @@ -301,7 +273,7 @@ }, "preceding": { "items": { - "$ref": "#/$defs/Preceding" + "$ref": "https://gobl.org/draft-0/org/document-ref" }, "type": "array", "title": "Preceding Details", @@ -567,50 +539,71 @@ "title": "Period", "description": "Period of time that the invoice document refers to often used in addition to the details\nprovided in the individual line items." }, - "project": { - "$ref": "#/$defs/DocumentReference", - "title": "Project", - "description": "Project this invoice refers to." + "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." }, - "contract": { - "$ref": "#/$defs/DocumentReference", - "title": "Contract", - "description": "The identification of a contract." + "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." + }, + "projects": { + "items": { + "$ref": "https://gobl.org/draft-0/org/document-ref" + }, + "type": "array", + "title": "Projects", + "description": "Projects this invoice refers to." + }, + "contracts": { + "items": { + "$ref": "https://gobl.org/draft-0/org/document-ref" + }, + "type": "array", + "title": "Contracts", + "description": "The identification of contracts." }, - "purchase": { - "$ref": "#/$defs/DocumentReference", - "title": "Purchase Order", - "description": "Purchase order issued by the customer or buyer." + "purchases": { + "items": { + "$ref": "https://gobl.org/draft-0/org/document-ref" + }, + "type": "array", + "title": "Purchase Orders", + "description": "Purchase orders issued by the customer or buyer." }, - "sale": { - "$ref": "#/$defs/DocumentReference", - "title": "Sales Order", - "description": "Sales order issued by the supplier or seller." + "sales": { + "items": { + "$ref": "https://gobl.org/draft-0/org/document-ref" + }, + "type": "array", + "title": "Sales Orders", + "description": "Sales orders issued by the supplier or seller." }, "receiving": { - "$ref": "#/$defs/DocumentReference", + "items": { + "$ref": "https://gobl.org/draft-0/org/document-ref" + }, + "type": "array", "title": "Receiving Advice", "description": "Receiving Advice." }, "despatch": { - "$ref": "#/$defs/DocumentReference", + "items": { + "$ref": "https://gobl.org/draft-0/org/document-ref" + }, + "type": "array", "title": "Despatch Advice", "description": "Despatch advice." }, "tender": { - "$ref": "#/$defs/DocumentReference", + "items": { + "$ref": "https://gobl.org/draft-0/org/document-ref" + }, + "type": "array", "title": "Tender Advice", "description": "Tender advice, the identification of the call for tender or lot the invoice relates to." - }, - "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." - }, - "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." } }, "type": "object", @@ -698,98 +691,6 @@ "type": "object", "description": "Payment contains details as to how the invoice should be paid." }, - "Preceding": { - "properties": { - "uuid": { - "type": "string", - "format": "uuid", - "title": "UUID", - "description": "Preceding document's UUID." - }, - "type": { - "$ref": "https://gobl.org/draft-0/cbc/key", - "oneOf": [ - { - "const": "standard", - "title": "Standard", - "description": "A regular commercial invoice document between a supplier and customer." - }, - { - "const": "proforma", - "title": "Proforma", - "description": "For a clients validation before sending a final invoice." - }, - { - "const": "corrective", - "title": "Corrective", - "description": "Corrected invoice that completely *replaces* the preceding document." - }, - { - "const": "credit-note", - "title": "Credit Note", - "description": "Reflects a refund either partial or complete of the preceding document. A \ncredit note effectively *extends* the previous document." - }, - { - "const": "debit-note", - "title": "Debit Note", - "description": "An additional set of charges to be added to the preceding document." - } - ], - "title": "Type", - "description": "Type of the preceding document" - }, - "series": { - "type": "string", - "pattern": "^([A-Za-z0-9][A-Za-z0-9 /\\._-]?)*[A-Za-z0-9]$", - "title": "Series", - "description": "Series identification code" - }, - "code": { - "type": "string", - "pattern": "^([A-Za-z0-9][A-Za-z0-9 /\\._-]?)*[A-Za-z0-9]$", - "title": "Code", - "description": "Code of the previous document." - }, - "issue_date": { - "$ref": "https://gobl.org/draft-0/cal/date", - "title": "Issue Date", - "description": "The issue date of the previous document." - }, - "reason": { - "type": "string", - "title": "Reason", - "description": "Human readable description on why the preceding invoice is being replaced." - }, - "stamps": { - "items": { - "$ref": "https://gobl.org/draft-0/head/stamp" - }, - "type": "array", - "title": "Stamps", - "description": "Seals of approval from other organisations that may need to be listed." - }, - "period": { - "$ref": "https://gobl.org/draft-0/cal/period", - "title": "Period", - "description": "Tax period in which the previous invoice had an effect required by some tax regimes and formats." - }, - "ext": { - "$ref": "https://gobl.org/draft-0/tax/extensions", - "title": "Extensions", - "description": "Extensions for region specific requirements." - }, - "meta": { - "$ref": "https://gobl.org/draft-0/cbc/meta", - "title": "Meta", - "description": "Additional semi-structured data that may be useful in specific regions" - } - }, - "type": "object", - "required": [ - "code" - ], - "description": "Preceding allows for information to be provided about a previous invoice that this one will replace, subtract from, or add to." - }, "Tax": { "properties": { "prices_include": { diff --git a/data/schemas/org/document-ref.json b/data/schemas/org/document-ref.json new file mode 100644 index 00000000..cbdfffc3 --- /dev/null +++ b/data/schemas/org/document-ref.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gobl.org/draft-0/org/document-ref", + "$ref": "#/$defs/DocumentRef", + "$defs": { + "DocumentRef": { + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "title": "UUID", + "description": "Universally Unique Identifier." + }, + "type": { + "$ref": "https://gobl.org/draft-0/cbc/key", + "title": "Type", + "description": "Type of the document referenced." + }, + "issue_date": { + "$ref": "https://gobl.org/draft-0/cal/date", + "title": "Issue Date", + "description": "IssueDate reflects the date the document was issued." + }, + "series": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "pattern": "^[A-Z0-9]+([\\.\\-\\/]?[A-Z0-9]+)*$", + "title": "Series", + "description": "Series the referenced document belongs to." + }, + "code": { + "$ref": "https://gobl.org/draft-0/cbc/code", + "pattern": "^[A-Z0-9]+([\\.\\-\\/]?[A-Z0-9]+)*$", + "title": "Code", + "description": "Source document's code or other identifier." + }, + "line": { + "type": "integer", + "title": "Line", + "description": "Line index number inside the document, if relevant." + }, + "identities": { + "items": { + "$ref": "https://gobl.org/draft-0/org/identity" + }, + "type": "array", + "title": "Identities", + "description": "List of additional codes, IDs, or SKUs which can be used to identify the document or its contents, agreed upon by the supplier and customer." + }, + "period": { + "$ref": "https://gobl.org/draft-0/cal/period", + "title": "Period", + "description": "Tax period in which the referred document had an effect required by some tax regimes and formats." + }, + "reason": { + "type": "string", + "title": "Reason", + "description": "Human readable description on why this reference is here or needs to be used." + }, + "description": { + "type": "string", + "title": "Description", + "description": "Additional details about the document." + }, + "stamps": { + "items": { + "$ref": "https://gobl.org/draft-0/head/stamp" + }, + "type": "array", + "title": "Stamps", + "description": "Seals of approval from other organisations that may need to be listed." + }, + "url": { + "type": "string", + "format": "uri", + "title": "URL", + "description": "Link to the source document." + }, + "ext": { + "$ref": "https://gobl.org/draft-0/tax/extensions", + "description": "Extensions for additional codes that may be required." + }, + "meta": { + "$ref": "https://gobl.org/draft-0/cbc/meta", + "title": "Meta", + "description": "Meta contains additional information about the document." + } + }, + "type": "object", + "required": [ + "code" + ], + "description": "DocumentRef is used to describe an existing document or a specific part of it's contents." + } + } +} \ No newline at end of file diff --git a/envelope_test.go b/envelope_test.go index 7264ee93..6b8ba64c 100644 --- a/envelope_test.go +++ b/envelope_test.go @@ -341,7 +341,7 @@ func TestEnvelopeReplicate(t *testing.T) { require.NoError(t, err) doc := env.Extract().(*bill.Invoice) - assert.Equal(t, "SAMPLE-001", doc.Code, "should not update in place") + assert.Equal(t, "SAMPLE-001", doc.Code.String(), "should not update in place") e2, err := env.Replicate() require.NoError(t, err) diff --git a/internal/cli/bulk_test.go b/internal/cli/bulk_test.go index c787605d..e46a1318 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/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/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/document_ref.go b/org/document_ref.go new file mode 100644 index 00000000..48661f1f --- /dev/null +++ b/org/document_ref.go @@ -0,0 +1,92 @@ +package org + +import ( + "context" + + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/tax" + "github.com/invopop/gobl/uuid" + "github.com/invopop/jsonschema" + "github.com/invopop/validation" + "github.com/invopop/validation/is" +) + +// DocumentRef is used to describe an existing document or a specific part of it's contents. +type DocumentRef struct { + uuid.Identify + // Type of the document referenced. + Type cbc.Key `json:"type,omitempty" jsonschema:"title=Type"` + // IssueDate reflects the date the document was issued. + IssueDate *cal.Date `json:"issue_date,omitempty" jsonschema:"title=Issue Date"` + // Series the referenced document belongs to. + Series cbc.Code `json:"series,omitempty" jsonschema:"title=Series"` + // Source document's code or other identifier. + Code cbc.Code `json:"code" jsonschema:"title=Code"` + // Line index number inside the document, if relevant. + Line int `json:"line,omitempty" jsonschema:"title=Line"` + // List of additional codes, IDs, or SKUs which can be used to identify the document or its contents, agreed upon by the supplier and customer. + Identities []*Identity `json:"identities,omitempty" jsonschema:"title=Identities"` + // Tax period in which the referred document had an effect required by some tax regimes and formats. + Period *cal.Period `json:"period,omitempty" jsonschema:"title=Period"` + // Human readable description on why this reference is here or needs to be used. + Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` + // Additional details about the document. + Description string `json:"description,omitempty" jsonschema:"title=Description"` + // Seals of approval from other organisations that may need to be listed. + Stamps []*head.Stamp `json:"stamps,omitempty" jsonschema:"title=Stamps"` + // Link to the source document. + URL string `json:"url,omitempty" jsonschema:"title=URL,format=uri"` + // Extensions for additional codes that may be required. + Ext tax.Extensions `json:"ext,omitempty" jsonschemaL:"title=Extensions"` + // Meta contains additional information about the document. + Meta cbc.Meta `json:"meta,omitempty" jsonschema:"title=Meta"` +} + +// Normalize attempts to clean and normalize the DocumentRef. +func (dr *DocumentRef) Normalize(normalizers tax.Normalizers) { + if dr == nil { + return + } + dr.Ext = tax.CleanExtensions(dr.Ext) + dr.Series = cbc.NormalizeCode(dr.Series) + dr.Code = cbc.NormalizeCode(dr.Code) + normalizers.Each(dr) + tax.Normalize(normalizers, dr.Identities) +} + +// Validate ensures the Document looks correct. +func (dr *DocumentRef) Validate() error { + return dr.ValidateWithContext(context.Background()) +} + +// ValidateWithContext ensures the Document looks correct within the provided context. +func (dr *DocumentRef) ValidateWithContext(ctx context.Context) error { + return tax.ValidateStructWithContext(ctx, dr, + validation.Field(&dr.UUID), + validation.Field(&dr.Type), + validation.Field(&dr.IssueDate, cal.DateNotZero()), + validation.Field(&dr.Series), + validation.Field(&dr.Code, + validation.Match(cbc.CodePatternRegexp), + validation.Required, + ), + validation.Field(&dr.URL, is.URL), + validation.Field(&dr.Stamps), + validation.Field(&dr.Period), + validation.Field(&dr.Ext), + validation.Field(&dr.Meta), + ) +} + +// JSONSchemaExtend extends the schema with additional property details +func (DocumentRef) JSONSchemaExtend(schema *jsonschema.Schema) { + props := schema.Properties + if prop, ok := props.Get("series"); ok { + prop.Pattern = cbc.CodePattern + } + if prop, ok := props.Get("code"); ok { + prop.Pattern = cbc.CodePattern + } +} diff --git a/org/document_ref_test.go b/org/document_ref_test.go new file mode 100644 index 00000000..52473583 --- /dev/null +++ b/org/document_ref_test.go @@ -0,0 +1,31 @@ +package org_test + +import ( + "testing" + + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestDocumentRefValidation(t *testing.T) { + dr := new(org.DocumentRef) + dr.Code = "FOO" + dr.IssueDate = cal.NewDate(2022, 11, 6) + + err := dr.Validate() + assert.NoError(t, err) +} + +func TestDocumentNormalize(t *testing.T) { + dr := &org.DocumentRef{ + Code: " Foo ", + Ext: tax.Extensions{ + "fooo": "", + }, + } + dr.Normalize(nil) + assert.Equal(t, "FOO", dr.Code.String()) + assert.Empty(t, dr.Ext) +} diff --git a/org/org.go b/org/org.go index 21d737c8..9db88b8d 100644 --- a/org/org.go +++ b/org/org.go @@ -7,6 +7,7 @@ func init() { schema.Register(schema.GOBL.Add("org"), Address{}, Coordinates{}, + DocumentRef{}, Email{}, Identity{}, Image{}, diff --git a/regimes/co/invoices.go b/regimes/co/invoices.go index 57c66046..9143b5ad 100644 --- a/regimes/co/invoices.go +++ b/regimes/co/invoices.go @@ -138,7 +138,7 @@ func municipalityCodeRequired(tID *tax.Identity) bool { } func (v *invoiceValidator) preceding(value interface{}) error { - obj, ok := value.(*bill.Preceding) + obj, ok := value.(*org.DocumentRef) if !ok || obj == nil { return nil } diff --git a/regimes/co/invoices_test.go b/regimes/co/invoices_test.go index d50fb033..8441404a 100644 --- a/regimes/co/invoices_test.go +++ b/regimes/co/invoices_test.go @@ -68,7 +68,7 @@ func creditNote() *bill.Invoice { Code: "TEST", Type: bill.InvoiceTypeCreditNote, IssueDate: cal.MakeDate(2022, 12, 29), - Preceding: []*bill.Preceding{ + Preceding: []*org.DocumentRef{ { Code: "TEST", IssueDate: cal.NewDate(2022, 12, 27), diff --git a/regimes/gr/README.md b/regimes/gr/README.md index 19ecd376..35ba2bde 100644 --- a/regimes/gr/README.md +++ b/regimes/gr/README.md @@ -65,7 +65,7 @@ And this is how you'll get the same result by using the GOBL type and tags: Greece has three VAT rates: standard, reduced and super-reduced. Each of these rates are reduced by a 30% on the islands of Leros, Lesbos, Kos, Samos and Chios. The tax authority identifies each rate with a specific VAT category. -In GOBL, the IAPR VAT category code must be set using the `gr-mydata-vat-cat` extension of a line's tax to one of the codes: +In GOBL, the IAPR VAT category code must be set using the `gr-mydata-vat-rate` extension of a line's tax to one of the codes: | Code | Description | GOBL Rate | | ---- | --------------------------- | ---------------------- | @@ -79,7 +79,7 @@ In GOBL, the IAPR VAT category code must be set using the `gr-mydata-vat-cat` ex | `8` | Records without VAT | | -Please, note that GOBL will automatically set the proper `gr-mydata-vat-cat` code and tax percent automatically when the line tax uses any of the GOBL rates specified in the table above. For example: +Please, note that GOBL will automatically set the proper `gr-mydata-vat-rate` code and tax percent automatically when the line tax uses any of the GOBL rates specified in the table above. For example: ```js { @@ -200,7 +200,7 @@ For example: Invoices reported to the Greek tax authority via myDATA can optionally include information about the income classification of each invoice item. -In a GOBL invoice, the `gr-mydata-income-cat` and `gr-mydata-income-type` extensions can be set at line tax level to any of the values expected by the IAPR: +In a GOBL invoice, the `gr-mydata-income-cat` and `gr-mydata-income-type` extensions can be set at item level to any of the values expected by the IAPR: #### Income Category @@ -266,20 +266,12 @@ For example: "quantity": "20", "item": { "name": "Υπηρεσίες Ανάπτυξης", - "price": "90.00" - }, - // ... - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "ext": { - "gr-mydata-income-cat": "category1_1", - "gr-mydata-income-type": "E3_106", - // ... - } + "price": "90.00", + "ext": { + "gr-mydata-income-cat": "category1_1", + "gr-mydata-income-type": "E3_561_001", } - ] + } } ] ``` diff --git a/regimes/mx/sat/corrections.go b/regimes/mx/corrections.go similarity index 58% rename from regimes/mx/sat/corrections.go rename to regimes/mx/corrections.go index 999eef45..8033b251 100644 --- a/regimes/mx/sat/corrections.go +++ b/regimes/mx/corrections.go @@ -1,4 +1,4 @@ -package sat +package mx import ( "github.com/invopop/gobl/bill" @@ -6,11 +6,6 @@ import ( "github.com/invopop/gobl/tax" ) -// CorrectionDefinitions provides the array of correction definitions that apply to SAT -func CorrectionDefinitions() []*tax.CorrectionDefinition { - return correctionDefinitions -} - var correctionDefinitions = []*tax.CorrectionDefinition{ { Schema: bill.ShortSchemaInvoice, @@ -18,7 +13,7 @@ var correctionDefinitions = []*tax.CorrectionDefinition{ bill.InvoiceTypeCreditNote, }, Stamps: []cbc.Key{ - StampUUID, + StampSATUUID, }, }, } diff --git a/regimes/mx/invoice.go b/regimes/mx/invoice.go new file mode 100644 index 00000000..c7a92bdb --- /dev/null +++ b/regimes/mx/invoice.go @@ -0,0 +1,40 @@ +package mx + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +const ( + // constants copied from CFDI + extKeyIssuePlace cbc.Key = "mx-cfdi-issue-place" + extKeyPostCode cbc.Key = "mx-cfdi-post-code" +) + +func normalizeInvoice(inv *bill.Invoice) { + // 2024-04-26: copy suppliers post code to invoice, if not already + // set. + normalizeParty(inv.Supplier) // first do party + ext := make(tax.Extensions) + if inv.Tax != nil { + ext = inv.Tax.Ext + } + if ext.Has(extKeyIssuePlace) { + return + } + if inv.Supplier != nil && inv.Supplier.Ext.Has(extKeyPostCode) { + ext[extKeyIssuePlace] = inv.Supplier.Ext[extKeyPostCode] + } else if len(inv.Supplier.Addresses) > 0 { + addr := inv.Supplier.Addresses[0] + if addr.Code != "" { + ext[extKeyIssuePlace] = tax.ExtValue(addr.Code) + } + } + if len(ext) > 0 { + if inv.Tax == nil { + inv.Tax = new(bill.Tax) + } + inv.Tax.Ext = ext + } +} diff --git a/regimes/mx/invoice_test.go b/regimes/mx/invoice_test.go new file mode 100644 index 00000000..c0223988 --- /dev/null +++ b/regimes/mx/invoice_test.go @@ -0,0 +1,120 @@ +package mx_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/mx/cfdi" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "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 TestNormalizeInvoice(t *testing.T) { + t.Run("no tax", func(t *testing.T) { + inv := baseInvoice() + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + require.NotNil(t, inv.Tax) + assert.Equal(t, tax.ExtValue("21000"), inv.Tax.Ext[cfdi.ExtKeyIssuePlace]) + }) + t.Run("with supplier address code", func(t *testing.T) { + inv := baseInvoice() + delete(inv.Supplier.Ext, cfdi.ExtKeyPostCode) + inv.Supplier.Addresses = append(inv.Supplier.Addresses, + &org.Address{ + Locality: "Mexico", + Code: "21000", + }, + ) + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + require.NotNil(t, inv.Tax) + assert.Equal(t, tax.ExtValue("21000"), inv.Tax.Ext[cfdi.ExtKeyIssuePlace]) + }) + t.Run("migrate supplier issue place", func(t *testing.T) { + inv := baseInvoice() + inv.Tax = nil + inv.Supplier.Ext = tax.Extensions{ + cfdi.ExtKeyPostCode: "12345", + } + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + require.NotNil(t, inv.Tax) + assert.Equal(t, tax.ExtValue("12345"), inv.Tax.Ext[cfdi.ExtKeyIssuePlace]) + }) + t.Run("migrate supplier issue place", func(t *testing.T) { + inv := baseInvoice() + inv.Tax = nil + inv.Supplier.Ext = nil + inv.Supplier.Addresses = append(inv.Supplier.Addresses, + &org.Address{ + Locality: "Mexico", + Code: "12345", + }, + ) + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + require.NotNil(t, inv.Tax) + assert.Equal(t, tax.ExtValue("12345"), inv.Tax.Ext[cfdi.ExtKeyIssuePlace]) + }) +} + +func baseInvoice() *bill.Invoice { + return &bill.Invoice{ + Regime: tax.WithRegime("MX"), + Code: "123", + Currency: "MXN", + IssueDate: cal.MakeDate(2023, 1, 1), + Tax: &bill.Tax{ + Ext: tax.Extensions{ + cfdi.ExtKeyIssuePlace: "21000", + }, + }, + Supplier: &org.Party{ + Name: "Test Supplier", + Ext: tax.Extensions{ + cfdi.ExtKeyPostCode: "21000", + cfdi.ExtKeyFiscalRegime: "601", + }, + TaxID: &tax.Identity{ + Country: "MX", + Code: "AAA010101AAA", + }, + }, + Customer: &org.Party{ + Name: "Test Customer", + Ext: tax.Extensions{ + cfdi.ExtKeyPostCode: "65000", + cfdi.ExtKeyFiscalRegime: "608", + cfdi.ExtKeyUse: "G01", + }, + TaxID: &tax.Identity{ + Country: "MX", + Code: "ZZZ010101ZZZ", + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "bogus", + Price: num.MakeAmount(10000, 2), + Unit: org.UnitPackage, + Ext: tax.Extensions{ + cfdi.ExtKeyProdServ: "01010101", + }, + }, + Taxes: tax.Set{ + { + Category: "VAT", + Rate: "standard", + }, + }, + }, + }, + } +} diff --git a/regimes/mx/mx.go b/regimes/mx/mx.go index 5ac11b6d..8074ac23 100644 --- a/regimes/mx/mx.go +++ b/regimes/mx/mx.go @@ -2,19 +2,36 @@ package mx import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/currency" "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/common" - "github.com/invopop/gobl/regimes/mx/sat" "github.com/invopop/gobl/tax" ) func init() { tax.RegisterRegimeDef(New()) +} - // MX GOBL Schema Complements for CFDI +// Official SAT codes to include in stamps. +const ( + StampSATSignature cbc.Key = "sat-sig" // Signature - Sello Digital del SAT (optional) + StampSATSerial cbc.Key = "sat-serial" // Cert Serial - Número de Certificado SAT + StampSATTimestamp cbc.Key = "sat-timestamp" // Timestamp - Fecha y hora de certificación del SAT + StampSATUUID cbc.Key = "sat-uuid" // Folio Fiscal + StampSATURL cbc.Key = "sat-url" // URL QR Code + StampSATProviderRFC cbc.Key = "sat-provider-rfc" // Provider RFC - RFC del Proveedor de Certificación + StampSATChain cbc.Key = "sat-chain" // Cadena original del complemento de certificación digital del SAT +) -} +// Custom keys used typically in meta or codes information. +const ( + KeyFormaPago cbc.Key = "sat-forma-pago" // for mapping to c_FormaPago’s codes + KeyTipoRelacion cbc.Key = "sat-tipo-relacion" // for mapping to c_TipoRelacion’s codes + KeyImpuesto cbc.Key = "sat-impuesto" // for mapping to c_Impuesto’s codes +) // New provides the tax region definition func New() *tax.RegimeDef { @@ -31,24 +48,28 @@ func New() *tax.RegimeDef { Tags: []*tax.TagSet{ common.InvoiceTags(), }, - Categories: sat.TaxCategories(), - Corrections: sat.CorrectionDefinitions(), + Categories: taxCategories, + Corrections: correctionDefinitions, } } -// Validate validates a document against the tax regime. -func Validate(doc any) error { +// Normalize performs regime specific calculations. +func Normalize(doc any) { switch obj := doc.(type) { + case *bill.Invoice: + normalizeInvoice(obj) case *tax.Identity: - return sat.ValidateTaxIdentity(obj) + tax.NormalizeIdentity(obj) + case *org.Party: + normalizeParty(obj) } - return nil } -// Normalize performs regime specific calculations. -func Normalize(doc any) { +// Validate validates a document against the tax regime. +func Validate(doc any) error { switch obj := doc.(type) { case *tax.Identity: - tax.NormalizeIdentity(obj) + return ValidateTaxIdentity(obj) } + return nil } diff --git a/regimes/mx/party.go b/regimes/mx/party.go new file mode 100644 index 00000000..b10b397c --- /dev/null +++ b/regimes/mx/party.go @@ -0,0 +1,20 @@ +package mx + +import ( + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" +) + +func normalizeParty(p *org.Party) { + if p == nil { + return + } + // 2024-03-14: Migrate Tax ID Zone to extensions "mx-cfdi-post-code" + if p.TaxID != nil && p.TaxID.Zone != "" { //nolint:staticcheck + if p.Ext == nil { + p.Ext = make(tax.Extensions) + } + p.Ext[extKeyPostCode] = tax.ExtValue(p.TaxID.Zone) //nolint:staticcheck + p.TaxID.Zone = "" //nolint:staticcheck + } +} diff --git a/regimes/mx/party_test.go b/regimes/mx/party_test.go new file mode 100644 index 00000000..49ffa8df --- /dev/null +++ b/regimes/mx/party_test.go @@ -0,0 +1,27 @@ +package mx_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/mx/cfdi" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestMigratePartyIdentities(t *testing.T) { + customer := &org.Party{ + Name: "Test Customer", + TaxID: &tax.Identity{ + Country: "MX", + Code: "ZZZ010101ZZZ", + Zone: "65000", + }, + } + + mx := tax.RegimeDefFor("MX") + mx.Normalizer(customer) + + assert.Equal(t, "65000", customer.Ext[cfdi.ExtKeyPostCode].String()) + assert.Empty(t, customer.TaxID.Zone) //nolint:staticcheck +} diff --git a/regimes/mx/sat/sat.go b/regimes/mx/sat/sat.go deleted file mode 100644 index 568d18d2..00000000 --- a/regimes/mx/sat/sat.go +++ /dev/null @@ -1,23 +0,0 @@ -// Package sat provides specifications from the Mexican SAT -// (Servicio de Administración Tributaria) tax authority. -package sat - -import "github.com/invopop/gobl/cbc" - -// Official SAT codes to include in stamps. -const ( - StampSignature cbc.Key = "sat-sig" // Signature - Sello Digital del SAT (optional) - StampSerial cbc.Key = "sat-serial" // Cert Serial - Número de Certificado SAT - StampTimestamp cbc.Key = "sat-timestamp" // Timestamp - Fecha y hora de certificación del SAT - StampUUID cbc.Key = "sat-uuid" // Folio Fiscal - StampURL cbc.Key = "sat-url" // URL QR Code - StampProviderRFC cbc.Key = "sat-provider-rfc" // Provider RFC - RFC del Proveedor de Certificación - StampChain cbc.Key = "sat-chain" // Cadena original del complemento de certificación digital del SAT -) - -// Custom keys used typically in meta or codes information. -const ( - KeyFormaPago cbc.Key = "sat-forma-pago" // for mapping to c_FormaPago’s codes - KeyTipoRelacion cbc.Key = "sat-tipo-relacion" // for mapping to c_TipoRelacion’s codes - KeyImpuesto cbc.Key = "sat-impuesto" // for mapping to c_Impuesto’s codes -) diff --git a/regimes/mx/sat/tax_categories.go b/regimes/mx/tax_categories.go similarity index 95% rename from regimes/mx/sat/tax_categories.go rename to regimes/mx/tax_categories.go index 6514c05a..e99ce943 100644 --- a/regimes/mx/sat/tax_categories.go +++ b/regimes/mx/tax_categories.go @@ -1,4 +1,4 @@ -package sat +package mx import ( "github.com/invopop/gobl/cbc" @@ -20,11 +20,6 @@ const ( TaxRateExempt cbc.Key = "exempt" ) -// TaxCategories returns the list of tax categories used in Mexico. -func TaxCategories() []*tax.CategoryDef { - return taxCategories -} - var taxCategories = []*tax.CategoryDef{ // // IVA diff --git a/regimes/mx/sat/tax_identity.go b/regimes/mx/tax_identity.go similarity index 99% rename from regimes/mx/sat/tax_identity.go rename to regimes/mx/tax_identity.go index c601c36e..fe9e794a 100644 --- a/regimes/mx/sat/tax_identity.go +++ b/regimes/mx/tax_identity.go @@ -1,4 +1,4 @@ -package sat +package mx import ( "regexp" diff --git a/regimes/mx/sat/tax_identity_test.go b/regimes/mx/tax_identity_test.go similarity index 86% rename from regimes/mx/sat/tax_identity_test.go rename to regimes/mx/tax_identity_test.go index e71868db..e43b14ba 100644 --- a/regimes/mx/sat/tax_identity_test.go +++ b/regimes/mx/tax_identity_test.go @@ -1,4 +1,4 @@ -package sat_test +package mx_test import ( "testing" @@ -6,7 +6,6 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/regimes/mx" - "github.com/invopop/gobl/regimes/mx/sat" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" ) @@ -44,7 +43,7 @@ func TestTaxIdentityValidation(t *testing.T) { zone l10n.Code err string }{ - {name: "foreign code", code: sat.TaxIdentityCodeForeign, zone: "21000"}, + {name: "foreign code", code: mx.TaxIdentityCodeForeign, zone: "21000"}, {name: "valid code 1", code: "MNOP8201019HJ"}, {name: "valid code 2", code: "UVWX610715JKL"}, {name: "valid code 3", code: "STU760612MN1"}, @@ -97,16 +96,16 @@ func TestTaxIdentityDetermineType(t *testing.T) { Type: cbc.KeyEmpty, }, { - Code: sat.TaxIdentityCodeForeign, - Type: sat.TaxIdentityTypePerson, + Code: mx.TaxIdentityCodeForeign, + Type: mx.TaxIdentityTypePerson, }, { Code: "MNOP8201019HJ", - Type: sat.TaxIdentityTypePerson, + Type: mx.TaxIdentityTypePerson, }, { Code: "ABC830720XYZ", - Type: sat.TaxIdentityTypeCompany, + Type: mx.TaxIdentityTypeCompany, }, { Code: "XXXX", @@ -115,7 +114,7 @@ func TestTaxIdentityDetermineType(t *testing.T) { } for _, ts := range tests { t.Run(string(ts.Code), func(t *testing.T) { - res := sat.DetermineTaxCodeType(ts.Code) + res := mx.DetermineTaxCodeType(ts.Code) assert.Equal(t, ts.Type, res) }) } diff --git a/regimes/pl/invoices.go b/regimes/pl/invoices.go index e2076494..1bf5d181 100644 --- a/regimes/pl/invoices.go +++ b/regimes/pl/invoices.go @@ -83,7 +83,7 @@ func (v *invoiceValidator) commercialCustomer(value interface{}) error { } func (v *invoiceValidator) preceding(value interface{}) error { - obj, ok := value.(*bill.Preceding) + obj, ok := value.(*org.DocumentRef) if !ok || obj == nil { return nil } diff --git a/regimes/pl/invoices_test.go b/regimes/pl/invoices_test.go index 106e06f1..e1417a2d 100644 --- a/regimes/pl/invoices_test.go +++ b/regimes/pl/invoices_test.go @@ -20,7 +20,7 @@ func creditNote() *bill.Invoice { Code: "TEST", Type: bill.InvoiceTypeCreditNote, IssueDate: cal.MakeDate(2022, 12, 29), - Preceding: []*bill.Preceding{ + Preceding: []*org.DocumentRef{ { Code: "TEST", IssueDate: cal.NewDate(2022, 12, 27), diff --git a/tax/addons.go b/tax/addons.go index f044b410..438700cf 100644 --- a/tax/addons.go +++ b/tax/addons.go @@ -63,8 +63,13 @@ func (as *Addons) SetAddons(addons ...cbc.Key) { as.List = addons } -// GetAddons provides a slice of Addon instances. -func (as Addons) GetAddons() []*AddonDef { +// GetAddons provides the list of addon keys in use. +func (as *Addons) GetAddons() []cbc.Key { + return as.List +} + +// GetAddonDefs provides a slice of Addon Definition instances. +func (as Addons) GetAddonDefs() []*AddonDef { list := make([]*AddonDef, 0, len(as.List)) for _, ak := range as.List { if a := AddonForKey(ak); a != nil { diff --git a/tax/addons_test.go b/tax/addons_test.go index 15a1b5dd..792807df 100644 --- a/tax/addons_test.go +++ b/tax/addons_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,7 +23,9 @@ func TestEmbeddingAddons(t *testing.T) { assert.NotNil(t, ts.Addons) assert.Equal(t, "Test", ts.Name) - defs := ts.GetAddons() + assert.Equal(t, []cbc.Key{"mx-cfdi-v4"}, ts.GetAddons()) + + defs := ts.GetAddonDefs() assert.Len(t, defs, 1) assert.Equal(t, "mx-cfdi-v4", defs[0].Key.String()) diff --git a/version.go b/version.go index 32948394..4aa6ae3d 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.200.0-rc1" +const VERSION Version = "v0.200.0-rc2" // Semver parses and returns semver func (v Version) Semver() *semver.Version {