From 4e8c219f6a9928e20460d12f8935d0bc76796510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luismi=20Cavall=C3=A9?= Date: Fri, 29 Sep 2023 14:17:59 +0000 Subject: [PATCH] Add fuel account balance complement to MX regime --- bill/invoice.go | 16 ++ regimes/mx/examples/fuel-account-balance.yaml | 76 ++++++++ .../mx/examples/out/fuel-account-balance.json | 155 +++++++++++++++ regimes/mx/fuel_account_balance.go | 182 ++++++++++++++++++ regimes/mx/fuel_account_balance_test.go | 107 ++++++++++ regimes/mx/mx.go | 4 + 6 files changed, 540 insertions(+) create mode 100644 regimes/mx/examples/fuel-account-balance.yaml create mode 100644 regimes/mx/examples/out/fuel-account-balance.json create mode 100644 regimes/mx/fuel_account_balance.go create mode 100644 regimes/mx/fuel_account_balance_test.go diff --git a/bill/invoice.go b/bill/invoice.go index 42a550f4..d85b3562 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(r, inv.Complements); err != nil { + return validation.Errors{"complements": err} + } + + return nil +} + +func calculateComplements(r *tax.Regime, 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..c8ef8b3a --- /dev/null +++ b/regimes/mx/examples/fuel-account-balance.yaml @@ -0,0 +1,76 @@ +$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" + unit: "l" + fuel_type: "3" + fuel_name: "Diesel" + unit_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" + fuel_type: "1" + fuel_name: "Gasolina Magna" + unit_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..855d7161 --- /dev/null +++ b/regimes/mx/examples/out/fuel-account-balance.json @@ -0,0 +1,155 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "c0a43b719859e1f656b6a3bf6cc500703f1536240a110522a7f3f7fbeb566ef4" + }, + "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", + "fuel_type": "3", + "unit": "l", + "fuel_name": "Diesel", + "unit_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", + "fuel_type": "1", + "fuel_name": "Gasolina Magna", + "unit_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..62fd9b84 --- /dev/null +++ b/regimes/mx/fuel_account_balance.go @@ -0,0 +1,182 @@ +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" +) + +const ( + FuelAccountInterimPrecision = 3 + FuelAccountFinalPrecision = 2 + FuelAccountRatePrecision = 6 + + FuelAccountTaxCodeVAT = cbc.Code("IVA") + FuelAccountTaxCodeIEPS = cbc.Code("IEPS") +) + +// 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"` + // Type of fuel (one of `c_ClaveTipoCombustible` codes, maps to `TipoCombustible`). + FuelType cbc.Code `json:"fuel_type" jsonschema:"title=Fuel 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`). + FuelName string `json:"fuel_name" jsonschema:"title=Fuel Name"` + // Base price of a single unit of the fuel without taxes (maps to `ValorUnitario`). + UnitPrice num.Amount `json:"unit_price" jsonschema:"title=Unit Price"` + // 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"` +} + +// 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"` +} + +func (comp *FuelAccountBalance) Validate() error { + return validation.ValidateStruct(comp, + validation.Field(&comp.AccountNumber, + validation.Required, + validation.Length(1, 50), + ), + validation.Field(&comp.Subtotal, validation.Required), + validation.Field(&comp.Total, validation.Required), + validation.Field(&comp.Lines, + validation.Required, + validation.Each(validation.By(validateFuelAccountLine)), + ), + ) +} + +func validateFuelAccountLine(value interface{}) error { + line, _ := value.(*FuelAccountLine) + if line == nil { + return nil + } + + return validation.ValidateStruct(line, + validation.Field(&line.EWalletID, validation.Required), + validation.Field(&line.PurchaseDateTime, cal.DateTimeNotZero()), + validation.Field(&line.VendorTaxCode, + validation.Required, + validation.By(validateTaxCode), + ), + validation.Field(&line.ServiceStationCode, + validation.Required, + validation.Length(1, 20), + ), + validation.Field(&line.Quantity, num.Positive), + validation.Field(&line.FuelType, validation.Required), + validation.Field(&line.FuelName, + validation.Required, + validation.Length(1, 300), + ), + validation.Field(&line.PurchaseCode, + validation.Required, + validation.Length(1, 50), + ), + validation.Field(&line.UnitPrice, num.Positive), + validation.Field(&line.Total, isValidLineTotal(line)), + validation.Field(&line.Taxes, + validation.Required, + validation.Each(validation.By(validateFuelAccountTax)), + ), + ) +} + +var validTaxCodes = []interface{}{ + FuelAccountTaxCodeVAT, + FuelAccountTaxCodeIEPS, +} + +func validateFuelAccountTax(value interface{}) error { + tax, _ := value.(*FuelAccountTax) + if tax == nil { + return nil + } + + return validation.ValidateStruct(tax, + validation.Field(&tax.Code, + validation.Required, + validation.In(validTaxCodes...), + ), + validation.Field(&tax.Rate, num.Positive), + validation.Field(&tax.Amount, num.Positive), + ) +} + +func isValidLineTotal(line *FuelAccountLine) validation.Rule { + expected := line.Quantity.Multiply(line.UnitPrice).Rescale(2) + + return validation.In(expected).Error("must be quantity x unit_price") +} + +func (comp *FuelAccountBalance) Calculate() error { + var subtotal, taxtotal num.Amount + + for _, line := range comp.Lines { + // Normalise amounts to the expected precision + line.Quantity = line.Quantity.Rescale(FuelAccountInterimPrecision) + line.UnitPrice = line.UnitPrice.Rescale(FuelAccountInterimPrecision) + line.Total = line.Total.Rescale(FuelAccountFinalPrecision) + + subtotal = line.Total.Add(subtotal) + + for _, tax := range line.Taxes { + // Normalise amounts to the expected precision + tax.Rate = tax.Rate.Rescale(FuelAccountRatePrecision) + tax.Amount = tax.Amount.Rescale(FuelAccountFinalPrecision) + + taxtotal = tax.Amount.Add(taxtotal) + } + } + + comp.Subtotal = subtotal.Rescale(FuelAccountFinalPrecision) + comp.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..95873476 --- /dev/null +++ b/regimes/mx/fuel_account_balance_test.go @@ -0,0 +1,107 @@ +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) { + comp := &mx.FuelAccountBalance{} + + err := comp.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) { + comp := &mx.FuelAccountBalance{Lines: []*mx.FuelAccountLine{{}}} + + err := comp.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(), "fuel_type: cannot be blank") + assert.Contains(t, err.Error(), "fuel_name: cannot be blank") + assert.Contains(t, err.Error(), "purchase_code: cannot be blank") + assert.Contains(t, err.Error(), "unit_price: must be greater than 0") + assert.Contains(t, err.Error(), "taxes: cannot be blank") + + comp.Lines[0].VendorTaxCode = "1234" + comp.Lines[0].Quantity = num.MakeAmount(1, 0) + comp.Lines[0].UnitPrice = num.MakeAmount(1, 0) + + err = comp.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 TestInvalidTax(t *testing.T) { + comp := &mx.FuelAccountBalance{Lines: []*mx.FuelAccountLine{ + {Taxes: []*mx.FuelAccountTax{{}}}}, + } + + err := comp.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") + + comp.Lines[0].Taxes[0].Code = "IRPF" + + err = comp.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "code: must be a valid value") +} + +func TestCalculate(t *testing.T) { + comp := &mx.FuelAccountBalance{ + Lines: []*mx.FuelAccountLine{ + { + Quantity: num.MakeAmount(11, 1), + UnitPrice: 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 := comp.Calculate() + + require.NoError(t, err) + assert.Equal(t, num.MakeAmount(20001, 2), comp.Subtotal) + assert.Equal(t, num.MakeAmount(24337, 2), comp.Total) + + assert.Equal(t, num.MakeAmount(1100, 3), comp.Lines[0].Quantity) + assert.Equal(t, num.MakeAmount(90910, 3), comp.Lines[0].UnitPrice) + assert.Equal(t, num.MakeAmount(10000, 2), comp.Lines[0].Total) + + assert.Equal(t, num.MakeAmount(160000, 6), comp.Lines[0].Taxes[0].Rate) + assert.Equal(t, num.MakeAmount(1600, 2), comp.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.