Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert to EstadoDeCuentaCombustible complement #15

Merged
merged 2 commits into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions cfdi.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ import (
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/num"
"github.com/invopop/gobl/regimes/mx"
"github.com/invopop/gobl/schema"
"github.com/invopop/gobl/tax"
)

// CFDI schema constants
const (
CFDINamespace = "http://www.sat.gob.mx/cfd/4"
XSINamespace = "http://www.w3.org/2001/XMLSchema-instance"
SchemaLocation = "http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd"
CFDIVersion = "4.0"
CFDINamespace = "http://www.sat.gob.mx/cfd/4"
CFDISchemaLocation = "http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd"
XSINamespace = "http://www.w3.org/2001/XMLSchema-instance"
CFDIVersion = "4.0"
)

// Hard-coded values for (yet) unsupported mappings
Expand All @@ -38,6 +39,7 @@ type Document struct {
XMLName xml.Name `xml:"cfdi:Comprobante"`
CFDINamespace string `xml:"xmlns:cfdi,attr"`
XSINamespace string `xml:"xmlns:xsi,attr"`
ECCNamespace string `xml:"xmlns:ecc12,attr,omitempty"`
SchemaLocation string `xml:"xsi:schemaLocation,attr"`
Version string `xml:"Version,attr"`

Expand All @@ -63,6 +65,8 @@ type Document struct {
Receptor *Receptor `xml:"cfdi:Receptor"`
Conceptos *Conceptos `xml:"cfdi:Conceptos"` //nolint:misspell
Impuestos *Impuestos `xml:"cfdi:Impuestos,omitempty"`

Complementos []interface{} `xml:"cfdi:Complemento>*,omitempty"`
}

// NewDocument converts a GOBL envelope into a CFDI document
Expand All @@ -78,7 +82,7 @@ func NewDocument(env *gobl.Envelope) (*Document, error) {
document := &Document{
CFDINamespace: CFDINamespace,
XSINamespace: XSINamespace,
SchemaLocation: SchemaLocation,
SchemaLocation: formatSchemaLocation(CFDINamespace, CFDISchemaLocation),
Version: CFDIVersion,

TipoDeComprobante: lookupTipoDeComprobante(inv),
Expand All @@ -104,6 +108,10 @@ func NewDocument(env *gobl.Envelope) (*Document, error) {
Impuestos: newImpuestos(inv.Totals, &inv.Currency),
}

if err := addComplementos(document, inv.Complements); err != nil {
return nil, err
}

return document, nil
}

Expand All @@ -117,6 +125,19 @@ func (d *Document) Bytes() ([]byte, error) {
return append([]byte(xml.Header), bytes...), nil
}

func addComplementos(d *Document, complements []*schema.Object) error {
for _, c := range complements {
switch o := c.Instance().(type) {
case *mx.FuelAccountBalance:
addEstadoCuentaCombustible(d, o)
default:
return fmt.Errorf("unsupported complement %T", o)
}
}

return nil
}

func formatIssueDate(date cal.Date) string {
dateTime := civil.DateTime{Date: date.Date, Time: civil.Time{}}
return dateTime.String()
Expand Down Expand Up @@ -173,6 +194,10 @@ func formatOptionalAmount(a num.Amount) string {
return a.String()
}

func formatSchemaLocation(namespace, schemaLocation string) string {
return fmt.Sprintf("%s %s", namespace, schemaLocation)
}

func totalInvoiceDiscount(i *bill.Invoice) num.Amount {
td := i.Currency.Def().Zero() // currency's precision is required by the SAT
for _, l := range i.Lines {
Expand Down
40 changes: 2 additions & 38 deletions cfdi_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
package cfdi_test

import (
"fmt"
"path/filepath"
"testing"

"github.com/invopop/gobl.cfdi/test"
"github.com/lestrrat-go/libxml2"
"github.com/lestrrat-go/libxml2/xsd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -35,6 +31,8 @@ func TestComprobanteIngreso(t *testing.T) {
assert.Equal(t, "PUE", doc.MetodoPago)
assert.Equal(t, "03", doc.FormaPago)
assert.Equal(t, "Pago a 30 días.", doc.CondicionesDePago)

assert.Equal(t, 0, len(doc.Complementos))
})
}

Expand All @@ -48,37 +46,3 @@ func TestComprobanteEgreso(t *testing.T) {
assert.Equal(t, "0003", doc.Folio)
})
}

func TestXMLGeneration(t *testing.T) {
schemaPath := filepath.Join(test.GetTestPath(), "schema", "cfdv40.xsd")
schema, err := xsd.ParseFromFile(schemaPath)
defer schema.Free()
require.NoError(t, err)

tests := []string{
"bare-minimum-invoice.json",
"invoice.json",
"credit-note.json",
}

for _, testFile := range tests {
name := fmt.Sprintf("should generate a schema-valid XML from %s", testFile)
t.Run(name, func(t *testing.T) {
doc, err := test.NewDocumentFrom(testFile)
require.NoError(t, err)

data, err := doc.Bytes()
require.NoError(t, err)

xmlDoc, err := libxml2.ParseString(string(data))
require.NoError(t, err)

err = schema.Validate(xmlDoc)
if err != nil {
for _, e := range err.(xsd.SchemaValidationError).Errors() {
require.NoError(t, e)
}
}
})
}
}
105 changes: 105 additions & 0 deletions examples_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package cfdi_test

import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/invopop/gobl.cfdi/test"
"github.com/lestrrat-go/libxml2"
"github.com/lestrrat-go/libxml2/xsd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var updateOut = flag.Bool("update", false, "Update the XML files in the test/data/out directory")

func TestXMLGeneration(t *testing.T) {

schema, err := loadSchema()
require.NoError(t, err)

examples, err := lookupExamples()
require.NoError(t, err)

for _, example := range examples {
name := fmt.Sprintf("should convert %s example file successfully", example)

t.Run(name, func(t *testing.T) {
data, err := convertExample(example)
require.NoError(t, err)

outPath := filepath.Join(test.GetDataPath(), "out", strings.TrimSuffix(example, ".json")+".xml")

if *updateOut {
errs := validateDoc(schema, data)
for _, e := range errs {
assert.NoError(t, e)
}
if len(errs) > 0 {
return
}

err = os.WriteFile(outPath, data, 0644)
require.NoError(t, err)

return
}

expected, err := os.ReadFile(outPath)

require.False(t, os.IsNotExist(err), "output file %s missing, run tests with `--update` flag to create", filepath.Base(outPath))
require.NoError(t, err)
require.Equal(t, string(expected), string(data), "output file %s does not match, run tests with `--update` flag to update", filepath.Base(outPath))
})
}
}

func loadSchema() (*xsd.Schema, error) {
schemaPath := filepath.Join(test.GetTestPath(), "schema", "schema.xsd")
schema, err := xsd.ParseFromFile(schemaPath)
if err != nil {
return nil, err
}

return schema, nil
}

func lookupExamples() ([]string, error) {
examples, err := filepath.Glob(filepath.Join(test.GetDataPath(), "*.json"))
if err != nil {
return nil, err
}

for i, example := range examples {
examples[i] = filepath.Base(example)
}

return examples, nil
}

func convertExample(example string) ([]byte, error) {
doc, err := test.NewDocumentFrom(example)
if err != nil {
return nil, err
}

return doc.Bytes()
}

func validateDoc(schema *xsd.Schema, doc []byte) []error {
xmlDoc, err := libxml2.ParseString(string(doc))
if err != nil {
return []error{err}
}

err = schema.Validate(xmlDoc)
if err != nil {
return err.(xsd.SchemaValidationError).Errors()
}

return nil
}
107 changes: 107 additions & 0 deletions fuel_account_balance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package cfdi

import (
"encoding/xml"

"github.com/invopop/gobl/regimes/mx"
)

// ECC Schema constants
const (
ECCVersion = "1.2"
ECCTipoOperacion = "Tarjeta"
ECCNamespace = "http://www.sat.gob.mx/EstadoDeCuentaCombustible12"
ECCSchemaLocation = "http://www.sat.gob.mx/sitio_internet/cfd/EstadoDeCuentaCombustible/ecc12.xsd"
)

// EstadoDeCuentaCombustible stores the fuel account balance data
type EstadoDeCuentaCombustible struct {
XMLName xml.Name `xml:"ecc12:EstadoDeCuentaCombustible"`
Version string `xml:",attr"`
TipoOperacion string `xml:",attr"`

NumeroDeCuenta string `xml:",attr"`
SubTotal string `xml:",attr"`
Total string `xml:",attr"`

Conceptos []*ECCConcepto `xml:"ecc12:Conceptos>ecc12:ConceptoEstadoDeCuentaCombustible"` // nolint:misspell
}

// ECCConcepto stores the data of a fuel purchase
type ECCConcepto struct {
Identificador string `xml:",attr"`
Fecha string `xml:",attr"`
Rfc string `xml:",attr"`
ClaveEstacion string `xml:",attr"`
Cantidad string `xml:",attr"`
TipoCombustible string `xml:",attr"`
Unidad string `xml:",attr,omitempty"`
NombreCombustible string `xml:",attr"`
FolioOperacion string `xml:",attr"`
ValorUnitario string `xml:",attr"`
Importe string `xml:",attr"`

Traslados []*ECCTraslado `xml:"ecc12:Traslados>ecc12:Traslado"`
}

// ECCTraslado stores the tax data of a fuel purchase
type ECCTraslado struct {
Impuesto string `xml:",attr"`
TasaOCuota string `xml:",attr"`
Importe string `xml:",attr"`
}

func addEstadoCuentaCombustible(doc *Document, fc *mx.FuelAccountBalance) {
ecc := &EstadoDeCuentaCombustible{
Version: ECCVersion,
TipoOperacion: ECCTipoOperacion,

NumeroDeCuenta: fc.AccountNumber,
SubTotal: fc.Subtotal.String(),
Total: fc.Total.String(),

Conceptos: newECCConceptos(fc.Lines), // nolint:misspell
}

doc.ECCNamespace = ECCNamespace
doc.SchemaLocation = doc.SchemaLocation + " " + formatSchemaLocation(ECCNamespace, ECCSchemaLocation)
doc.Complementos = append(doc.Complementos, ecc)
}

// nolint:misspell
func newECCConceptos(lines []*mx.FuelAccountLine) []*ECCConcepto {
cs := make([]*ECCConcepto, len(lines))

for i, l := range lines {
cs[i] = &ECCConcepto{
Identificador: l.EWalletID.String(),
Fecha: l.PurchaseDateTime.String(),
Rfc: l.VendorTaxCode.String(),
ClaveEstacion: l.ServiceStationCode.String(),
Cantidad: l.Quantity.String(),
TipoCombustible: l.Item.Type.String(),
Unidad: l.Item.Unit.UNECE().String(),
NombreCombustible: l.Item.Name,
FolioOperacion: l.PurchaseCode.String(),
ValorUnitario: l.Item.Price.String(),
Importe: l.Total.String(),
Traslados: newECCTraslados(l.Taxes),
}
}

return cs
}

func newECCTraslados(taxes []*mx.FuelAccountTax) []*ECCTraslado {
ts := make([]*ECCTraslado, len(taxes))

for i, t := range taxes {
ts[i] = &ECCTraslado{
Impuesto: t.Code.String(),
TasaOCuota: t.Rate.String(),
Importe: t.Amount.String(),
}
}

return ts
}
Loading
Loading