diff --git a/regimes/mx/examples/food-vouchers-complement.yaml b/regimes/mx/examples/food-vouchers-complement.yaml new file mode 100644 index 00000000..846f2080 --- /dev/null +++ b/regimes/mx/examples/food-vouchers-complement.yaml @@ -0,0 +1,56 @@ +$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/food-vouchers-complement" + employer_registration: "12345678901234567890" + account_number: "0123456789" + lines: + - e_wallet_id: "ABC1234" + issue_date_time: "2022-07-19T10:20:30" + employee: + tax_code: "JUFA7608212V6" + curp: "JUFA760821MDFRRR00" + name: "Adriana Juarez Fernández" + social_security: "12345678901" + amount: 10.123 + - e_wallet_id: "BCD4321" + issue_date_time: "2022-08-20T11:20:30" + employee: + tax_code: "KAHO641101B39" + curp: "KAHO641101HDFRRR00" + name: "Oscar Kala Haak" + amount: 20.4 diff --git a/regimes/mx/examples/out/food-vouchers-complement.json b/regimes/mx/examples/out/food-vouchers-complement.json new file mode 100644 index 00000000..4c3a5db1 --- /dev/null +++ b/regimes/mx/examples/out/food-vouchers-complement.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "91514018429e4d46deb1f689658d361ae4870ac5e5aa76133018086fb923e31b" + }, + "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/food-vouchers-complement", + "employer_registration": "12345678901234567890", + "account_number": "0123456789", + "total": "30.52", + "lines": [ + { + "e_wallet_id": "ABC1234", + "issue_date_time": "2022-07-19T10:20:30", + "employee": { + "tax_code": "JUFA7608212V6", + "curp": "JUFA760821MDFRRR00", + "name": "Adriana Juarez Fernández", + "social_security": "12345678901" + }, + "amount": "10.12" + }, + { + "e_wallet_id": "BCD4321", + "issue_date_time": "2022-08-20T11:20:30", + "employee": { + "tax_code": "KAHO641101B39", + "curp": "KAHO641101HDFRRR00", + "name": "Oscar Kala Haak" + }, + "amount": "20.40" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/regimes/mx/food_vouchers_complement.go b/regimes/mx/food_vouchers_complement.go new file mode 100644 index 00000000..a9b45667 --- /dev/null +++ b/regimes/mx/food_vouchers_complement.go @@ -0,0 +1,132 @@ +package mx + +import ( + "regexp" + + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/validation" +) + +// Constants for the precision of the complement's amounts +const ( + FoodVouchersFinalPrecision = 2 +) + +// Complement's Codes Patterns +const ( + CURPPattern = "^[A-Z][A,E,I,O,U,X][A-Z]{2}[0-9]{2}[0-1][0-9][0-3][0-9][M,H][A-Z]{2}[B,C,D,F,G,H,J,K,L,M,N,Ñ,P,Q,R,S,T,V,W,X,Y,Z]{3}[0-9,A-Z][0-9]$" + SocialSecurityPattern = "^[0-9]{11}$" +) + +// Complement's Codes Regexps +var ( + CURPRegexp = regexp.MustCompile(CURPPattern) + SocialSecurityRegexp = regexp.MustCompile(SocialSecurityPattern) +) + +// FoodVouchersComplement carries the data to produce a CFDI's "Complemento de +// Vales de Despensa" (version 1.0) providing detailed information about food +// vouchers issued by an e-wallet supplier to its customer's employees. +// +// This struct maps to the `ValesDeDespensa` root node in the CFDI's complement. +type FoodVouchersComplement struct { + // Customer's employer registration number (maps to `registroPatronal`). + EmployerRegistration string `json:"employer_registration,omitempty" jsonschema:"title=Employer Registration"` + // Customer's account number (maps to `numeroDeCuenta`). + AccountNumber string `json:"account_number" jsonschema:"title=Account Number"` + // Sum of all line amounts (calculated, maps to `total`). + Total num.Amount `json:"total" jsonschema:"title=Total" jsonschema_extras:"calculated=true"` + // List of food vouchers issued to the customer's employees (maps to `Conceptos`). + Lines []*FoodVouchersLine `json:"lines" jsonschema:"title=Lines"` +} + +// FoodVouchersLine represents a single food voucher issued to the e-wallet of +// one of the customer's employees. It maps to one `Concepto` node in the CFDI's +// complement. +type FoodVouchersLine struct { + // Identifier of the e-wallet that received the food voucher (maps to `Identificador`). + EWalletID cbc.Code `json:"e_wallet_id" jsonschema:"title=E-wallet Identifier"` + // Date and time of the food voucher's issue (maps to `Fecha`). + IssueDateTime cal.DateTime `json:"issue_date_time" jsonschema:"title=Issue Date and Time"` + // Employee that received the food voucher. + Employee *FoodVouchersEmployee `json:"employee,omitempty" jsonschema:"title=Employee"` + // Amount of the food voucher (maps to `importe`). + Amount num.Amount `json:"amount" jsonschema:"title=Amount"` +} + +// FoodVouchersEmployee represents an employee that received a food voucher. It +// groups employee related field that appears under the `Concepto` node in the +// CFDI's complement. +type FoodVouchersEmployee struct { + // Employee's tax identity code (maps to `rfc`). + TaxCode cbc.Code `json:"tax_code" jsonschema:"title=Employee's Tax Identity Code"` + // Employee's CURP ("Clave Única de Registro de Población", maps to `curp`). + CURP cbc.Code `json:"curp" jsonschema:"title=Employee's CURP"` + // Employee's name (maps to `nombre`). + Name string `json:"name" jsonschema:"title=Employee's Name"` + // Employee's Social Security Number (maps to `numSeguridadSocial`). + SocialSecurity cbc.Code `json:"social_security,omitempty" jsonschema:"title=Employee's Social Security Number"` +} + +// Validate checks the FoodVouchersComplement data according to the SAT's +// rules for the "Complemento de Vales de Despensa". +func (fvc *FoodVouchersComplement) Validate() error { + return validation.ValidateStruct(fvc, + validation.Field(&fvc.EmployerRegistration, validation.Length(0, 20)), + validation.Field(&fvc.AccountNumber, + validation.Required, + validation.Length(0, 20), + ), + validation.Field(&fvc.Total, validation.Required), + validation.Field(&fvc.Lines, validation.Required), + ) +} + +// Validate checks the FoodVouchersLine data is valid. +func (fvl *FoodVouchersLine) Validate() error { + return validation.ValidateStruct(fvl, + validation.Field(&fvl.EWalletID, + validation.Required, + validation.Length(0, 20), + ), + validation.Field(&fvl.IssueDateTime, cal.DateTimeNotZero()), + validation.Field(&fvl.Employee, validation.Required), + validation.Field(&fvl.Amount, validation.Required), + ) +} + +// Validate checks the FoodVouchersEmployee data is valid. +func (fve *FoodVouchersEmployee) Validate() error { + return validation.ValidateStruct(fve, + validation.Field(&fve.TaxCode, + validation.Required, + validation.By(validateTaxCode), + ), + validation.Field(&fve.CURP, + validation.Required, + validation.Match(CURPRegexp), + ), + validation.Field(&fve.Name, + validation.Required, + validation.Length(0, 100), + ), + validation.Field(&fve.SocialSecurity, + validation.Match(SocialSecurityRegexp), + ), + ) +} + +// Calculate performs the complement's calculations and normalisations. +func (fvc *FoodVouchersComplement) Calculate() error { + fvc.Total = num.MakeAmount(0, FoodVouchersFinalPrecision) + + for _, l := range fvc.Lines { + l.Amount = l.Amount.Rescale(FoodVouchersFinalPrecision) + + fvc.Total = fvc.Total.Add(l.Amount) + } + + return nil +} diff --git a/regimes/mx/food_vouchers_complement_test.go b/regimes/mx/food_vouchers_complement_test.go new file mode 100644 index 00000000..b3e2651b --- /dev/null +++ b/regimes/mx/food_vouchers_complement_test.go @@ -0,0 +1,84 @@ +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 TestInvalidFoodVouchersComplement(t *testing.T) { + fvc := &mx.FoodVouchersComplement{} + + err := fvc.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "account_number: cannot be blank") + assert.Contains(t, err.Error(), "lines: cannot be blank") + + fvc.EmployerRegistration = "123456789012345678901" + fvc.AccountNumber = "012345678901234567891" + + err = fvc.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "employer_registration: the length must be no more than 20") + assert.Contains(t, err.Error(), "account_number: the length must be no more than 20") +} + +func TestInvalidFoodVouchersLine(t *testing.T) { + fvc := &mx.FoodVouchersComplement{Lines: []*mx.FoodVouchersLine{{}}} + + err := fvc.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "e_wallet_id: cannot be blank") + assert.Contains(t, err.Error(), "issue_date_time: required") + assert.Contains(t, err.Error(), "employee: cannot be blank") + + fvc.Lines[0].EWalletID = "123456789012345678901" + + err = fvc.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "e_wallet_id: the length must be no more than 20") +} + +func TestInvalidFoodVouchersEmployee(t *testing.T) { + fvc := &mx.FoodVouchersComplement{Lines: []*mx.FoodVouchersLine{{Employee: &mx.FoodVouchersEmployee{}}}} + + err := fvc.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "tax_code: cannot be blank") + assert.Contains(t, err.Error(), "curp: cannot be blank") + assert.Contains(t, err.Error(), "name: cannot be blank") + + fvc.Lines[0].Employee.TaxCode = "INVALID1" + fvc.Lines[0].Employee.CURP = "INVALID2" + fvc.Lines[0].Employee.SocialSecurity = "INVALID3" + + err = fvc.Validate() + + require.Error(t, err) + assert.Contains(t, err.Error(), "tax_code: invalid tax identity code") + assert.Contains(t, err.Error(), "curp: must be in a valid format") + assert.Contains(t, err.Error(), "social_security: must be in a valid format") +} + +func TestCalculateFoodVouchersComplement(t *testing.T) { + fvc := &mx.FoodVouchersComplement{ + Lines: []*mx.FoodVouchersLine{ + {Amount: num.MakeAmount(1234, 3)}, + {Amount: num.MakeAmount(4321, 3)}, + }, + } + + err := fvc.Calculate() + + require.NoError(t, err) + assert.Equal(t, num.MakeAmount(123, 2), fvc.Lines[0].Amount) + assert.Equal(t, num.MakeAmount(555, 2), fvc.Total) +} diff --git a/regimes/mx/mx.go b/regimes/mx/mx.go index 09a88c0a..5fbd38c0 100644 --- a/regimes/mx/mx.go +++ b/regimes/mx/mx.go @@ -17,7 +17,10 @@ func init() { tax.RegisterRegime(New()) // MX GOBL Schema Complements - schema.Register(schema.GOBL.Add("regimes/mx"), FuelAccountBalance{}) + schema.Register(schema.GOBL.Add("regimes/mx"), + FuelAccountBalance{}, + FoodVouchersComplement{}, + ) } // Custom keys used typically in meta or codes information.