diff --git a/bill/invoice.go b/bill/invoice.go index 42a550f4..9f2595f8 100644 --- a/bill/invoice.go +++ b/bill/invoice.go @@ -145,6 +145,8 @@ func (inv *Invoice) ValidateWithContext(ctx context.Context) error { validation.Field(&inv.Notes), validation.Field(&inv.Meta), + + validation.Field(&inv.Complements, validation.Each()), ) if err == nil { err = r.ValidateObject(inv) @@ -466,6 +468,20 @@ func (inv *Invoice) calculate(r *tax.Regime, tID *tax.Identity) error { t.round(zero) + // Complements + if err := calculateComplements(inv.Complements); err != nil { + return validation.Errors{"complements": err} + } + + return nil +} + +func calculateComplements(comps []*schema.Object) error { + for _, c := range comps { + if err := c.Calculate(); err != nil { + return err + } + } return nil } diff --git a/regimes/mx/examples/fuel-account-balance.yaml b/regimes/mx/examples/fuel-account-balance.yaml new file mode 100644 index 00000000..e8d2b318 --- /dev/null +++ b/regimes/mx/examples/fuel-account-balance.yaml @@ -0,0 +1,78 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +issue_date: "2023-07-10" +series: "TEST" +code: "00002" +supplier: + name: "ESCUELA KEMPER URGATE" + ext: + mx-cfdi-fiscal-regime: "601" + tax_id: + country: "MX" + code: "EKU9003173C9" + zone: "21000" +customer: + name: "UNIVERSIDAD ROBOTICA ESPAÑOLA" + ext: + mx-cfdi-fiscal-regime: "601" + mx-cfdi-use: "G01" + tax_id: + country: "MX" + code: "URE180429TM6" + zone: "86991" +lines: + - quantity: "1" + item: + name: "Comisión servicio de monedero electrónico" + price: "10.00" + ext: + mx-cfdi-prod-serv: "84141602" + taxes: + - cat: "VAT" + rate: "standard" +payment: + terms: + notes: "Condiciones de pago" + instructions: + key: "online+wallet" +complements: + - $schema: "https://gobl.org/draft-0/regimes/mx/fuel-account-balance" + account_number: "0123456789" + lines: + - e_wallet_id: "1234" + purchase_date_time: "2022-07-19T10:20:30" + vendor_tax_code: "RWT860605OF5" + service_station_code: "8171650" + quantity: "9.6613" + item: + unit: "l" + type: "3" + name: "Diesel" + price: "12.7428" + purchase_code: "2794668" + total: "123.11" + taxes: + - code: "IVA" + rate: "0.16" + amount: "19.70" + - code: "IEPS" + rate: "5.9195" + amount: "57.19" + - e_wallet_id: "1234" + purchase_date_time: "2022-08-19T10:20:30" + vendor_tax_code: "DJV320816JT1" + service_station_code: "8171667" + quantity: "9.68" + item: + type: "1" + name: "Gasolina Magna" + price: "12.709" + purchase_code: "2794669" + total: "123.02" + taxes: + - code: "IVA" + rate: "0.16" + amount: "19.68" + - code: "IEPS" + rate: "5.9195" + amount: "57.30" + diff --git a/regimes/mx/examples/out/fuel-account-balance.json b/regimes/mx/examples/out/fuel-account-balance.json new file mode 100644 index 00000000..a5094a88 --- /dev/null +++ b/regimes/mx/examples/out/fuel-account-balance.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "1451d94f4ab432342eb1cf5721e978ff88bf45a90835c0538a917b58b9d59224" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "TEST", + "code": "00002", + "issue_date": "2023-07-10", + "currency": "MXN", + "supplier": { + "name": "ESCUELA KEMPER URGATE", + "tax_id": { + "country": "MX", + "zone": "21000", + "code": "EKU9003173C9" + }, + "ext": { + "mx-cfdi-fiscal-regime": "601" + } + }, + "customer": { + "name": "UNIVERSIDAD ROBOTICA ESPAÑOLA", + "tax_id": { + "country": "MX", + "zone": "86991", + "code": "URE180429TM6" + }, + "ext": { + "mx-cfdi-fiscal-regime": "601", + "mx-cfdi-use": "G01" + } + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Comisión servicio de monedero electrónico", + "price": "10.00", + "ext": { + "mx-cfdi-prod-serv": "84141602" + } + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "16.0%" + } + ], + "total": "10.00" + } + ], + "payment": { + "terms": { + "notes": "Condiciones de pago" + }, + "instructions": { + "key": "online+wallet" + } + }, + "totals": { + "sum": "10.00", + "total": "10.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "10.00", + "percent": "16.0%", + "amount": "1.60" + } + ], + "amount": "1.60" + } + ], + "sum": "1.60" + }, + "tax": "1.60", + "total_with_tax": "11.60", + "payable": "11.60" + }, + "complements": [ + { + "$schema": "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", + "account_number": "0123456789", + "subtotal": "246.13", + "total": "400.00", + "lines": [ + { + "e_wallet_id": "1234", + "purchase_date_time": "2022-07-19T10:20:30", + "vendor_tax_code": "RWT860605OF5", + "service_station_code": "8171650", + "quantity": "9.661", + "item": { + "type": "3", + "unit": "l", + "name": "Diesel", + "price": "12.743" + }, + "purchase_code": "2794668", + "total": "123.11", + "taxes": [ + { + "code": "IVA", + "rate": "0.160000", + "amount": "19.70" + }, + { + "code": "IEPS", + "rate": "5.919500", + "amount": "57.19" + } + ] + }, + { + "e_wallet_id": "1234", + "purchase_date_time": "2022-08-19T10:20:30", + "vendor_tax_code": "DJV320816JT1", + "service_station_code": "8171667", + "quantity": "9.680", + "item": { + "type": "1", + "name": "Gasolina Magna", + "price": "12.709" + }, + "purchase_code": "2794669", + "total": "123.02", + "taxes": [ + { + "code": "IVA", + "rate": "0.160000", + "amount": "19.68" + }, + { + "code": "IEPS", + "rate": "5.919500", + "amount": "57.30" + } + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/regimes/mx/fuel_account_balance.go b/regimes/mx/fuel_account_balance.go new file mode 100644 index 00000000..5b9a7179 --- /dev/null +++ b/regimes/mx/fuel_account_balance.go @@ -0,0 +1,197 @@ +package mx + +import ( + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/validation" +) + +// Constants for the precision of complement's amounts +const ( + FuelAccountInterimPrecision = 3 + FuelAccountFinalPrecision = 2 + FuelAccountRatePrecision = 6 +) + +// Constants for the complement's allowed tax codes +const ( + FuelAccountTaxCodeVAT = cbc.Code("IVA") + FuelAccountTaxCodeIEPS = cbc.Code("IEPS") +) + +// FuelAccountValidTaxCodes lists of the complement's allowed tax codes +var FuelAccountValidTaxCodes = []interface{}{ + FuelAccountTaxCodeVAT, + FuelAccountTaxCodeIEPS, +} + +// FuelAccountBalance carries the data to produce a CFDI's "Complemento de +// Estado de Cuenta de Combustibles para Monederos Electrónicos" (version 1.2 +// revision B) providing detailed information about fuel purchases made with +// electronic wallets. In Mexico, e-wallet suppliers are required to report this +// complementary information in the invoices they issue to their customers. +// +// This struct maps to the `EstadoDeCuentaCombustible` root node in the CFDI's +// complement. +type FuelAccountBalance struct { + // Customer's account number (maps to `NumeroDeCuenta`). + AccountNumber string `json:"account_number" jsonschema:"title=Account Number"` + // Sum of all line totals (i.e. taxes not included) (calculated, maps to `SubTotal`). + Subtotal num.Amount `json:"subtotal" jsonschema:"title=Subtotal" jsonschema_extras:"calculated=true"` + // Grand total after taxes have been applied (calculated, maps to `Total`). + Total num.Amount `json:"total" jsonschema:"title=Total" jsonschema_extras:"calculated=true"` + // List of fuel purchases made with the customer's e-wallets (maps to `Conceptos`). + Lines []*FuelAccountLine `json:"lines" jsonschema:"title=Lines"` +} + +// FuelAccountLine represents a single fuel purchase made with an e-wallet +// issued by the invoice's supplier. It maps to one +// `ConceptoEstadoDeCuentaCombustible` node in the CFDI's complement. +type FuelAccountLine struct { + // Identifier of the e-wallet used to make the purchase (maps to `Identificador`). + EWalletID cbc.Code `json:"e_wallet_id" jsonschema:"title=E-wallet Identifier"` + // Date and time of the purchase (maps to `Fecha`). + PurchaseDateTime cal.DateTime `json:"purchase_date_time" jsonschema:"title=Purchase Date and Time"` + // Tax Identity Code of the fuel's vendor (maps to `Rfc`) + VendorTaxCode cbc.Code `json:"vendor_tax_code" jsonschema:"title=Vendor's Tax Identity Code"` + // Code of the service station where the purchase was made (maps to `ClaveEstacion`). + ServiceStationCode cbc.Code `json:"service_station_code" jsonschema:"title=Service Station Code"` + // Amount of fuel units purchased (maps to `Cantidad`) + Quantity num.Amount `json:"quantity" jsonschema:"title=Quantity"` + // Details of the fuel purchased. + Item *FuelAccountItem `json:"item" jsonschema:"title=Item"` + // Identifier of the purchase (maps to `FolioOperacion`). + PurchaseCode cbc.Code `json:"purchase_code" jsonschema:"title=Purchase Code"` + // Result of quantity multiplied by the unit price (maps to `Importe`). + Total num.Amount `json:"total" jsonschema:"title=Total"` + // Map of taxes applied to the purchase (maps to `Traslados`). + Taxes []*FuelAccountTax `json:"taxes" jsonschema:"title=Taxes"` +} + +// FuelAccountItem provides the details of a fuel purchase. Its fields map to +// attributes of the `ConceptoEstadoDeCuentaCombustible` node in the CFDI's +// complement. +type FuelAccountItem struct { + // Type of fuel (one of `c_ClaveTipoCombustible` codes, maps to `TipoCombustible`). + Type cbc.Code `json:"type" jsonschema:"title=Type"` + // Reference unit of measure used in the price and the quantity (maps to `Unidad`). + Unit org.Unit `json:"unit,omitempty" jsonschema:"title=Unit"` + // Name of the fuel (maps to `NombreCombustible`). + Name string `json:"name" jsonschema:"title=Name"` + // Base price of a single unit of the fuel without taxes (maps to `ValorUnitario`). + Price num.Amount `json:"price" jsonschema:"title=Price"` +} + +// FuelAccountTax represents a single tax applied to a fuel purchase. It maps to +// one `Traslado` node in the CFDI's complement. +type FuelAccountTax struct { + // Code that identifies the tax ("IVA" or "IEPS", maps to `Impuesto`) + Code cbc.Code `json:"code" jsonschema:"title=Code"` + // Rate applicable to either the line total (tasa) or the line quantity (cuota) (maps to `TasaOCuota`). + Rate num.Amount `json:"rate" jsonschema:"title=Rate"` + // Total amount of the tax once the rate has been applied (maps to `Importe`). + Amount num.Amount `json:"amount" jsonschema:"title=Amount"` +} + +// Validate ensures that the complement's data is valid. +func (fab *FuelAccountBalance) Validate() error { + return validation.ValidateStruct(fab, + validation.Field(&fab.AccountNumber, + validation.Required, + validation.Length(1, 50), + ), + validation.Field(&fab.Subtotal, validation.Required), + validation.Field(&fab.Total, validation.Required), + validation.Field(&fab.Lines, validation.Required), + ) +} + +// Validate ensures that the line's data is valid. +func (fal *FuelAccountLine) Validate() error { + return validation.ValidateStruct(fal, + validation.Field(&fal.EWalletID, validation.Required), + validation.Field(&fal.PurchaseDateTime, cal.DateTimeNotZero()), + validation.Field(&fal.VendorTaxCode, + validation.Required, + validation.By(validateTaxCode), + ), + validation.Field(&fal.ServiceStationCode, + validation.Required, + validation.Length(1, 20), + ), + validation.Field(&fal.Quantity, num.Positive), + validation.Field(&fal.Item, validation.Required), + + validation.Field(&fal.PurchaseCode, + validation.Required, + validation.Length(1, 50), + ), + validation.Field(&fal.Total, isValidLineTotal(fal)), + validation.Field(&fal.Taxes, validation.Required), + ) +} + +// Validate ensures that the item's data is valid. +func (fai *FuelAccountItem) Validate() error { + return validation.ValidateStruct(fai, + validation.Field(&fai.Type, validation.Required), + validation.Field(&fai.Name, + validation.Required, + validation.Length(1, 300), + ), + validation.Field(&fai.Price, num.Positive), + ) +} + +// Validate ensures that the tax's data is valid. +func (fat *FuelAccountTax) Validate() error { + return validation.ValidateStruct(fat, + validation.Field(&fat.Code, + validation.Required, + validation.In(FuelAccountValidTaxCodes...), + ), + validation.Field(&fat.Rate, num.Positive), + validation.Field(&fat.Amount, num.Positive), + ) +} + +func isValidLineTotal(line *FuelAccountLine) validation.Rule { + if line.Item == nil { + return validation.Skip + } + + expected := line.Quantity.Multiply(line.Item.Price).Rescale(2) + + return validation.In(expected).Error("must be quantity x unit_price") +} + +// Calculate performs the complement's calculations and normalisations. +func (fab *FuelAccountBalance) Calculate() error { + var subtotal, taxtotal num.Amount + + for _, l := range fab.Lines { + // Normalise amounts to the expected precision + if l.Item != nil { + l.Item.Price = l.Item.Price.Rescale(FuelAccountInterimPrecision) + } + l.Quantity = l.Quantity.Rescale(FuelAccountInterimPrecision) + l.Total = l.Total.Rescale(FuelAccountFinalPrecision) + + subtotal = l.Total.Add(subtotal) + + for _, t := range l.Taxes { + // Normalise amounts to the expected precision + t.Rate = t.Rate.Rescale(FuelAccountRatePrecision) + t.Amount = t.Amount.Rescale(FuelAccountFinalPrecision) + + taxtotal = t.Amount.Add(taxtotal) + } + } + + fab.Subtotal = subtotal.Rescale(FuelAccountFinalPrecision) + fab.Total = subtotal.Add(taxtotal).Rescale(FuelAccountFinalPrecision) + + return nil +} diff --git a/regimes/mx/fuel_account_balance_test.go b/regimes/mx/fuel_account_balance_test.go new file mode 100644 index 00000000..e7ab1e49 --- /dev/null +++ b/regimes/mx/fuel_account_balance_test.go @@ -0,0 +1,118 @@ +package mx_test + +import ( + "testing" + + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/regimes/mx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvalidComplement(t *testing.T) { + fab := &mx.FuelAccountBalance{} + + err := fab.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "account_number: cannot be blank") + assert.Contains(t, err.Error(), "lines: cannot be blank") +} + +func TestInvalidLine(t *testing.T) { + fab := &mx.FuelAccountBalance{Lines: []*mx.FuelAccountLine{{}}} + + err := fab.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "e_wallet_id: cannot be blank") + assert.Contains(t, err.Error(), "purchase_date_time: required") + assert.Contains(t, err.Error(), "vendor_tax_code: cannot be blank") + assert.Contains(t, err.Error(), "service_station_code: cannot be blank") + assert.Contains(t, err.Error(), "quantity: must be greater than 0") + assert.Contains(t, err.Error(), "item: cannot be blank") + assert.Contains(t, err.Error(), "purchase_code: cannot be blank") + assert.Contains(t, err.Error(), "taxes: cannot be blank") + + fab.Lines[0].VendorTaxCode = "1234" + fab.Lines[0].Quantity = num.MakeAmount(1, 0) + fab.Lines[0].Item = &mx.FuelAccountItem{Price: num.MakeAmount(1, 0)} + + err = fab.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "vendor_tax_code: invalid tax identity code") + assert.Contains(t, err.Error(), "total: must be quantity x unit_price") +} + +func TestInvalidItem(t *testing.T) { + fab := &mx.FuelAccountBalance{Lines: []*mx.FuelAccountLine{ + {Item: &mx.FuelAccountItem{}}}, + } + + err := fab.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "type: cannot be blank") + assert.Contains(t, err.Error(), "name: cannot be blank") + assert.Contains(t, err.Error(), "price: must be greater than 0") +} + +func TestInvalidTax(t *testing.T) { + fab := &mx.FuelAccountBalance{Lines: []*mx.FuelAccountLine{ + {Taxes: []*mx.FuelAccountTax{{}}}}, + } + + err := fab.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "code: cannot be blank") + assert.Contains(t, err.Error(), "rate: must be greater than 0") + assert.Contains(t, err.Error(), "amount: must be greater than 0") + + fab.Lines[0].Taxes[0].Code = "IRPF" + + err = fab.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "code: must be a valid value") +} + +func TestCalculate(t *testing.T) { + fab := &mx.FuelAccountBalance{ + Lines: []*mx.FuelAccountLine{ + { + Quantity: num.MakeAmount(11, 1), + Item: &mx.FuelAccountItem{Price: num.MakeAmount(9091, 2)}, + Total: num.MakeAmount(100, 0), + Taxes: []*mx.FuelAccountTax{ + { + Rate: num.MakeAmount(16, 2), + Amount: num.MakeAmount(16, 0), + }, + {Amount: num.MakeAmount(56789, 4)}, + }, + }, + { + Total: num.MakeAmount(100009, 3), + Taxes: []*mx.FuelAccountTax{ + {Amount: num.MakeAmount(16, 0)}, + {Amount: num.MakeAmount(56789, 4)}, + }, + }, + }, + } + + err := fab.Calculate() + + require.NoError(t, err) + assert.Equal(t, num.MakeAmount(20001, 2), fab.Subtotal) + assert.Equal(t, num.MakeAmount(24337, 2), fab.Total) + + assert.Equal(t, num.MakeAmount(1100, 3), fab.Lines[0].Quantity) + assert.Equal(t, num.MakeAmount(90910, 3), fab.Lines[0].Item.Price) + assert.Equal(t, num.MakeAmount(10000, 2), fab.Lines[0].Total) + + assert.Equal(t, num.MakeAmount(160000, 6), fab.Lines[0].Taxes[0].Rate) + assert.Equal(t, num.MakeAmount(1600, 2), fab.Lines[0].Taxes[0].Amount) +} diff --git a/regimes/mx/mx.go b/regimes/mx/mx.go index e65f0ed3..09a88c0a 100644 --- a/regimes/mx/mx.go +++ b/regimes/mx/mx.go @@ -9,11 +9,15 @@ import ( "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/common" + "github.com/invopop/gobl/schema" "github.com/invopop/gobl/tax" ) func init() { tax.RegisterRegime(New()) + + // MX GOBL Schema Complements + schema.Register(schema.GOBL.Add("regimes/mx"), FuelAccountBalance{}) } // Custom keys used typically in meta or codes information.