From 8f2bedc630495e0bc68720cfb07609523a338845 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 4 Nov 2024 12:37:08 +0000 Subject: [PATCH 01/56] Initial Structure --- .golangci.yaml | 30 ++++++++++++++ cmd/convert.go | 74 +++++++++++++++++++++++++++++++++ cmd/main.go | 37 +++++++++++++++++ cmd/root.go | 56 +++++++++++++++++++++++++ cmd/version.go | 29 +++++++++++++ go.mod | 36 ++++++++++++++++ go.sum | 77 ++++++++++++++++++++++++++++++++++ verifactu.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 449 insertions(+) create mode 100644 .golangci.yaml create mode 100644 cmd/convert.go create mode 100644 cmd/main.go create mode 100644 cmd/root.go create mode 100644 cmd/version.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 verifactu.go diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..1925251 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,30 @@ +run: + timeout: "120s" + +output: + formats: + - format: "colored-line-number" + +linters: + enable: + - "gocyclo" + - "unconvert" + - "goimports" + - "govet" + #- "misspell" # doesn't handle multilanguage well + - "nakedret" + - "revive" + - "goconst" + - "unparam" + - "gofmt" + - "errname" + - "zerologlint" + +linters-settings: + staticcheck: + # SAxxxx checks in https://staticcheck.io/docs/configuration/options/#checks + # Default: ["*"] + checks: ["all"] + +issues: + exclude-use-default: false diff --git a/cmd/convert.go b/cmd/convert.go new file mode 100644 index 0000000..44dbd2d --- /dev/null +++ b/cmd/convert.go @@ -0,0 +1,74 @@ +// Package main provides the command line interface to the VeriFactu package. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/invopop/gobl" + "github.com/spf13/cobra" +) + +type convertOpts struct { + *rootOpts +} + +func convert(o *rootOpts) *convertOpts { + return &convertOpts{rootOpts: o} +} + +func (c *convertOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "convert [infile] [outfile]", + Short: "Convert a GOBL JSON into a TickeBAI XML", + RunE: c.runE, + } + + return cmd +} + +func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer input.Close() // nolint:errcheck + + out, err := c.openOutput(cmd, args) + if err != nil { + return err + } + defer out.Close() // nolint:errcheck + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(input); err != nil { + return fmt.Errorf("reading input: %w", err) + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(buf.Bytes(), env); err != nil { + return fmt.Errorf("unmarshaling gobl envelope: %w", err) + } + + // tbai, err := ticketbai.New(&ticketbai.Software{}) + // if err != nil { + // return fmt.Errorf("creating ticketbai client: %w", err) + // } + + // doc, err := tbai.NewDocument(env) + // if err != nil { + // panic(err) + // } + + // data, err := doc.BytesIndent() + // if err != nil { + // return fmt.Errorf("generating ticketbai xml: %w", err) + // } + + // if _, err = out.Write(append(data, '\n')); err != nil { + // return fmt.Errorf("writing ticketbai xml: %w", err) + // } + + return nil +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..b3536e4 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,37 @@ +// Package main is the entry point for the gobl.verifactu command. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" +) + +// build data provided by goreleaser and mage setup +var ( + version = "dev" + date = "" +) + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + return root().cmd().ExecuteContext(ctx) +} + +func inputFilename(args []string) string { + if len(args) > 0 && args[0] != "-" { + return args[0] + } + return "" +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..a11aed6 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,56 @@ +package main + +import ( + "io" + "os" + + "github.com/spf13/cobra" +) + +type rootOpts struct { +} + +func root() *rootOpts { + return &rootOpts{} +} + +func (o *rootOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "gobl.verifactu", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand(versionCmd()) + cmd.AddCommand(convert(o).cmd()) + + return cmd +} + +func (o *rootOpts) outputFilename(args []string) string { + if len(args) >= 2 && args[1] != "-" { + return args[1] + } + return "" +} + +func openInput(cmd *cobra.Command, args []string) (io.ReadCloser, error) { + if inFile := inputFilename(args); inFile != "" { + return os.Open(inFile) + } + return io.NopCloser(cmd.InOrStdin()), nil +} + +func (o *rootOpts) openOutput(cmd *cobra.Command, args []string) (io.WriteCloser, error) { + if outFile := o.outputFilename(args); outFile != "" { + flags := os.O_CREATE | os.O_WRONLY + return os.OpenFile(outFile, flags, os.ModePerm) + } + return writeCloser{cmd.OutOrStdout()}, nil +} + +type writeCloser struct { + io.Writer +} + +func (writeCloser) Close() error { return nil } diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..31d11ab --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,29 @@ +package main + +import ( + "encoding/json" + + "github.com/invopop/gobl" + "github.com/spf13/cobra" +) + +var versionOutput = struct { + Version string `json:"version"` + GOBL string `json:"gobl"` + Date string `json:"date,omitempty"` +}{ + Version: version, + GOBL: string(gobl.VERSION), + Date: date, +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + RunE: func(cmd *cobra.Command, _ []string) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", "\t") // always indent version + return enc.Encode(versionOutput) + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e8735e --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module github.com/invopop/gobl.verifactu + +go 1.22 + +toolchain go1.22.1 + +// replace github.com/invopop/gobl => ../gobl + +require ( + github.com/invopop/gobl v0.204.0 + github.com/invopop/xmldsig v0.10.0 + github.com/spf13/cobra v1.8.1 +) + +require ( + cloud.google.com/go v0.110.2 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beevik/etree v1.4.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.12.0 // indirect + github.com/invopop/validation v0.7.0 // indirect + github.com/invopop/yaml v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/magefile/mage v1.15.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + golang.org/x/crypto v0.26.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a9d58b --- /dev/null +++ b/go.sum @@ -0,0 +1,77 @@ +cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= +cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beevik/etree v1.4.0 h1:oz1UedHRepuY3p4N5OjE0nK1WLCqtzHf25bxplKOHLs= +github.com/beevik/etree v1.4.0/go.mod h1:cyWiXwGoasx60gHvtnEh5x8+uIjUVnjWqBvEnhnqKDA= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/gobl v0.204.0 h1:xvpq2EtRgc3eQ/rbWjWAKtTXc9OX02NVumTSvEk3U7g= +github.com/invopop/gobl v0.204.0/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= +github.com/invopop/validation v0.7.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg= +github.com/invopop/xmldsig v0.10.0 h1:pPKX4dLWi4GEQQVs1dXbH3EW/Thm/5Bf9db1nYqfoUQ= +github.com/invopop/xmldsig v0.10.0/go.mod h1:YBUFOoEo8mJ0B6ssQzReE2EICznYdxwyY6Y/LAn2/Z0= +github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= +github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 h1:igWZJluD8KtEtAgRyF4x6lqcxDry1ULztksMJh2mnQE= +github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21/go.mod h1:RMRJLmBOqWacUkmJHRMiPKh1S1m3PA7Zh4W80/kWPpg= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 h1:wD1IWQwAhdWclCwaf6DdzgCAe9Bfz1M+4AHRd7N786Y= +github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693/go.mod h1:6hSY48PjDm4UObWmGLyJE9DxYVKTgR9kbCspXXJEhcU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/verifactu.go b/verifactu.go new file mode 100644 index 0000000..1483000 --- /dev/null +++ b/verifactu.go @@ -0,0 +1,110 @@ +package verifactu + +import ( + "errors" + "time" + + "github.com/invopop/gobl/l10n" + "github.com/invopop/xmldsig" +) + +// Standard error responses. +var ( + ErrNotSpanish = newValidationError("only spanish invoices are supported") + ErrAlreadyProcessed = newValidationError("already processed") + ErrOnlyInvoices = newValidationError("only invoices are supported") + ErrInvalidZone = newValidationError("invalid zone") +) + +// ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed +// to server-side errors (that should be retried). +type ValidationError struct { + err error +} + +// Error implements the error interface for ClientError. +func (e *ValidationError) Error() string { + return e.err.Error() +} + +func newValidationError(text string) error { + return &ValidationError{errors.New(text)} +} + +// Client provides the main interface to the TicketBAI package. +type Client struct { + software *Software + // list *gateways.List + cert *xmldsig.Certificate + // env gateways.Environment + // issuerRole doc.IssuerRole + curTime time.Time + zone l10n.Code +} + +// Option is used to configure the client. +type Option func(*Client) + +// WithCurrentTime defines the current time to use when generating the TicketBAI +// document. Useful for testing. +func WithCurrentTime(curTime time.Time) Option { + return func(c *Client) { + c.curTime = curTime + } +} + +// Software defines the details about the software that is using this library to +// generate TicketBAI documents. These details are included in the final +// document. +type Software struct { + License string + NIF string + Name string + CompanyName string + Version string +} + +// PreviousInvoice stores the fields from the previously generated invoice +// document that are linked to in the new document. +type PreviousInvoice struct { + Series string + Code string + IssueDate string + Signature string +} + +// New creates a new TicketBAI client with shared software and configuration +// options for creating and sending new documents. +func New(software *Software, opts ...Option) (*Client, error) { + c := new(Client) + c.software = software + + // Set default values that can be overwritten by the options + // c.env = gateways.EnvironmentTesting + // c.issuerRole = doc.IssuerRoleSupplier + + // for _, opt := range opts { + // opt(c) + // } + + // // Create a new gateway list if none was created by the options + // if c.list == nil && c.cert != nil { + // list, err := gateways.New(c.env, c.cert) + // if err != nil { + // return nil, fmt.Errorf("creating gateway list: %w", err) + // } + + // c.list = list + // } + + return c, nil +} + +// CurrentTime returns the current time to use when generating +// the TicketBAI document. +func (c *Client) CurrentTime() time.Time { + if !c.curTime.IsZero() { + return c.curTime + } + return time.Now() +} From 0e462a8d8e5a1952effda76c6d6523cec68eedb3 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 4 Nov 2024 16:58:08 +0000 Subject: [PATCH 02/56] URL Code Generation and Invoice Structure --- document.go | 15 + internal/doc/doc.go | 26 + internal/doc/hash.go | 1 + internal/doc/header.go | 23 + internal/doc/invoice.go | 106 ++ internal/doc/qr_code.go | 49 + test/schema/EventosSIF.xsd | 823 +++++++++++++ test/schema/RespuestaSuministro.xsd | 139 +++ test/schema/SistemaFacturacion.wsdl.xml | 89 ++ test/schema/SuministroInformacion.xsd | 1399 +++++++++++++++++++++++ test/schema/SuministroLR.xsd | 25 + verifactu.go | 24 +- 12 files changed, 2702 insertions(+), 17 deletions(-) create mode 100644 document.go create mode 100644 internal/doc/doc.go create mode 100644 internal/doc/hash.go create mode 100644 internal/doc/header.go create mode 100644 internal/doc/invoice.go create mode 100644 internal/doc/qr_code.go create mode 100644 test/schema/EventosSIF.xsd create mode 100644 test/schema/RespuestaSuministro.xsd create mode 100644 test/schema/SistemaFacturacion.wsdl.xml create mode 100644 test/schema/SuministroInformacion.xsd create mode 100644 test/schema/SuministroLR.xsd diff --git a/document.go b/document.go new file mode 100644 index 0000000..96d8053 --- /dev/null +++ b/document.go @@ -0,0 +1,15 @@ +package verifactu + +import ( + "github.com/invopop/gobl" + "github.com/invopop/gobl.verifactu/internal/doc" + "github.com/invopop/gobl/bill" +) + +// Document is a wrapper around the internal TicketBAI document. +type Document struct { + env *gobl.Envelope + inv *bill.Invoice + vf *doc.VeriFactu + client *Client +} diff --git a/internal/doc/doc.go b/internal/doc/doc.go new file mode 100644 index 0000000..7cf4c9d --- /dev/null +++ b/internal/doc/doc.go @@ -0,0 +1,26 @@ +package doc + +type VeriFactu struct { + Cabecera *Cabecera + RegistroFactura []*RegistroFactura +} + +type RegistroFactura struct { + RegistroAlta *RegistroAlta + RegistroAnulacion *RegistroAnulacion +} + +type RegistroAnulacion struct { +} + +type Software struct { + NombreRazon string + NIF string + IdSistemaInformatico string + NombreSistemaInformatico string + NumeroInstalacion string + TipoUsoPosibleSoloVerifactu string + TipoUsoPosibleMultiOT string + IndicadorMultiplesOT string + Version string +} diff --git a/internal/doc/hash.go b/internal/doc/hash.go new file mode 100644 index 0000000..1025707 --- /dev/null +++ b/internal/doc/hash.go @@ -0,0 +1 @@ +package doc diff --git a/internal/doc/header.go b/internal/doc/header.go new file mode 100644 index 0000000..a6ba6f3 --- /dev/null +++ b/internal/doc/header.go @@ -0,0 +1,23 @@ +package doc + +type Cabecera struct { + Obligado Obligado + Representante *Obligado + RemisionVoluntaria *RemisionVoluntaria + RemisionRequerimiento *RemisionRequerimiento +} + +type Obligado struct { + NombreRazon string + NIF string +} + +type RemisionVoluntaria struct { + FechaFinVerifactu string + Incidencia string +} + +type RemisionRequerimiento struct { + RefRequerimiento string + FinRequerimiento string +} diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go new file mode 100644 index 0000000..3867b2e --- /dev/null +++ b/internal/doc/invoice.go @@ -0,0 +1,106 @@ +package doc + +type RegistroAlta struct { + IDVersion string `xml:"IDVersion"` + IDFactura IDFactura `xml:"IDFactura"` + RefExterna string `xml:"RefExterna,omitempty"` + NombreRazonEmisor string `xml:"NombreRazonEmisor"` + Subsanacion string `xml:"Subsanacion,omitempty"` + RechazoPrevio string `xml:"RechazoPrevio,omitempty"` + TipoFactura string `xml:"TipoFactura"` + TipoRectificativa string `xml:"TipoRectificativa,omitempty"` + FacturasRectificadas []*FacturaRectificada `xml:"FacturasRectificadas>FacturaRectificada,omitempty"` + FacturasSustituidas []*FacturaSustituida `xml:"FacturasSustituidas>FacturaSustituida,omitempty"` + ImporteRectificacion ImporteRectificacion `xml:"ImporteRectificacion,omitempty"` + FechaOperacion string `xml:"FechaOperacion"` + DescripcionOperacion string `xml:"DescripcionOperacion"` + FacturaSimplificadaArt7273 string `xml:"FacturaSimplificadaArt7273,omitempty"` + FacturaSinIdentifDestinatarioArt61d string `xml:"FacturaSinIdentifDestinatarioArt61d,omitempty"` + Macrodato string `xml:"Macrodato,omitempty"` + EmitidaPorTerceroODestinatario string `xml:"EmitidaPorTerceroODestinatario,omitempty"` + Tercero Tercero `xml:"Tercero,omitempty"` + Destinatarios []*Destinatario `xml:"Destinatarios>Destinatario,omitempty"` + Cupon string `xml:"Cupon,omitempty"` + Desglose Desglose `xml:"Desglose"` + CuotaTotal float64 `xml:"CuotaTotal"` + ImporteTotal float64 `xml:"ImporteTotal"` + Encadenamiento Encadenamiento `xml:"Encadenamiento"` + SistemaInformatico Software `xml:"SistemaInformatico"` + FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` + NumRegistroAcuerdoFacturacion string `xml:"NumRegistroAcuerdoFacturacion,omitempty"` + IdAcuerdoSistemaInformatico string `xml:"IdAcuerdoSistemaInformatico,omitempty"` + TipoHuella string `xml:"TipoHuella"` + Huella string `xml:"Huella"` + Signature string `xml:"Signature"` +} + +type IDFactura struct { + IDEmisorFactura string `xml:"IDEmisorFactura"` + NumSerieFactura string `xml:"NumSerieFactura"` + FechaExpedicionFactura string `xml:"FechaExpedicionFactura"` +} + +type FacturaRectificada struct { + IDFactura IDFactura `xml:"IDFactura"` +} + +type FacturaSustituida struct { + IDFactura IDFactura `xml:"IDFactura"` +} + +type ImporteRectificacion struct { + BaseRectificada float64 `xml:"BaseRectificada"` + CuotaRectificada float64 `xml:"CuotaRectificada"` + CuotaRecargoRectificado float64 `xml:"CuotaRecargoRectificado"` +} + +type Tercero struct { + Nif string `xml:"Nif,omitempty"` + NombreRazon string `xml:"NombreRazon"` + IDOtro string `xml:"IDOtro,omitempty"` +} + +type Destinatario struct { + IDDestinatario IDDestinatario `xml:"IDDestinatario"` +} + +type IDDestinatario struct { + NIF string `xml:"NIF,omitempty"` + NombreRazon string `xml:"NombreRazon"` + IDOtro IDOtro `xml:"IDOtro,omitempty"` +} + +type IDOtro struct { + CodigoPais string `xml:"CodigoPais"` + IDType string `xml:"IDType"` + ID string `xml:"ID"` +} + +type Desglose struct { + DetalleDesglose []*DetalleDesglose `xml:"DetalleDesglose"` +} + +type DetalleDesglose struct { + Impuesto string `xml:"Impuesto"` + ClaveRegimen string `xml:"ClaveRegimen"` + CalificacionOperacion string `xml:"CalificacionOperacion,omitempty"` + OperacionExenta string `xml:"OperacionExenta,omitempty"` + TipoImpositivo string `xml:"TipoImpositivo,omitempty"` + BaseImponibleOImporteNoSujeto string `xml:"BaseImponibleOImporteNoSujeto"` + BaseImponibleACoste string `xml:"BaseImponibleACoste,omitempty"` + CuotaRepercutida string `xml:"CuotaRepercutida,omitempty"` + TipoRecargoEquivalencia string `xml:"TipoRecargoEquivalencia,omitempty"` + CuotaRecargoEquivalencia string `xml:"CuotaRecargoEquivalencia,omitempty"` +} + +type Encadenamiento struct { + PrimerRegistro string `xml:"PrimerRegistro"` + RegistroAnterior RegistroAnterior `xml:"RegistroAnterior,omitempty"` +} + +type RegistroAnterior struct { + IDEmisorFactura string `xml:"IDEmisorFactura"` + NumSerieFactura string `xml:"NumSerieFactura"` + FechaExpedicionFactura string `xml:"FechaExpedicionFactura"` + Huella string `xml:"Huella"` +} diff --git a/internal/doc/qr_code.go b/internal/doc/qr_code.go new file mode 100644 index 0000000..3ff6e00 --- /dev/null +++ b/internal/doc/qr_code.go @@ -0,0 +1,49 @@ +package doc + +import ( + "fmt" + "net/url" + // "github.com/sigurn/crc8" +) + +// Codes contain info about the codes that should be generated and shown on a +// Ticketbai invoice. One is an alphanumeric code that identifies the invoice +// and the other one is a URL (which can be used by a customer to validate that +// the invoice has been sent to the tax agency) that should be encoded as a +// QR code in the printed invoice / ticket. +type Codes struct { + URLCode string + QRCode string +} + +const ( + BaseURL = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?" +) + +// var crcTable = crc8.MakeTable(crc8.CRC8) + +// generateCodes will generate the QR and URL codes for the invoice +func (doc *VeriFactu) generateCodes(inv *RegistroAlta) *Codes { + urlCode := doc.generateURLCode(inv) + // qrCode := doc.generateQRCode(urlCode) + + return &Codes{ + URLCode: urlCode, + // QRCode: qrCode, + } +} + +// generateURLCode generates the encoded URL code with parameters. +func (doc *VeriFactu) generateURLCode(inv *RegistroAlta) string { + // URL encode each parameter + nif := url.QueryEscape(doc.Cabecera.Obligado.NIF) + numSerie := url.QueryEscape(inv.IDFactura.NumSerieFactura) + fecha := url.QueryEscape(inv.IDFactura.FechaExpedicionFactura) + importe := url.QueryEscape(fmt.Sprintf("%.2f", inv.ImporteTotal)) + + // Build the URL + urlCode := fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", + BaseURL, nif, numSerie, fecha, importe) + + return urlCode +} diff --git a/test/schema/EventosSIF.xsd b/test/schema/EventosSIF.xsd new file mode 100644 index 0000000..21cb370 --- /dev/null +++ b/test/schema/EventosSIF.xsd @@ -0,0 +1,823 @@ + + + + + + + + + + + + + + + + + + + Obligado a expedir la factura. + + + + + + + Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601) + + + + + Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601) + + + + + + + + + + + + + + + + Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601) + + + + + Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601) + + + + + + + + + + + + + + + + + + + + + + + + + Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601) + + + + + + + + + + + Formato: YYYY-MM-DDThh:mm:ssTZD (ej: 2024-01-01T19:20:30+01:00) (ISO 8601) + + + + + + + + + + + + + + + Datos de una persona física o jurídica Española con un NIF asociado + + + + + + + + + NIF + + + + + + + + + Datos de una persona física o jurídica Española o Extranjera + + + + + + + + + + + + + Identificador de persona Física o jurídica distinto del NIF + (Código pais, Tipo de Identificador, y hasta 15 caractéres) + No se permite CodigoPais=ES e IDType=01-NIFContraparte + para ese caso, debe utilizarse NIF en lugar de IDOtro. + + + + + + + + + + + + + + Destinatario + + + + + Tercero + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NIF-IVA + + + + + Pasaporte + + + + + IDEnPaisResidencia + + + + + Certificado Residencia + + + + + Otro documento Probatorio + + + + + No Censado + + + + + + + + + + SHA-256 + + + + + + + + + Inicio del funcionamiento del sistema informático como «NO VERI*FACTU». + + + + + Fin del funcionamiento del sistema informático como «NO VERI*FACTU». + + + + + Lanzamiento del proceso de detección de anomalías en los registros de facturación. + + + + + Detección de anomalías en la integridad, inalterabilidad y trazabilidad de registros de facturación. + + + + + Lanzamiento del proceso de detección de anomalías en los registros de evento. + + + + + Detección de anomalías en la integridad, inalterabilidad y trazabilidad de registros de evento. + + + + + Restauración de copia de seguridad, cuando ésta se gestione desde el propio sistema informático de facturación. + + + + + Exportación de registros de facturación generados en un periodo. + + + + + Exportación de registros de evento generados en un periodo. + + + + + Registro resumen de eventos + + + + + Otros tipos de eventos a registrar voluntariamente por la persona o entidad productora del sistema informático. + + + + + + + + + + Integridad-huella + + + + + Integridad-firma + + + + + Integridad - Otros + + + + + Trazabilidad-cadena-registro - Reg. no primero pero con reg. anterior no anotado o inexistente + + + + + Trazabilidad-cadena-registro - Reg. no último pero con reg. posterior no anotado o inexistente + + + + + Trazabilidad-cadena-registro - Otros + + + + + Trazabilidad-cadena-huella - Huella del reg. no se corresponde con la 'huella del reg. anterior' almacenada en el registro posterior + + + + + Trazabilidad-cadena-huella - Campo 'huella del reg. anterior' no se corresponde con la huella del reg. anterior + + + + + Trazabilidad-cadena-huella - Otros + + + + + Trazabilidad-cadena - Otros + + + + + Trazabilidad-fechas - Fecha-hora anterior a la fecha del reg. anterior + + + + + Trazabilidad-fechas - Fecha-hora posterior a la fecha del reg. posterior + + + + + Trazabilidad-fechas - Reg. con fecha-hora de generación posterior a la fecha-hora actual del sistema + + + + + Trazabilidad-fechas - Otros + + + + + Trazabilidad - Otros + + + + + Otros + + + + + + + Datos de identificación de factura expedida para operaciones de consulta + + + + + + + + + + Datos de encadenamiento + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/schema/RespuestaSuministro.xsd b/test/schema/RespuestaSuministro.xsd new file mode 100644 index 0000000..2dc97e4 --- /dev/null +++ b/test/schema/RespuestaSuministro.xsd @@ -0,0 +1,139 @@ + + + + + + + + + + + + CSV asociado al envío generado por AEAT. Solo se genera si no hay rechazo del envio + + + + + Se devuelven datos de la presentacion realizada. Solo se genera si no hay rechazo del envio + + + + + Se devuelve la cabecera que se incluyó en el envío. + + + + + + + Estado del envío en conjunto. + Si los datos de cabecera y todos los registros son correctos,el estado es correcto. + En caso de estructura y cabecera correctos donde todos los registros son incorrectos, el estado es incorrecto + En caso de estructura y cabecera correctos con al menos un registro incorrecto, el estado global es parcialmente correcto. + + + + + + + + Respuesta a un envío de registro de facturacion + + + + + + + + Estado detallado de cada línea del suministro. + + + + + + + + + + Respuesta a un envío + + + + + ID Factura Expedida + + + + + + + + Estado del registro. Correcto o Incorrecto + + + + + + + Código del error de registro, en su caso. + + + + + + + Descripción detallada del error de registro, en su caso. + + + + + + + Solo en el caso de que se rechace el registro por duplicado se devuelve este nodo con la informacion registrada en el sistema para este registro + + + + + + + + + + Correcto + + + + + Parcialmente correcto. Ver detalle de errores + + + + + Incorrecto + + + + + + + + + Correcto + + + + + Aceptado con Errores. Ver detalle del error + + + + + Incorrecto + + + + + + + + diff --git a/test/schema/SistemaFacturacion.wsdl.xml b/test/schema/SistemaFacturacion.wsdl.xml new file mode 100644 index 0000000..2c9e164 --- /dev/null +++ b/test/schema/SistemaFacturacion.wsdl.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/schema/SuministroInformacion.xsd b/test/schema/SuministroInformacion.xsd new file mode 100644 index 0000000..b6d14b5 --- /dev/null +++ b/test/schema/SuministroInformacion.xsd @@ -0,0 +1,1399 @@ + + + + + + Datos de cabecera + + + + + Obligado a expedir la factura. + + + + + Representante del obligado tributario. A rellenar solo en caso de que los registros de facturación remitdos hayan sido generados por un representante/asesor del obligado tributario. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Información básica que contienen los registros del sistema de facturacion + + + + + + Período de la fecha de la operación + + + + + + + + + + + + Datos de identificación de factura expedida para operaciones de consulta + + + + + + Nº Serie+Nº Factura de la Factura del Emisor. + + + + + Fecha de emisión de la factura + + + + + + + Datos de identificación de factura que se anula para operaciones de baja + + + + + NIF del obligado + + + + + Nº Serie+Nº Factura de la Factura que se anula. + + + + + Fecha de emisión de la factura que se anula + + + + + + + Datos correspondientes al registro de facturacion de alta + + + + + + + + + + + Clave del tipo de factura + + + + + Identifica si el tipo de factura rectificativa es por sustitución o por diferencia + + + + + + El ID de las facturas rectificadas, únicamente se rellena en el caso de rectificación de facturas + + + + + + + + + + El ID de las facturas sustituidas, únicamente se rellena en el caso de facturas sustituidas + + + + + + + + + + + + + + + + Tercero que expida la factura y/o genera el registro de alta. + + + + + + Contraparte de la operación. Cliente + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Datos correspondientes al registro de facturacion de anulacion + + + + + + + + + + + + + + + + + + + + + + + + + + + Datos de encadenamiento + + + + + NIF del obligado a expedir la factura a que se refiere el registro de facturación anterior + + + + + + + + + + + + + + + + + + + + + + + + + + + + Datos de identificación de factura + + + + + NIF del obligado + + + + + Nº Serie+Nº Factura de la Factura del Emisor + + + + + Fecha de emisión de la factura + + + + + + + + Datos de identificación de factura sustituida o rectificada. El NIF se cogerá del NIF indicado en el bloque IDFactura + + + + + NIF del obligado + + + + + Nº Serie+Nº Factura de la factura + + + + + Fecha de emisión de la factura sustituida o rectificada + + + + + + + + + + + + + + + + + + + + + + + Desglose de Base y Cuota sustituida en las Facturas Rectificativas sustitutivas + + + + + + + + + + + Datos de una persona física o jurídica Española con un NIF asociado + + + + + + + + + + Datos de una persona física o jurídica Española o Extranjera + + + + + + + + + + + + + Identificador de persona Física o jurídica distinto del NIF + (Código pais, Tipo de Identificador, y hasta 15 caractéres) + No se permite CodigoPais=ES e IDType=01-NIFContraparte + para ese caso, debe utilizarse NIF en lugar de IDOtro. + + + + + + + + + + + Rango de fechas de expedicion + + + + + + + + + + + + + + + + + + IdPeticion asociado a la factura registrada previamente en el sistema. Solo se suministra si la factura enviada es rechazada por estar duplicada + + + + + + + Estado del registro duplicado almacenado en el sistema. Los estados posibles son: Correcta, AceptadaConErrores y Anulada. Solo se suministra si la factura enviada es rechazada por estar duplicada + + + + + + + Código del error de registro duplicado almacenado en el sistema, en su caso. + + + + + + + Descripción detallada del error de registro duplicado almacenado en el sistema, en su caso. + + + + + + + + + + + + + + + Año en formato YYYY + + + + + + + + + Período de la factura + + + + + Enero + + + + + Febrero + + + + + Marzo + + + + + Abril + + + + + Mayo + + + + + Junio + + + + + Julio + + + + + Agosto + + + + + Septiembre + + + + + Octubre + + + + + Noviembre + + + + + Diciembre + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NIF + + + + + + + + + + + + + + + + + + + + + + EXENTA por Art. 20 + + + + + EXENTA por Art. 21 + + + + + EXENTA por Art. 22 + + + + + EXENTA por Art. 24 + + + + + EXENTA por Art. 25 + + + + + EXENTA otros + + + + + + + + + + FACTURA (ART. 6, 7.2 Y 7.3 DEL RD 1619/2012) + + + + + FACTURA SIMPLIFICADA Y FACTURAS SIN IDENTIFICACIÓN DEL DESTINATARIO ART. 6.1.D) RD 1619/2012 + + + + + FACTURA RECTIFICATIVA (Art 80.1 y 80.2 y error fundado en derecho) + + + + + FACTURA RECTIFICATIVA (Art. 80.3) + + + + + FACTURA RECTIFICATIVA (Art. 80.4) + + + + + FACTURA RECTIFICATIVA (Resto) + + + + + FACTURA RECTIFICATIVA EN FACTURAS SIMPLIFICADAS + + + + + FACTURA EMITIDA EN SUSTITUCIÓN DE FACTURAS SIMPLIFICADAS FACTURADAS Y DECLARADAS + + + + + + + + + No ha habido rechazo previo por la AEAT. + + + + + Ha habido rechazo previo por la AEAT. + + + + + Independientemente de si ha habido o no algún rechazo previo por la AEAT, el registro de facturación no existe en la AEAT (registro existente en ese SIF o en algún SIF del obligado tributario y que no se remitió a la AEAT, por ejemplo, al acogerse a Veri*factu desde no Veri*factu). No deberían existir operaciones de alta (N,X), por lo que no se admiten. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SUSTITUTIVA + + + + + INCREMENTAL + + + + + + + + + + Destinatario + + + + + Tercero + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Expedidor (obligado a Expedir la factura anulada). + + + + + Destinatario + + + + + Tercero + + + + + + + + + + NIF-IVA + + + + + Pasaporte + + + + + IDEnPaisResidencia + + + + + Certificado Residencia + + + + + Otro documento Probatorio + + + + + No Censado + + + + + + + + + + SHA-256 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + El registro se ha almacenado sin errores + + + + + El registro que se ha almacenado tiene algunos errores. Ver detalle del error + + + + + El registro almacenado ha sido anulado + + + + + + + + + + + + OPERACIÓN SUJETA Y NO EXENTA - SIN INVERSIÓN DEL SUJETO PASIVO. + + + + + OPERACIÓN SUJETA Y NO EXENTA - CON INVERSIÓN DEL SUJETO PASIVO + + + + + OPERACIÓN NO SUJETA ARTÍCULO 7, 14, OTROS. + + + + + OPERACIÓN NO SUJETA POR REGLAS DE LOCALIZACIÓN + + + + + + + + + + + + + + + + + + + Datos de una persona física o jurídica Española o Extranjera + + + + + + + + + + + + Compuesto por datos + de contexto y una secuencia de 1 o más registros. + + + + + + + + Cabecera de la Cobnsulta + + + + + + + Obligado a la emision de los registros de facturacion + + + + + Destinatario (a veces también denominado contraparte, es decir, el cliente) de la operación + + + + + + Flag opcional que tendrá valor S si la consulta la está realizando el representante/asesor del obligado tributario. A rellenar solo en caso de que los registros de facturación remitidos hayan sido generados por un representante/asesor del obligado tributario. Este flag solo se puede cumplimentar cuando esté informado el obligado tributario en la consulta + + + + + + + + Datos de una persona física o jurídica Española con un NIF asociado + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Impuesto sobre el Valor Añadido (IVA) + + + + + Impuesto sobre la Producción, los Servicios y la Importación (IPSI) de Ceuta y Melilla + + + + + Impuesto General Indirecto Canario (IGIC) + + + + + Otros + + + + + + + + + + + + + + + + + + La operación realizada ha sido un alta + + + + + La operación realizada ha sido una anulación + + + + + diff --git a/test/schema/SuministroLR.xsd b/test/schema/SuministroLR.xsd new file mode 100644 index 0000000..0800e24 --- /dev/null +++ b/test/schema/SuministroLR.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + Datos correspondientes a los registros de facturacion + + + + + + + + + diff --git a/verifactu.go b/verifactu.go index 1483000..a71119d 100644 --- a/verifactu.go +++ b/verifactu.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "github.com/invopop/gobl.verifactu/internal/doc" "github.com/invopop/gobl/l10n" "github.com/invopop/xmldsig" ) @@ -31,9 +32,9 @@ func newValidationError(text string) error { return &ValidationError{errors.New(text)} } -// Client provides the main interface to the TicketBAI package. +// Client provides the main interface to the VeriFactu package. type Client struct { - software *Software + software *doc.Software // list *gateways.List cert *xmldsig.Certificate // env gateways.Environment @@ -45,7 +46,7 @@ type Client struct { // Option is used to configure the client. type Option func(*Client) -// WithCurrentTime defines the current time to use when generating the TicketBAI +// WithCurrentTime defines the current time to use when generating the VeriFactu // document. Useful for testing. func WithCurrentTime(curTime time.Time) Option { return func(c *Client) { @@ -53,17 +54,6 @@ func WithCurrentTime(curTime time.Time) Option { } } -// Software defines the details about the software that is using this library to -// generate TicketBAI documents. These details are included in the final -// document. -type Software struct { - License string - NIF string - Name string - CompanyName string - Version string -} - // PreviousInvoice stores the fields from the previously generated invoice // document that are linked to in the new document. type PreviousInvoice struct { @@ -73,9 +63,9 @@ type PreviousInvoice struct { Signature string } -// New creates a new TicketBAI client with shared software and configuration +// New creates a new VeriFactu client with shared software and configuration // options for creating and sending new documents. -func New(software *Software, opts ...Option) (*Client, error) { +func New(software *doc.Software, opts ...Option) (*Client, error) { c := new(Client) c.software = software @@ -101,7 +91,7 @@ func New(software *Software, opts ...Option) (*Client, error) { } // CurrentTime returns the current time to use when generating -// the TicketBAI document. +// the VeriFactu document. func (c *Client) CurrentTime() time.Time { if !c.curTime.IsZero() { return c.curTime From 8504cb422e7dc0b280401f01c085d9d1b416ac56 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 4 Nov 2024 19:50:33 +0000 Subject: [PATCH 03/56] Document Structure (XML) --- cmd/convert.go | 2 +- document.go | 33 ++++++ internal/doc/cancel.go | 17 +++ internal/doc/doc.go | 136 ++++++++++++++++++++- internal/doc/document.go | 135 +++++++++++++++++++++ internal/doc/header.go | 23 ---- internal/doc/invoice.go | 152 +++++++++--------------- test/schema/errores.properties | 210 +++++++++++++++++++++++++++++++++ 8 files changed, 586 insertions(+), 122 deletions(-) create mode 100644 internal/doc/cancel.go create mode 100644 internal/doc/document.go delete mode 100644 internal/doc/header.go create mode 100644 test/schema/errores.properties diff --git a/cmd/convert.go b/cmd/convert.go index 44dbd2d..37115e2 100644 --- a/cmd/convert.go +++ b/cmd/convert.go @@ -21,7 +21,7 @@ func convert(o *rootOpts) *convertOpts { func (c *convertOpts) cmd() *cobra.Command { cmd := &cobra.Command{ Use: "convert [infile] [outfile]", - Short: "Convert a GOBL JSON into a TickeBAI XML", + Short: "Convert a GOBL JSON into a VeriFactu XML", RunE: c.runE, } diff --git a/document.go b/document.go index 96d8053..90987b2 100644 --- a/document.go +++ b/document.go @@ -4,6 +4,7 @@ import ( "github.com/invopop/gobl" "github.com/invopop/gobl.verifactu/internal/doc" "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/l10n" ) // Document is a wrapper around the internal TicketBAI document. @@ -13,3 +14,35 @@ type Document struct { vf *doc.VeriFactu client *Client } + +// NewDocument creates a new TicketBAI document from the provided GOBL Envelope. +// The envelope must contain a valid Invoice. +func (c *Client) NewDocument(env *gobl.Envelope) (*Document, error) { + d := new(Document) + + // Set the client for later use + d.client = c + + var ok bool + d.env = env + d.inv, ok = d.env.Extract().(*bill.Invoice) + if !ok { + return nil, ErrOnlyInvoices + } + + // Check the existing stamps, we might not need to do anything + // if d.hasExistingStamps() { + // return nil, ErrAlreadyProcessed + // } + if d.inv.Supplier.TaxID.Country != l10n.ES.Tax() { + return nil, ErrNotSpanish + } + + var err error + d.vf, err = doc.NewVeriFactu(d.inv, c.CurrentTime()) + if err != nil { + return nil, err + } + + return d, nil +} diff --git a/internal/doc/cancel.go b/internal/doc/cancel.go new file mode 100644 index 0000000..05e0ad9 --- /dev/null +++ b/internal/doc/cancel.go @@ -0,0 +1,17 @@ +package doc + +type RegistroAnulacion struct { + IDVersion string `xml:"IDVersion"` + IDFactura IDFactura `xml:"IDFactura"` + RefExterna string `xml:"RefExterna,omitempty"` + SinRegistroPrevio string `xml:"SinRegistroPrevio"` + RechazoPrevio string `xml:"RechazoPrevio,omitempty"` + GeneradoPor string `xml:"GeneradoPor"` + Generador *Tercero `xml:"Generador"` + Encadenamiento Encadenamiento `xml:"Encadenamiento"` + SistemaInformatico Software `xml:"SistemaInformatico"` + FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` + TipoHuella string `xml:"TipoHuella"` + Huella string `xml:"Huella"` + Signature string `xml:"Signature"` +} diff --git a/internal/doc/doc.go b/internal/doc/doc.go index 7cf4c9d..4e50929 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -1,8 +1,17 @@ package doc +import ( + "bytes" + "encoding/xml" + "fmt" + "time" + + "github.com/invopop/gobl/bill" +) + type VeriFactu struct { Cabecera *Cabecera - RegistroFactura []*RegistroFactura + RegistroFactura *RegistroFactura } type RegistroFactura struct { @@ -10,9 +19,6 @@ type RegistroFactura struct { RegistroAnulacion *RegistroAnulacion } -type RegistroAnulacion struct { -} - type Software struct { NombreRazon string NIF string @@ -24,3 +30,125 @@ type Software struct { IndicadorMultiplesOT string Version string } + +func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { + if inv.Type == bill.InvoiceTypeCreditNote { + + if err := inv.Invert(); err != nil { + return nil, err + } + } + + goblWithoutIncludedTaxes, err := inv.RemoveIncludedTaxes() + if err != nil { + return nil, err + } + + doc := &VeriFactu{ + Cabecera: &Cabecera{ + Obligado: Obligado{ + NombreRazon: inv.Supplier.Name, + NIF: inv.Supplier.TaxID.Code.String(), + }, + }, + RegistroFactura: &RegistroFactura{}, + } + + doc.SetIssueTimestamp(ts) + + // Add customers + if inv.Customer != nil { + dest, err := newDestinatario(inv.Customer) + if err != nil { + return nil, err + } + doc.Sujetos.Destinatarios = &Destinatarios{ + IDDestinatario: []*IDDestinatario{dest}, + } + } + + if inv.Type == bill.InvoiceTypeCreditNote { + doc.RegistroFactura.RegistroAnulacion, err = newDatosFactura(goblWithoutIncludedTaxes) + } else { + doc.RegistroFactura.RegistroAlta, err = newDatosFactura(goblWithoutIncludedTaxes) + } + if err != nil { + return nil, err + } + + return doc, nil +} + +// QRCodes generates the QR codes for this invoice, but requires the Fingerprint to have been +// generated first. +func (doc *TicketBAI) QRCodes() *Codes { + if doc.HuellaTBAI == nil { + return nil + } + return doc.generateCodes(doc.zone) +} + +// Bytes returns the XML document bytes +func (doc *TicketBAI) Bytes() ([]byte, error) { + return toBytes(doc) +} + +// BytesIndent returns the indented XML document bytes +func (doc *TicketBAI) BytesIndent() ([]byte, error) { + return toBytesIndent(doc) +} + +func toBytes(doc any) ([]byte, error) { + buf, err := buffer(doc, xml.Header, false) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func toBytesIndent(doc any) ([]byte, error) { + buf, err := buffer(doc, xml.Header, true) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func toBytesCanonical(doc any) ([]byte, error) { + buf, err := buffer(doc, "", false) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func buffer(doc any, base string, indent bool) (*bytes.Buffer, error) { + buf := bytes.NewBufferString(base) + + enc := xml.NewEncoder(buf) + if indent { + enc.Indent("", " ") + } + + if err := enc.Encode(doc); err != nil { + return nil, fmt.Errorf("encoding document: %w", err) + } + + return buf, nil +} + +type timeLocationable interface { + In(*time.Location) time.Time +} + +// TODO +// func formatDate(ts timeLocationable) string { +// return ts.In(location).Format("02-01-2006") +// } + +// func formatTime(ts timeLocationable) string { +// return ts.In(location).Format("15:04:05") +// } diff --git a/internal/doc/document.go b/internal/doc/document.go new file mode 100644 index 0000000..7b588d6 --- /dev/null +++ b/internal/doc/document.go @@ -0,0 +1,135 @@ +package doc + +// "github.com/invopop/gobl/pkg/xmldsig" + +const ( + SUM = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" + SUM1 = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" +) + +type Cabecera struct { + Obligado Obligado `xml:"sum1:Obligado"` + Representante *Obligado `xml:"sum1:Representante,omitempty"` + RemisionVoluntaria *RemisionVoluntaria `xml:"sum1:RemisionVoluntaria,omitempty"` + RemisionRequerimiento *RemisionRequerimiento `xml:"sum1:RemisionRequerimiento,omitempty"` +} + +type Obligado struct { + NombreRazon string `xml:"sum1:NombreRazon"` + NIF string `xml:"sum1:NIF"` +} + +type RemisionVoluntaria struct { + FechaFinVerifactu string `xml:"sum1:FechaFinVerifactu"` + Incidencia string `xml:"sum1:Incidencia"` +} + +type RemisionRequerimiento struct { + RefRequerimiento string `xml:"sum1:RefRequerimiento"` + FinRequerimiento string `xml:"sum1:FinRequerimiento"` +} + +type RegistroAlta struct { + IDVersion string `xml:"sum1:IDVersion"` + IDFactura IDFactura `xml:"sum1:IDFactura"` + RefExterna string `xml:"sum1:RefExterna,omitempty"` + NombreRazonEmisor string `xml:"sum1:NombreRazonEmisor"` + Subsanacion string `xml:"sum1:Subsanacion,omitempty"` + RechazoPrevio string `xml:"sum1:RechazoPrevio,omitempty"` + TipoFactura string `xml:"sum1:TipoFactura"` + TipoRectificativa string `xml:"sum1:TipoRectificativa,omitempty"` + FacturasRectificadas []*FacturaRectificada `xml:"sum1:FacturasRectificadas>sum1:FacturaRectificada,omitempty"` + FacturasSustituidas []*FacturaSustituida `xml:"sum1:FacturasSustituidas>sum1:FacturaSustituida,omitempty"` + ImporteRectificacion ImporteRectificacion `xml:"sum1:ImporteRectificacion,omitempty"` + FechaOperacion string `xml:"sum1:FechaOperacion"` + DescripcionOperacion string `xml:"sum1:DescripcionOperacion"` + FacturaSimplificadaArt7273 string `xml:"sum1:FacturaSimplificadaArt7273,omitempty"` + FacturaSinIdentifDestinatarioArt61d string `xml:"sum1:FacturaSinIdentifDestinatarioArt61d,omitempty"` + Macrodato string `xml:"sum1:Macrodato,omitempty"` + EmitidaPorTerceroODestinatario string `xml:"sum1:EmitidaPorTerceroODestinatario,omitempty"` + Tercero Tercero `xml:"sum1:Tercero,omitempty"` + Destinatarios []*Destinatario `xml:"sum1:Destinatarios>sum1:Destinatario,omitempty"` + Cupon string `xml:"sum1:Cupon,omitempty"` + Desglose Desglose `xml:"sum1:Desglose"` + CuotaTotal float64 `xml:"sum1:CuotaTotal"` + ImporteTotal float64 `xml:"sum1:ImporteTotal"` + Encadenamiento Encadenamiento `xml:"sum1:Encadenamiento"` + SistemaInformatico Software `xml:"sum1:SistemaInformatico"` + FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"` + NumRegistroAcuerdoFacturacion string `xml:"sum1:NumRegistroAcuerdoFacturacion,omitempty"` + IdAcuerdoSistemaInformatico string `xml:"sum1:IdAcuerdoSistemaInformatico,omitempty"` + TipoHuella string `xml:"sum1:TipoHuella"` + Huella string `xml:"sum1:Huella"` + // Signature *xmldsig.Signature `xml:"sum1:Signature,omitempty"` +} + +type IDFactura struct { + IDEmisorFactura string `xml:"sum1:IDEmisorFactura"` + NumSerieFactura string `xml:"sum1:NumSerieFactura"` + FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"` +} + +type FacturaRectificada struct { + IDFactura IDFactura `xml:"sum1:IDFactura"` +} + +type FacturaSustituida struct { + IDFactura IDFactura `xml:"sum1:IDFactura"` +} + +type ImporteRectificacion struct { + BaseRectificada float64 `xml:"sum1:BaseRectificada"` + CuotaRectificada float64 `xml:"sum1:CuotaRectificada"` + CuotaRecargoRectificado float64 `xml:"sum1:CuotaRecargoRectificado"` +} + +type Tercero struct { + Nif string `xml:"sum1:Nif,omitempty"` + NombreRazon string `xml:"sum1:NombreRazon"` + IDOtro string `xml:"sum1:IDOtro,omitempty"` +} + +type Destinatario struct { + IDDestinatario IDDestinatario `xml:"sum1:IDDestinatario"` +} + +type IDDestinatario struct { + NIF string `xml:"sum1:NIF,omitempty"` + NombreRazon string `xml:"sum1:NombreRazon"` + IDOtro IDOtro `xml:"sum1:IDOtro,omitempty"` +} + +type IDOtro struct { + CodigoPais string `xml:"sum1:CodigoPais"` + IDType string `xml:"sum1:IDType"` + ID string `xml:"sum1:ID"` +} + +type Desglose struct { + DetalleDesglose []*DetalleDesglose `xml:"sum1:DetalleDesglose"` +} + +type DetalleDesglose struct { + Impuesto string `xml:"sum1:Impuesto"` + ClaveRegimen string `xml:"sum1:ClaveRegimen"` + CalificacionOperacion string `xml:"sum1:CalificacionOperacion,omitempty"` + OperacionExenta string `xml:"sum1:OperacionExenta,omitempty"` + TipoImpositivo string `xml:"sum1:TipoImpositivo,omitempty"` + BaseImponibleOImporteNoSujeto string `xml:"sum1:BaseImponibleOImporteNoSujeto"` + BaseImponibleACoste string `xml:"sum1:BaseImponibleACoste,omitempty"` + CuotaRepercutida string `xml:"sum1:CuotaRepercutida,omitempty"` + TipoRecargoEquivalencia string `xml:"sum1:TipoRecargoEquivalencia,omitempty"` + CuotaRecargoEquivalencia string `xml:"sum1:CuotaRecargoEquivalencia,omitempty"` +} + +type Encadenamiento struct { + PrimerRegistro string `xml:"sum1:PrimerRegistro"` + RegistroAnterior RegistroAnterior `xml:"sum1:RegistroAnterior,omitempty"` +} + +type RegistroAnterior struct { + IDEmisorFactura string `xml:"sum1:IDEmisorFactura"` + NumSerieFactura string `xml:"sum1:NumSerieFactura"` + FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"` + Huella string `xml:"sum1:Huella"` +} diff --git a/internal/doc/header.go b/internal/doc/header.go deleted file mode 100644 index a6ba6f3..0000000 --- a/internal/doc/header.go +++ /dev/null @@ -1,23 +0,0 @@ -package doc - -type Cabecera struct { - Obligado Obligado - Representante *Obligado - RemisionVoluntaria *RemisionVoluntaria - RemisionRequerimiento *RemisionRequerimiento -} - -type Obligado struct { - NombreRazon string - NIF string -} - -type RemisionVoluntaria struct { - FechaFinVerifactu string - Incidencia string -} - -type RemisionRequerimiento struct { - RefRequerimiento string - FinRequerimiento string -} diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index 3867b2e..272eed8 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -1,106 +1,70 @@ package doc -type RegistroAlta struct { - IDVersion string `xml:"IDVersion"` - IDFactura IDFactura `xml:"IDFactura"` - RefExterna string `xml:"RefExterna,omitempty"` - NombreRazonEmisor string `xml:"NombreRazonEmisor"` - Subsanacion string `xml:"Subsanacion,omitempty"` - RechazoPrevio string `xml:"RechazoPrevio,omitempty"` - TipoFactura string `xml:"TipoFactura"` - TipoRectificativa string `xml:"TipoRectificativa,omitempty"` - FacturasRectificadas []*FacturaRectificada `xml:"FacturasRectificadas>FacturaRectificada,omitempty"` - FacturasSustituidas []*FacturaSustituida `xml:"FacturasSustituidas>FacturaSustituida,omitempty"` - ImporteRectificacion ImporteRectificacion `xml:"ImporteRectificacion,omitempty"` - FechaOperacion string `xml:"FechaOperacion"` - DescripcionOperacion string `xml:"DescripcionOperacion"` - FacturaSimplificadaArt7273 string `xml:"FacturaSimplificadaArt7273,omitempty"` - FacturaSinIdentifDestinatarioArt61d string `xml:"FacturaSinIdentifDestinatarioArt61d,omitempty"` - Macrodato string `xml:"Macrodato,omitempty"` - EmitidaPorTerceroODestinatario string `xml:"EmitidaPorTerceroODestinatario,omitempty"` - Tercero Tercero `xml:"Tercero,omitempty"` - Destinatarios []*Destinatario `xml:"Destinatarios>Destinatario,omitempty"` - Cupon string `xml:"Cupon,omitempty"` - Desglose Desglose `xml:"Desglose"` - CuotaTotal float64 `xml:"CuotaTotal"` - ImporteTotal float64 `xml:"ImporteTotal"` - Encadenamiento Encadenamiento `xml:"Encadenamiento"` - SistemaInformatico Software `xml:"SistemaInformatico"` - FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` - NumRegistroAcuerdoFacturacion string `xml:"NumRegistroAcuerdoFacturacion,omitempty"` - IdAcuerdoSistemaInformatico string `xml:"IdAcuerdoSistemaInformatico,omitempty"` - TipoHuella string `xml:"TipoHuella"` - Huella string `xml:"Huella"` - Signature string `xml:"Signature"` -} - -type IDFactura struct { - IDEmisorFactura string `xml:"IDEmisorFactura"` - NumSerieFactura string `xml:"NumSerieFactura"` - FechaExpedicionFactura string `xml:"FechaExpedicionFactura"` -} +import ( + "fmt" -type FacturaRectificada struct { - IDFactura IDFactura `xml:"IDFactura"` -} + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" +) -type FacturaSustituida struct { - IDFactura IDFactura `xml:"IDFactura"` -} +func newInvoice(inv *bill.Invoice) (*RegistroAlta, error) { + // Create new RegistroAlta with required fields + reg := &RegistroAlta{ + IDVersion: "1.0", + IDFactura: IDFactura{ + IDEmisorFactura: inv.Supplier.TaxID.Code.String(), + NumSerieFactura: invoiceNumber(inv.Series, inv.Code), + FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), + }, + NombreRazonEmisor: inv.Supplier.Name, + FechaOperacion: inv.IssueDate.Format("02-01-2006"), + DescripcionOperacion: inv.Notes.String(), + ImporteTotal: inv.Totals.Total.Float64(), + CuotaTotal: inv.Totals.Tax.Float64(), + } -type ImporteRectificacion struct { - BaseRectificada float64 `xml:"BaseRectificada"` - CuotaRectificada float64 `xml:"CuotaRectificada"` - CuotaRecargoRectificado float64 `xml:"CuotaRecargoRectificado"` -} + // Set TipoFactura based on invoice type + switch inv.Type { + case bill.InvoiceTypeStandard: + reg.TipoFactura = "F1" + case bill.InvoiceTypeCreditNote: + reg.TipoFactura = "R1" + reg.TipoRectificativa = "I" // Por diferencias + case bill.InvoiceTypeDebitNote: + reg.TipoFactura = "R1" + reg.TipoRectificativa = "I" + } -type Tercero struct { - Nif string `xml:"Nif,omitempty"` - NombreRazon string `xml:"NombreRazon"` - IDOtro string `xml:"IDOtro,omitempty"` -} + // Add destinatarios if customer exists + if inv.Customer != nil { + dest := &Destinatario{ + IDDestinatario: IDDestinatario{ + NombreRazon: inv.Customer.Name, + }, + } -type Destinatario struct { - IDDestinatario IDDestinatario `xml:"IDDestinatario"` -} + // Handle tax ID + if inv.Customer.TaxID != nil { + if inv.Customer.TaxID.Country.Is("ES") { + dest.IDDestinatario.NIF = inv.Customer.TaxID.Code.String() + } else { + dest.IDDestinatario.IDOtro = IDOtro{ + CodigoPais: inv.Customer.TaxID.Country.String(), + IDType: "04", // NIF-IVA + ID: inv.Customer.TaxID.Code.String(), + } + } + } -type IDDestinatario struct { - NIF string `xml:"NIF,omitempty"` - NombreRazon string `xml:"NombreRazon"` - IDOtro IDOtro `xml:"IDOtro,omitempty"` -} - -type IDOtro struct { - CodigoPais string `xml:"CodigoPais"` - IDType string `xml:"IDType"` - ID string `xml:"ID"` -} - -type Desglose struct { - DetalleDesglose []*DetalleDesglose `xml:"DetalleDesglose"` -} - -type DetalleDesglose struct { - Impuesto string `xml:"Impuesto"` - ClaveRegimen string `xml:"ClaveRegimen"` - CalificacionOperacion string `xml:"CalificacionOperacion,omitempty"` - OperacionExenta string `xml:"OperacionExenta,omitempty"` - TipoImpositivo string `xml:"TipoImpositivo,omitempty"` - BaseImponibleOImporteNoSujeto string `xml:"BaseImponibleOImporteNoSujeto"` - BaseImponibleACoste string `xml:"BaseImponibleACoste,omitempty"` - CuotaRepercutida string `xml:"CuotaRepercutida,omitempty"` - TipoRecargoEquivalencia string `xml:"TipoRecargoEquivalencia,omitempty"` - CuotaRecargoEquivalencia string `xml:"CuotaRecargoEquivalencia,omitempty"` -} + reg.Destinatarios = []*Destinatario{dest} + } -type Encadenamiento struct { - PrimerRegistro string `xml:"PrimerRegistro"` - RegistroAnterior RegistroAnterior `xml:"RegistroAnterior,omitempty"` + return reg, nil } -type RegistroAnterior struct { - IDEmisorFactura string `xml:"IDEmisorFactura"` - NumSerieFactura string `xml:"NumSerieFactura"` - FechaExpedicionFactura string `xml:"FechaExpedicionFactura"` - Huella string `xml:"Huella"` +func invoiceNumber(series cbc.Code, code cbc.Code) string { + if series == "" { + return code.String() + } + return fmt.Sprintf("%s-%s", series, code) } diff --git a/test/schema/errores.properties b/test/schema/errores.properties new file mode 100644 index 0000000..05abc54 --- /dev/null +++ b/test/schema/errores.properties @@ -0,0 +1,210 @@ +********* Listado de cdigos de error que provocan el rechazo del envo completo ********* +4102 = El XML no cumple el esquema. Falta informar campo obligatorio. +4103 = Se ha producido un error inesperado al parsear el XML. +4104 = Error en la cabecera: el valor del campo NIF del bloque ObligadoEmision no est identificado. +4105 = Error en la cabecera: el valor del campo NIF del bloque Representante no est identificado. +4106 = El formato de fecha es incorrecto. +4107 = El NIF no est identificado en el censo de la AEAT. +4108 = Error tcnico al obtener el certificado. +4109 = El formato del NIF es incorrecto. +4110 = Error tcnico al comprobar los apoderamientos. +4111 = Error tcnico al crear el trmite. +4112 = El titular del certificado debe ser Obligado Emisin, Colaborador Social, Apoderado o Sucesor. +4113 = El XML no cumple con el esquema: se ha superado el lmite permitido de registros para el bloque. +4114 = El XML no cumple con el esquema: se ha superado el lmite mximo permitido de facturas a registrar. +4115 = El valor del campo NIF del bloque ObligadoEmision es incorrecto. +4116 = Error en la cabecera: el campo NIF del bloque ObligadoEmision tiene un formato incorrecto. +4117 = Error en la cabecera: el campo NIF del bloque Representante tiene un formato incorrecto. +4118 = Error tcnico: la direccin no se corresponde con el fichero de entrada. +4119 = Error al informar caracteres cuya codificacin no es UTF-8. +4120 = Error en la cabecera: el valor del campo FechaFinVeriFactu es incorrecto, debe ser 31-12-20XX, donde XX corresponde con el ao actual o el anterior. +4121 = Error en la cabecera: el valor del campo Incidencia es incorrecto. +4122 = Error en la cabecera: el valor del campo RefRequerimiento es incorrecto. +4123 = Error en la cabecera: el valor del campo NIF del bloque Representante no est identificado en el censo de la AEAT. +4124 = Error en la cabecera: el valor del campo Nombre del bloque Representante no est identificado en el censo de la AEAT. +4125 = Error en la cabecera: el campo RefRequerimiento es obligatorio. +4126 = Error en la cabecera: el campo RefRequerimiento solo debe informarse en sistemas No VERIFACTU. +4127 = Error en la cabecera: la remisin voluntaria solo debe informarse en sistemas VERIFACTU. +4128 = Error tcnico en la recuperacin del valor del Gestor de Tablas. +4129 = Error en la cabecera: el campo FinRequerimiento es obligatorio. +4130 = Error en la cabecera: el campo FinRequerimiento solo debe informarse en sistemas No VERIFACTU. +4131 = Error en la cabecera: el valor del campo FinRequerimiento es incorrecto. +4132 = El titular del certificado debe ser el destinatario que realiza la consulta, un Apoderado o Sucesor +3500 = Error tcnico de base de datos: error en la integridad de la informacin. +3501 = Error tcnico de base de datos. +3502 = La factura consultada para el suministro de pagos/cobros/inmuebles no existe. +3503 = La factura especificada no pertenece al titular registrado en el sistema. + +********* Listado de cdigos de error que provocan el rechazo de la factura (o de la peticin completa si el error se produce en la cabecera) ********* +1100 = Valor o tipo incorrecto del campo. +1101 = El valor del campo CodigoPais es incorrecto. +1102 = El valor del campo IDType es incorrecto. +1103 = El valor del campo ID es incorrecto. +1104 = El valor del campo NumSerieFactura es incorrecto. +1105 = El valor del campo FechaExpedicionFactura es incorrecto. +1106 = El valor del campo TipoFactura no est incluido en la lista de valores permitidos. +1107 = El valor del campo TipoRectificativa es incorrecto. +1108 = El NIF del IDEmisorFactura debe ser el mismo que el NIF del ObligadoEmision. +1109 = El NIF no est identificado en el censo de la AEAT. +1110 = El NIF no est identificado en el censo de la AEAT. +1111 = El campo CodigoPais es obligatorio cuando IDType es distinto de 02. +1112 = El campo FechaExpedicionFactura es superior a la fecha actual. +1114 = Si la factura es de tipo rectificativa, el campo TipoRectificativa debe tener valor. +1115 = Si la factura no es de tipo rectificativa, el campo TipoRectificativa no debe tener valor. +1116 = Debe informarse el campo FacturasSustituidas slo si la factura es de tipo F3. +1117 = Si la factura no es de tipo rectificativa, el bloque FacturasRectificadas no podr venir informado. +1118 = Si la factura es de tipo rectificativa por sustitucin el bloque ImporteRectificacion es obligatorio. +1119 = Si la factura no es de tipo rectificativa por sustitucin el bloque ImporteRectificacion no debe tener valor. +1120 = Valor de campo IDEmisorFactura del bloque IDFactura con tipo incorrecto. +1121 = El campo ID no est identificado en el censo de la AEAT. +1122 = El campo CodigoPais indicado no coincide con los dos primeros dgitos del identificador. +1123 = El formato del NIF es incorrecto. +1124 = El valor del campo TipoImpositivo no est incluido en la lista de valores permitidos. +1125 = El valor del campo FechaOperacion tiene una fecha superior a la permitida. +1126 = El valor del CodigoPais solo puede ser ES cuando el IDType sea 07 o 03. Si IDType es 07 el CodigoPais debe ser ES. +1127 = El valor del campo TipoRecargoEquivalencia no est incluido en la lista de valores permitidos. +1128 = No existe acuerdo de facturacin. +1129 = Error tcnico al obtener el acuerdo de facturacin. +1130 = El campo NumSerieFactura contiene caracteres no permitidos. +1131 = El valor del campo ID ha de ser el NIF de una persona fsica cuando el campo IDType tiene valor 07. +1132 = El valor del campo TipoImpositivo es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura inferior o igual al año 2012. +1133 = El valor del campo FechaExpedicionFactura no debe ser inferior a la fecha actual menos veinte años. +1134 = El valor del campo FechaOperacion no debe ser inferior a la fecha actual menos veinte años. +1135 = El valor del campo TipoRecargoEquivalencia es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura inferior o igual al año 2012. +1136 = El campo FacturaSimplificadaArticulos7273 solo acepta valores N o S. +1137 = El campo Macrodato solo acepta valores N o S. +1138 = El campo Macrodato solo debe ser informado con valor S si el valor de ImporteTotal es igual o superior a +-100.000.000 +1139 = Si el campo ImporteTotal est informado y es igual o superior a +-100.000.000 el campo Macrodato debe estar informado con valor S. +1140 = Los campos CuotaRepercutida y BaseImponibleACoste deben tener el mismo signo. +1142 = El campo CuotaRepercutida tiene un valor incorrecto para el valor de los campos BaseImponibleOimporteNoSujeto y TipoImpositivo suministrados. +1143 = Los campos CuotaRepercutida y BaseImponibleOimporteNoSujeto deben tener el mismo signo. +1144 = El campo CuotaRepercutida tiene un valor incorrecto para el valor de los campos BaseImponibleACoste y TipoImpositivo suministrados. +1145 = Formato de fecha incorrecto. +1146 = Slo se permite que la fecha de expedicion de la factura sea anterior a la fecha operacin si los detalles del desglose son ClaveRegimen 14 o 15 e Impuesto 01, 03 o vaco. +1147 = Si ClaveRegimen es 14, FechaOperacion es obligatoria y debe ser posterior a la FechaExpedicionFactura. +1148 = Si la ClaveRegimen es 14, el campo TipoFactura debe ser F1, R1, R2, R3 o R4. +1149 = Si ClaveRegimen es 14, el NIF de Destinatarios debe estar identificado en el censo de la AEAT y comenzar por P, Q, S o V. +1150 = Cuando TipoFactura sea F2 y no este informado NumRegistroAcuerdoFacturacion o FacturaSinIdentifDestinatarioArt61d no sea S el sumatorio de BaseImponibleOimporteNoSujeto y CuotaRepercutida de todas las lneas de detalle no podr ser superior a 3.000. +1151 = El campo EmitidaPorTerceroODestinatario solo acepta valores T o D. +1152 = La fecha de Expedicion de la factura no puede ser inferior al indicado en la Orden Ministerial. +1153 = Valor del campo RechazoPrevio no vlido, solo podr incluirse el campo RechazoPrevio con valor X si se ha informado el campo Subsanacion y tiene el valor S. +1154 = El NIF del emisor de la factura rectificada/sustitutiva no se ha podido identificar en el censo de la AEAT. +1155 = Se est informando el bloque Tercero sin estar informado el campo EmitidaPorTerceroODestinatario. +1156 = Para el bloque IDOtro y IDType 02, el valor de TipoFactura es incorrecto. +1157 = El valor de cupn solo puede ser S o N si est informado. El valor de cupn slo puede ser S si el tipo de factura es R1 o R5. +1158 = Se est informando EmitidaPorTerceroODestinatario, pero no se informa el bloque correspondiente. +1159 = Se est informando del bloque Tercero cuando se indica que se va a informar de Destinatario. +1160 = Si el TipoImpositivo es 5%, slo se admite TipoRecargoEquivalencia 0,5 o 0,62. +1161 = El valor del campo RechazoPrevio no es vlido, no podr incluirse el campo RechazoPrevio con valor S si no se ha informado del campo Subsanacion o tiene el valor N. +1162 = Si el TipoImpositivo es 21%, slo se admite TipoRecargoEquivalencia 5,2 1,75. +1163 = Si el TipoImpositivo es 10%, slo se admite TipoRecargoEquivalencia 1,4. +1164 = Si el TipoImpositivo es 4%, slo se admite TipoRecargoEquivalencia 0,5. +1165 = Si el TipoImpositivo es 0% entre el 1 de enero de 2023 y el 30 de septiembre de 2024, slo se admite TipoRecargoEquivalencia 0. +1166 = Si el TipoImpositivo es 2% entre el 1 de octubre de 2024 y el 31 de diciembre de 2024, slo se admite TipoRecargoEquivalencia 0,26. +1167 = Si el TipoImpositivo es 5% slo se admite TipoRecargoEquivalencia 0,5 si Fecha Operacion (Fecha Expedicion Factura si no se informa FechaOperacion) es mayor o igual que el 1 de julio de 2022 y el 31 de diciembre de 2022. +1168 = Si el TipoImpositivo es 5% slo se admite TipoRecargoEquivalencia 0,62 si Fecha Operacion (Fecha Expedicion Factura si no se informa FechaOperacion) es mayor o igual que el 1 de enero de 2023 y el 30 de septiembre de 2024. +1169 = Si el TipoImpositivo es 7,5% entre el 1 de octubre de 2024 y el 31 de diciembre de 2024, slo se admite TipoRecargoEquivalencia 1. +1170 = Si el TipoImpositivo es 0%, desde el 1 de octubre del 2024, slo se admite TipoRecargoEquivalencia 0,26. +1171 = El valor del campo Subsanacion o RechazoPrevio no se encuentra en los valores permitidos. +1172 = El valor del campo NIF u ObligadoEmision son nulos. +1173 = Slo se permite que la fecha de operacin sea superior a la fecha actual si los detalles del desglose son ClaveRegimen 14 o 15 e Impuesto 01, 03 o vaco. +1174 = El valor del campo FechaExpedicionFactura del bloque RegistroAnteriores incorrecto. +1175 = El valor del campo NumSerieFactura del bloque RegistroAnterior es incorrecto. +1176 = El valor de campo NIF del bloque SistemaInformatico es incorrecto. +1177 = El valor de campo IdSistemaInformatico del bloque SistemaInformatico es incorrecto. +1178 = Error en el bloque de Tercero. +1179 = Error en el bloque de SistemaInformatico. +1180 = Error en el bloque de Encadenamiento. +1181 = El valor del campo CalificacionOperacion es incorrecto. +1182 = El valor del campo OperacionExenta es incorrecto. +1183 = El campo FacturaSimplificadaArticulos7273 solo se podr rellenar con S si TipoFactura es de tipo F1 o F3 o R1 o R2 o R3 o R4. +1184 = El campo FacturaSinIdentifDestinatarioArt61d solo acepta valores S o N. +1185 = El campo FacturaSinIdentifDestinatarioArt61d solo se podr rellenar con S si TipoFactura es de tipo F2 o R5. +1186 = Si EmitidaPorTercerosODestinatario es igual a T el bloque Tercero ser de cumplimentacin obligatoria. +1187 = Slo se podr cumplimentarse el bloque Tercero si el valor de EmitidaPorTercerosODestinatario es T. +1188 = El NIF del bloque Tercero debe ser diferente al NIF del ObligadoEmision. +1189 = Si TipoFactura es F1 o F3 o R1 o R2 o R3 o R4 el bloque Destinatarios tiene que estar cumplimentado. +1190 = Si TipoFactura es F2 o R5 el bloque Destinatarios no puede estar cumplimentado. +1191 = Si TipoFactura es R3 slo se admitir NIF o IDType = 07. +1192 = Si TipoFactura es R2 slo se admitir NIF o IDType = 07 o 02. +1193 = En el bloque Destinatarios si se identifica mediante NIF, el NIF debe estar identificado y ser distinto del NIF ObligadoEmision. +1194 = El valor del campo TipoImpositivo es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura posterior o igual a 1 de julio de 2022 e inferior o igual a 30 de septiembre de 2024. +1195 = Al menos uno de los dos campos OperacionExenta o CalificacionOperacion deben estar informados. +1196 = OperacionExenta o CalificacionOperacion no pueden ser ambos informados ya que son excluyentes entre s. +1197 = Si CalificacionOperacion es S2 TipoFactura solo puede ser F1, F3, R1, R2, R3 y R4. +1198 = Si CalificacionOperacion es S2 TipoImpositivo y CuotaRepercutida deberan tener valor 0. +1199 = Si Impuesto es '01' (IVA), '03' (IGIC) o no se cumplimenta y ClaveRegimen es 01 no pueden marcarse las OperacionExenta E2, E3. +1200 = Si ClaveRegimen es 03 CalificacionOperacion slo puede ser S1. +1201 = Si ClaveRegimen es 04 CalificacionOperacion slo puede ser S2 o bien OperacionExenta. +1202 = Si ClaveRegimen es 06 TipoFactura no puede ser F2, F3, R5 y BaseImponibleACoste debe estar cumplimentado. +1203 = Si ClaveRegimen es 07 OperacionExenta no puede ser E2, E3, E4 y E5 o CalificacionOperacion no puede ser S2, N1, N2. +1205 = Si ClaveRegimen es 10 CalificacionOperacion tiene que ser N1, TipoFactura F1 y Destinatarios estar identificada mediante NIF. +1206 = Si ClaveRegimen es 11 TipoImpositivo ha de ser 21%. +1207 = La CuotaRepercutida solo podr ser distinta de 0 si CalificacionOperacion es S1. +1208 = Si CalificacionOperacion es S1 y BaseImponibleACoste no est cumplimentada, TipoImpositivo y CuotaRepercutida son obligatorios. +1209 = Si CalificacionOperacion es S1 y ClaveRegimen es 06, TipoImpositivo y CuotaRepercutida son obligatorios. +1210 = El campo ImporteTotal tiene un valor incorrecto para el valor de los campos BaseImponibleOimporteNoSujeto, CuotaRepercutida y CuotaRecargoEquivalencia suministrados. +1211 = El bloque Tercero no puede estar identificado con IDType=07 (no censado). +1212 = El campo TipoUsoPosibleSoloVerifactu solo acepta valores N o S. +1213 = El campo TipoUsoPosibleMultiOT solo acepta valores N o S. +1214 = El campo NumeroOTAlta debe ser númerico positivo de 4 posiciones. +1215 = Error en el bloque de ObligadoEmision. +1216 = El campo CuotaTotal tiene un valor incorrecto para el valor de los campos CuotaRepercutida y CuotaRecargoEquivalencia suministrados. +1217 = Error identificando el IDEmisorFactura. +1218 = El valor del campo Impuesto es incorrecto. +1219 = El valor del campo IDEmisorFactura es incorrecto. +1220 = El valor del campo NombreSistemaInformatico es incorrecto. +1221 = El valor del campo IDType del sistema informtico es incorrecto. +1222 = El valor del campo ID del bloque IDOtro es incorrecto. +1223 = En el bloque SistemaInformatico si se cumplimenta NIF, no deber existir la agrupacin IDOtro y viceversa, pero es obligatorio que se cumplimente uno de los dos. +1224 = Si se informa el campo GeneradoPor deber existir la agrupacin Generador y viceversa. +1225 = El valor del campo GeneradoPor es incorrecto. +1226 = El campo IndicadorMultiplesOT solo acepta valores N o S. +1227 = Si el campo GeneradoPor es igual a E debe estar relleno el campo NIF del bloque Generador. +1228 = En el bloque Generador si se cumplimenta NIF, no deber existir la agrupacin IDOtro y viceversa, pero es obligatorio que se cumplimente uno de los dos. +1229 = Si el valor de GeneradoPor es igual a T el valor del campo IDType del bloque Generador no debe ser 07 (No censado). +1230 = Si el valor de GeneradoPor es igual a D y el CodigoPais tiene valor ES, el valor del campo IDType del bloque Generador debe ser 03 o 07. +1231 = El valor del campo IDType del bloque Generador es incorrecto. +1232 = Si se identifica a travs de la agrupacin IDOtro y CodigoPais tiene valor ES, el campo IDType debe valer 03. +1233 = Si se identifica a travs de la agrupacin IDOtro y CodigoPais tiene valor ES, el campo IDType debe valer 07. +1234 = Si se identifica a travs de la agrupacin IDOtro y CodigoPais tiene valor ES, el campo IDType debe valer 03 o 07. +1235 = El valor del campo TipoImpositivo es incorrecto, el valor informado slo es permitido para FechaOperacion o FechaExpedicionFactura posterior o igual a 1 de octubre de 2024 e inferior o igual a 31 de diciembre de 2024. +1236 = El valor del campo TipoImpositivo es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura posterior o igual a 1 de octubre de 2024 e inferior o igual a 31 de diciembre de 2024. +1237 = El valor del campo CalificacionOperacion est informado como N1 o N2 y el impuesto es IVA. No se puede informar de los campos TipoImpositivo (excepto con ClaveRegimen 17), CuotaRepercutida (excepto con ClaveRegimen 17), TipoRecargoEquivalencia y CuotaRecargoEquivalencia. +1238 = Si la operacion es exenta no se puede informar ninguno de los campos TipoImpositivo, CuotaRepercutida, TipoRecargoEquivalencia y CuotaRecargoEquivalencia. +1239 = Error en el bloque Destinatario. +1240 = Error en el bloque de IdEmisorFactura. +1241 = Error tcnico al obtener el SistemaInformatico. +1242 = No existe el sistema informtico. +1243 = Error tcnico al obtener el clculo de la fecha del huso horario. +1244 = El campo FechaHoraHusoGenRegistro tiene un formato incorrecto. +1245 = Si el campo Impuesto est vaco o tiene valor 01 o 03 el campo ClaveRegimen debe de estar cumplimentado. +1246 = El valor del campo ClaveRegimen es incorrecto. +1247 = El valor del campo TipoHuella es incorrecto. +1248 = El valor del campo Periodo es incorrecto. +1249 = El valor del campo IndicadorRepresentante tiene un valor incorrecto. +1250 = El valor de fecha desde debe ser menor que el valor de fecha hasta en RangoFechaExpedicion. +1251 = El valor del campo IdVersion tiene un valor incorrecto +1252 = Si ClaveRegimen es 08 el campo CalificacionOperacion tiene que ser N2 e ir siempre informado. +1253 = El valor del campo RefExterna tiene un valor incorrecto. +1254 = Si FechaOperacion (FechaExpedicionFactura si no se informa FechaOperacion) es anterior a 01/01/2021 no se permite el valor 'XI' para Identificaciones NIF-IVA +1255 = Si FechaOperacion (FechaExpedicionFactura si no se informa FechaOperacion) es mayor o igual que 01/02/2021 no se permite el valor 'GB' para Identificaciones NIF-IVA +1256 = Error tcnico al obtener el lmite de la fecha de expedicin. +1257 = El campo BaseImponibleACoste solo puede estar cumplimentado si la ClaveRegimen es = '06' o Impuesto = '02' (IPSI) o Impuesto = '05' (Otros). +1258 = El valor de campo NIF del bloque Generador es incorrecto. +1259 = En el bloque Generador si se identifica mediante NIF, el NIF debe estar identificado y ser distinto del NIF ObligadoEmision. +1260 = el campo ClaveRegimen solo debe de estar cumplimentado si el campo Impuesto est vaco o tiene valor 01 o 03 + +3000 = Registro de facturacin duplicado. +3001 = El registro de facturacin ya ha sido dado de baja. +3002 = No existe el registro de facturacin. +3003 = El presentador no tiene los permisos necesarios para actualizar este registro de facturacin. + +********* Listado de cdigos de error que producen la aceptacin del registro de facturacin en el sistema (posteriormente deben ser subsanados) ********* +2000 = El clculo de la huella suministrada es incorrecta. +2001 = El NIF del bloque Destinatarios no est identificado en el censo de la AEAT. +2002 = La longitud de huella del registro anterior no cumple con las especificaciones. +2003 = El contenido de la huella del registro anterior no cumple con las especificaciones. +2004 = El valor del campo FechaHoraHusoGenRegistro debe ser la fecha actual del sistema de la AEAT, admitindose un margen de error de: +2005 = El campo ImporteTotal tiene un valor incorrecto para el valor de los campos BaseImponibleOimporteNoSujeto, CuotaRepercutida y CuotaRecargoEquivalencia suministrados. +2006 = El campo CuotaTotal tiene un valor incorrecto para el valor de los campos CuotaRepercutida y CuotaRecargoEquivalencia suministrados. \ No newline at end of file From 536cb49e7440dd570bd251452cfbf00588f7cf7a Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 5 Nov 2024 12:41:46 +0000 Subject: [PATCH 04/56] Hash generation --- go.mod | 2 + go.sum | 4 + internal/doc/breakdown.go | 60 +++++++++++++ internal/doc/doc.go | 72 +++++----------- internal/doc/document.go | 22 ++--- internal/doc/hash.go | 60 +++++++++++++ internal/doc/invoice.go | 85 ++++++++++++++----- internal/doc/parties.go | 1 + internal/doc/software.go | 27 ++++++ internal/doc/validations.go | 46 ++++++++++ internal/gateways/errors.go | 55 ++++++++++++ internal/gateways/gateways.go | 72 ++++++++++++++++ ...acion.wsdl.xml => SistemaFacturacion.wsdl} | 0 13 files changed, 423 insertions(+), 83 deletions(-) create mode 100644 internal/doc/breakdown.go create mode 100644 internal/doc/parties.go create mode 100644 internal/doc/software.go create mode 100644 internal/doc/validations.go create mode 100644 internal/gateways/errors.go create mode 100644 internal/gateways/gateways.go rename test/schema/{SistemaFacturacion.wsdl.xml => SistemaFacturacion.wsdl} (100%) diff --git a/go.mod b/go.mod index 4e8735e..7f92370 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beevik/etree v1.4.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/go-resty/resty/v2 v2.15.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect @@ -31,6 +32,7 @@ require ( github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/crypto v0.26.0 // indirect + golang.org/x/net v0.28.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect ) diff --git a/go.sum b/go.sum index 1a9d58b..0b81fca 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= +github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -63,6 +65,8 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/doc/breakdown.go b/internal/doc/breakdown.go new file mode 100644 index 0000000..1df011d --- /dev/null +++ b/internal/doc/breakdown.go @@ -0,0 +1,60 @@ +package doc + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +// DesgloseIVA contains the breakdown of VAT amounts +type DesgloseIVA struct { + DetalleIVA []*DetalleIVA +} + +// DetalleIVA details about taxed amounts +type DetalleIVA struct { + TipoImpositivo string + BaseImponible string + CuotaImpuesto string + TipoRecargoEquiv string + CuotaRecargoEquiv string +} + +func newDesglose(inv *bill.Invoice) (*Desglose, error) { + desglose := &Desglose{} + + for _, c := range inv.Totals.Taxes.Categories { + for _, r := range c.Rates { + detalleDesglose, err := buildDetalleDesglose(c, r) + if err != nil { + return nil, err + } + desglose.DetalleDesglose = append(desglose.DetalleDesglose, detalleDesglose) + } + } + + return desglose, nil +} + +func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesglose, error) { + detalle := &DetalleDesglose{ + BaseImponibleOImporteNoSujeto: r.Base.String(), + CuotaRepercutida: r.Amount.String(), + } + + // MAL - mapear a codigo + if c.Code != cbc.CodeEmpty { + detalle.Impuesto = c.Code.String() + } + + if r.Key == tax.RateExempt { + detalle.OperacionExenta = "1" + } else { + detalle.CalificacionOperacion = "1" + } + + if r.Percent != nil { + detalle.TipoImpositivo = r.Percent.String() + } + return detalle, nil +} diff --git a/internal/doc/doc.go b/internal/doc/doc.go index 4e50929..9fced52 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -9,6 +9,9 @@ import ( "github.com/invopop/gobl/bill" ) +// for needed for timezones +var location *time.Location + type VeriFactu struct { Cabecera *Cabecera RegistroFactura *RegistroFactura @@ -19,16 +22,12 @@ type RegistroFactura struct { RegistroAnulacion *RegistroAnulacion } -type Software struct { - NombreRazon string - NIF string - IdSistemaInformatico string - NombreSistemaInformatico string - NumeroInstalacion string - TipoUsoPosibleSoloVerifactu string - TipoUsoPosibleMultiOT string - IndicadorMultiplesOT string - Version string +func init() { + var err error + location, err = time.LoadLocation("Europe/Madrid") + if err != nil { + panic(err) + } } func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { @@ -39,10 +38,10 @@ func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { } } - goblWithoutIncludedTaxes, err := inv.RemoveIncludedTaxes() - if err != nil { - return nil, err - } + // goblWithoutIncludedTaxes, err := inv.RemoveIncludedTaxes() + // if err != nil { + // return nil, err + // } doc := &VeriFactu{ Cabecera: &Cabecera{ @@ -54,47 +53,25 @@ func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { RegistroFactura: &RegistroFactura{}, } - doc.SetIssueTimestamp(ts) - - // Add customers - if inv.Customer != nil { - dest, err := newDestinatario(inv.Customer) - if err != nil { - return nil, err - } - doc.Sujetos.Destinatarios = &Destinatarios{ - IDDestinatario: []*IDDestinatario{dest}, - } - } - - if inv.Type == bill.InvoiceTypeCreditNote { - doc.RegistroFactura.RegistroAnulacion, err = newDatosFactura(goblWithoutIncludedTaxes) - } else { - doc.RegistroFactura.RegistroAlta, err = newDatosFactura(goblWithoutIncludedTaxes) - } - if err != nil { - return nil, err - } + doc.RegistroFactura.RegistroAlta.FechaHoraHusoGenRegistro = formatDateTimeZone(ts) return doc, nil } -// QRCodes generates the QR codes for this invoice, but requires the Fingerprint to have been -// generated first. -func (doc *TicketBAI) QRCodes() *Codes { - if doc.HuellaTBAI == nil { +func (doc *VeriFactu) QRCodes() *Codes { + if doc.RegistroFactura.RegistroAlta.Encadenamiento == nil { return nil } - return doc.generateCodes(doc.zone) + return doc.generateCodes() } // Bytes returns the XML document bytes -func (doc *TicketBAI) Bytes() ([]byte, error) { +func (doc *VeriFactu) Bytes() ([]byte, error) { return toBytes(doc) } // BytesIndent returns the indented XML document bytes -func (doc *TicketBAI) BytesIndent() ([]byte, error) { +func (doc *VeriFactu) BytesIndent() ([]byte, error) { return toBytesIndent(doc) } @@ -144,11 +121,6 @@ type timeLocationable interface { In(*time.Location) time.Time } -// TODO -// func formatDate(ts timeLocationable) string { -// return ts.In(location).Format("02-01-2006") -// } - -// func formatTime(ts timeLocationable) string { -// return ts.In(location).Format("15:04:05") -// } +func formatDateTimeZone(ts timeLocationable) string { + return ts.In(location).Format("2006-01-02T15:04:05-07:00") +} diff --git a/internal/doc/document.go b/internal/doc/document.go index 7b588d6..9cedd58 100644 --- a/internal/doc/document.go +++ b/internal/doc/document.go @@ -31,7 +31,7 @@ type RemisionRequerimiento struct { type RegistroAlta struct { IDVersion string `xml:"sum1:IDVersion"` - IDFactura IDFactura `xml:"sum1:IDFactura"` + IDFactura *IDFactura `xml:"sum1:IDFactura"` RefExterna string `xml:"sum1:RefExterna,omitempty"` NombreRazonEmisor string `xml:"sum1:NombreRazonEmisor"` Subsanacion string `xml:"sum1:Subsanacion,omitempty"` @@ -40,21 +40,21 @@ type RegistroAlta struct { TipoRectificativa string `xml:"sum1:TipoRectificativa,omitempty"` FacturasRectificadas []*FacturaRectificada `xml:"sum1:FacturasRectificadas>sum1:FacturaRectificada,omitempty"` FacturasSustituidas []*FacturaSustituida `xml:"sum1:FacturasSustituidas>sum1:FacturaSustituida,omitempty"` - ImporteRectificacion ImporteRectificacion `xml:"sum1:ImporteRectificacion,omitempty"` + ImporteRectificacion *ImporteRectificacion `xml:"sum1:ImporteRectificacion,omitempty"` FechaOperacion string `xml:"sum1:FechaOperacion"` DescripcionOperacion string `xml:"sum1:DescripcionOperacion"` FacturaSimplificadaArt7273 string `xml:"sum1:FacturaSimplificadaArt7273,omitempty"` FacturaSinIdentifDestinatarioArt61d string `xml:"sum1:FacturaSinIdentifDestinatarioArt61d,omitempty"` Macrodato string `xml:"sum1:Macrodato,omitempty"` EmitidaPorTerceroODestinatario string `xml:"sum1:EmitidaPorTerceroODestinatario,omitempty"` - Tercero Tercero `xml:"sum1:Tercero,omitempty"` + Tercero *Tercero `xml:"sum1:Tercero,omitempty"` Destinatarios []*Destinatario `xml:"sum1:Destinatarios>sum1:Destinatario,omitempty"` Cupon string `xml:"sum1:Cupon,omitempty"` - Desglose Desglose `xml:"sum1:Desglose"` - CuotaTotal float64 `xml:"sum1:CuotaTotal"` - ImporteTotal float64 `xml:"sum1:ImporteTotal"` - Encadenamiento Encadenamiento `xml:"sum1:Encadenamiento"` - SistemaInformatico Software `xml:"sum1:SistemaInformatico"` + Desglose *Desglose `xml:"sum1:Desglose"` + CuotaTotal string `xml:"sum1:CuotaTotal"` + ImporteTotal string `xml:"sum1:ImporteTotal"` + Encadenamiento *Encadenamiento `xml:"sum1:Encadenamiento"` + SistemaInformatico *Software `xml:"sum1:SistemaInformatico"` FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"` NumRegistroAcuerdoFacturacion string `xml:"sum1:NumRegistroAcuerdoFacturacion,omitempty"` IdAcuerdoSistemaInformatico string `xml:"sum1:IdAcuerdoSistemaInformatico,omitempty"` @@ -78,9 +78,9 @@ type FacturaSustituida struct { } type ImporteRectificacion struct { - BaseRectificada float64 `xml:"sum1:BaseRectificada"` - CuotaRectificada float64 `xml:"sum1:CuotaRectificada"` - CuotaRecargoRectificado float64 `xml:"sum1:CuotaRecargoRectificado"` + BaseRectificada string `xml:"sum1:BaseRectificada"` + CuotaRectificada string `xml:"sum1:CuotaRectificada"` + CuotaRecargoRectificado string `xml:"sum1:CuotaRecargoRectificado"` } type Tercero struct { diff --git a/internal/doc/hash.go b/internal/doc/hash.go index 1025707..5271ffb 100644 --- a/internal/doc/hash.go +++ b/internal/doc/hash.go @@ -1 +1,61 @@ package doc + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// FormatField returns a formatted field as key=value or key= if the value is empty. +func FormatField(key, value string) string { + value = strings.TrimSpace(value) // Remove whitespace + if value == "" { + return fmt.Sprintf("%s=", key) + } + return fmt.Sprintf("%s=%s", key, value) +} + +// ConcatenateFields builds the concatenated string based on Verifactu requirements. +func makeRegistroAltaFields(inv *RegistroAlta) string { + fields := []string{ + FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), + FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), + FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), + FormatField("TipoFactura", inv.TipoFactura), + FormatField("CuotaTotal", inv.CuotaTotal), + FormatField("ImporteTotal", inv.ImporteTotal), + FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), + FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), + } + return strings.Join(fields, "&") +} + +func makeRegistroAnulacionFields(inv *RegistroAnulacion) string { + fields := []string{ + FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), + FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), + FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), + FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), + FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), + } + return strings.Join(fields, "&") +} + +// GenerateHash generates the SHA-256 hash for the invoice data. +func GenerateHash(inv *RegistroFactura) string { + // Concatenate fields according to Verifactu specifications + var concatenatedString string + if inv.RegistroAlta != nil { + concatenatedString = makeRegistroAltaFields(inv.RegistroAlta) + } else if inv.RegistroAnulacion != nil { + concatenatedString = makeRegistroAnulacionFields(inv.RegistroAnulacion) + } + + // Convert to UTF-8 byte array and hash it with SHA-256 + hash := sha256.New() + hash.Write([]byte(concatenatedString)) + + // Convert the hash to hexadecimal and make it uppercase + return strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) +} diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index 272eed8..25e1f0e 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -5,37 +5,32 @@ import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" ) func newInvoice(inv *bill.Invoice) (*RegistroAlta, error) { - // Create new RegistroAlta with required fields + description, err := newDescription(inv.Notes) + if err != nil { + return nil, err + } + reg := &RegistroAlta{ IDVersion: "1.0", - IDFactura: IDFactura{ + IDFactura: &IDFactura{ IDEmisorFactura: inv.Supplier.TaxID.Code.String(), NumSerieFactura: invoiceNumber(inv.Series, inv.Code), FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), }, NombreRazonEmisor: inv.Supplier.Name, - FechaOperacion: inv.IssueDate.Format("02-01-2006"), - DescripcionOperacion: inv.Notes.String(), - ImporteTotal: inv.Totals.Total.Float64(), - CuotaTotal: inv.Totals.Tax.Float64(), + TipoFactura: mapInvoiceType(inv), + DescripcionOperacion: description, + ImporteTotal: newImporteTotal(inv), + CuotaTotal: newTotalTaxes(inv), + SistemaInformatico: newSoftware(inv), } - // Set TipoFactura based on invoice type - switch inv.Type { - case bill.InvoiceTypeStandard: - reg.TipoFactura = "F1" - case bill.InvoiceTypeCreditNote: - reg.TipoFactura = "R1" - reg.TipoRectificativa = "I" // Por diferencias - case bill.InvoiceTypeDebitNote: - reg.TipoFactura = "R1" - reg.TipoRectificativa = "I" - } - - // Add destinatarios if customer exists if inv.Customer != nil { dest := &Destinatario{ IDDestinatario: IDDestinatario{ @@ -43,14 +38,13 @@ func newInvoice(inv *bill.Invoice) (*RegistroAlta, error) { }, } - // Handle tax ID if inv.Customer.TaxID != nil { - if inv.Customer.TaxID.Country.Is("ES") { + if inv.Customer.TaxID.Country == l10n.ES.Tax() { dest.IDDestinatario.NIF = inv.Customer.TaxID.Code.String() } else { dest.IDDestinatario.IDOtro = IDOtro{ CodigoPais: inv.Customer.TaxID.Country.String(), - IDType: "04", // NIF-IVA + IDType: "04", // Code for foreign tax IDs L7 ID: inv.Customer.TaxID.Code.String(), } } @@ -59,6 +53,10 @@ func newInvoice(inv *bill.Invoice) (*RegistroAlta, error) { reg.Destinatarios = []*Destinatario{dest} } + if inv.HasTags(tax.TagSimplified) { + reg.FacturaSimplificadaArt7273 = "S" + } + return reg, nil } @@ -68,3 +66,46 @@ func invoiceNumber(series cbc.Code, code cbc.Code) string { } return fmt.Sprintf("%s-%s", series, code) } + +func mapInvoiceType(inv *bill.Invoice) string { + switch inv.Type { + case bill.InvoiceTypeStandard: + return "F1" + case bill.ShortSchemaInvoice: + return "F2" + } + return "F1" +} + +func newDescription(notes []*cbc.Note) (string, error) { + for _, note := range notes { + if note.Key == cbc.NoteKeyGeneral { + return note.Text, nil + } + } + return "", validationErr(`notes: missing note with key '%s'`, cbc.NoteKeyGeneral) +} + +func newImporteTotal(inv *bill.Invoice) string { + totalWithDiscounts := inv.Totals.Total + + totalTaxes := num.MakeAmount(0, 2) + for _, category := range inv.Totals.Taxes.Categories { + if !category.Retained { + totalTaxes = totalTaxes.Add(category.Amount) + } + } + + return totalWithDiscounts.Add(totalTaxes).String() +} + +func newTotalTaxes(inv *bill.Invoice) string { + totalTaxes := num.MakeAmount(0, 2) + for _, category := range inv.Totals.Taxes.Categories { + if !category.Retained { + totalTaxes = totalTaxes.Add(category.Amount) + } + } + + return totalTaxes.String() +} diff --git a/internal/doc/parties.go b/internal/doc/parties.go new file mode 100644 index 0000000..1025707 --- /dev/null +++ b/internal/doc/parties.go @@ -0,0 +1 @@ +package doc diff --git a/internal/doc/software.go b/internal/doc/software.go new file mode 100644 index 0000000..cd092d4 --- /dev/null +++ b/internal/doc/software.go @@ -0,0 +1,27 @@ +package doc + +import "github.com/invopop/gobl/bill" + +type Software struct { + NombreRazon string + NIF string + IdSistemaInformatico string + NombreSistemaInformatico string + NumeroInstalacion string + TipoUsoPosibleSoloVerifactu string + TipoUsoPosibleMultiOT string + IndicadorMultiplesOT string + Version string +} + +func newSoftware(inv *bill.Invoice) *Software { + software := &Software{ + NombreRazon: "Invopop SL", + NIF: inv.Supplier.TaxID.Code.String(), + IDOtro: "04", + IDSistemaInformatico: "F1", + Version: "1.0", + NumeroInstalacion: "1", + } + return software +} diff --git a/internal/doc/validations.go b/internal/doc/validations.go new file mode 100644 index 0000000..895fb68 --- /dev/null +++ b/internal/doc/validations.go @@ -0,0 +1,46 @@ +package doc + +import ( + "fmt" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/l10n" +) + +// ValidationError is a simple wrapper around validation errors +type ValidationError struct { + text string +} + +// Error implements the error interface for ValidationError. +func (e *ValidationError) Error() string { + return e.text +} + +func validationErr(text string, args ...any) error { + return &ValidationError{ + text: fmt.Sprintf(text, args...), + } +} + +func validate(inv *bill.Invoice, zone l10n.Code) error { + if inv.Type == bill.InvoiceTypeCorrective { + return validationErr("corrective invoices not supported, use credit or debit notes") + } + + if inv.Supplier == nil || inv.Supplier.TaxID == nil { + return nil // ignore + } + + for _, l := range inv.Lines { + if len(l.Charges) > 0 { + return validationErr("charges are not supported") + } + } + + if len(inv.Charges) > 0 { + return validationErr("charges are not supported") + } + + return nil +} diff --git a/internal/gateways/errors.go b/internal/gateways/errors.go new file mode 100644 index 0000000..9479b44 --- /dev/null +++ b/internal/gateways/errors.go @@ -0,0 +1,55 @@ +package gateways + +// Error codes and their descriptions from VeriFactu +var ErrorCodes = map[string]string{ + // Errors that cause rejection of the entire submission + "4102": "El XML no cumple el esquema. Falta informar campo obligatorio.", + "4103": "Se ha producido un error inesperado al parsear el XML.", + "4104": "Error en la cabecera: el valor del campo NIF del bloque ObligadoEmision no está identificado.", + "4105": "Error en la cabecera: el valor del campo NIF del bloque Representante no está identificado.", + "4106": "El formato de fecha es incorrecto.", + "4107": "El NIF no está identificado en el censo de la AEAT.", + "4108": "Error técnico al obtener el certificado.", + "4109": "El formato del NIF es incorrecto.", + "4110": "Error técnico al comprobar los apoderamientos.", + "4111": "Error técnico al crear el trámite.", + "4112": "El titular del certificado debe ser Obligado Emisión, Colaborador Social, Apoderado o Sucesor.", + "4113": "El XML no cumple con el esquema: se ha superado el límite permitido de registros para el bloque.", + "4114": "El XML no cumple con el esquema: se ha superado el límite máximo permitido de facturas a registrar.", + "4115": "El valor del campo NIF del bloque ObligadoEmision es incorrecto.", + "4116": "Error en la cabecera: el campo NIF del bloque ObligadoEmision tiene un formato incorrecto.", + "4117": "Error en la cabecera: el campo NIF del bloque Representante tiene un formato incorrecto.", + "4118": "Error técnico: la dirección no se corresponde con el fichero de entrada.", + "4119": "Error al informar caracteres cuya codificación no es UTF-8.", + "4120": "Error en la cabecera: el valor del campo FechaFinVeriFactu es incorrecto, debe ser 31-12-20XX, donde XX corresponde con el año actual o el anterior.", + "4121": "Error en la cabecera: el valor del campo Incidencia es incorrecto.", + "4122": "Error en la cabecera: el valor del campo RefRequerimiento es incorrecto.", + "4123": "Error en la cabecera: el valor del campo NIF del bloque Representante no está identificado en el censo de la AEAT.", + "4124": "Error en la cabecera: el valor del campo Nombre del bloque Representante no está identificado en el censo de la AEAT.", + "4125": "Error en la cabecera: el campo RefRequerimiento es obligatorio.", + "4126": "Error en la cabecera: el campo RefRequerimiento solo debe informarse en sistemas No VERIFACTU.", + "4127": "Error en la cabecera: la remisión voluntaria solo debe informarse en sistemas VERIFACTU.", + "4128": "Error técnico en la recuperación del valor del Gestor de Tablas.", + "4129": "Error en la cabecera: el campo FinRequerimiento es obligatorio.", + "4130": "Error en la cabecera: el campo FinRequerimiento solo debe informarse en sistemas No VERIFACTU.", + "4131": "Error en la cabecera: el valor del campo FinRequerimiento es incorrecto.", + "4132": "El titular del certificado debe ser el destinatario que realiza la consulta, un Apoderado o Sucesor", + "3500": "Error técnico de base de datos: error en la integridad de la información.", + "3501": "Error técnico de base de datos.", + "3502": "La factura consultada para el suministro de pagos/cobros/inmuebles no existe.", + "3503": "La factura especificada no pertenece al titular registrado en el sistema.", + + // Errors that cause rejection of the invoice or entire request if in header + "1100": "Valor o tipo incorrecto del campo.", + "1101": "El valor del campo CodigoPais es incorrecto.", + "1102": "El valor del campo IDType es incorrecto.", + "1103": "El valor del campo ID es incorrecto.", + "1104": "El valor del campo NumSerieFactura es incorrecto.", + "1105": "El valor del campo FechaExpedicionFactura es incorrecto.", + "1106": "El valor del campo TipoFactura no está incluido en la lista de valores permitidos.", + "1107": "El valor del campo TipoRectificativa es incorrecto.", + "1108": "El NIF del IDEmisorFactura debe ser el mismo que el NIF del ObligadoEmision.", + "1109": "El NIF no está identificado en el censo de la AEAT.", + "1110": "El NIF no está identificado en el censo de la AEAT.", + "1111": "El campo CodigoPais es obligatorio cuando IDType es distinto de 02.", +} diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go new file mode 100644 index 0000000..2910cee --- /dev/null +++ b/internal/gateways/gateways.go @@ -0,0 +1,72 @@ +package gateways + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/go-resty/resty/v2" + "github.com/invopop/gobl.verifactu/internal/doc" +) + +// Environment defines the environment to use for connections +type Environment string + +// Environment to use for connections +const ( + EnvironmentProduction Environment = "production" + EnvironmentTesting Environment = "testing" + + // Production environment not published yet + ProductionBaseURL = "xxxxxxxx" + TestingBaseURL = "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP" +) + +// Standard gateway error responses +var ( + ErrConnection = errors.New("connection") + ErrInvalidRequest = errors.New("invalid request") +) + +// Connection defines what is expected from a connection to a gateway. +type VerifactuConn struct { + client *resty.Client +} + +// New instantiates a new connection using the provided config. +func NewVerifactu(env Environment) *VerifactuConn { + c := new(VerifactuConn) + c.client = resty.New() + + switch env { + case EnvironmentProduction: + c.client.SetBaseURL(ProductionBaseURL) + default: + c.client.SetBaseURL(TestingBaseURL) + } + c.client.SetDebug(os.Getenv("DEBUG") == "true") + return c +} + +func (v *VerifactuConn) Post(ctx context.Context, doc doc.VeriFactu) error { + payload, err := doc.Bytes() + if err != nil { + return fmt.Errorf("generating payload: %w", err) + } + + res, err := v.client.R(). + SetContext(ctx). + SetBody(payload). + Post() + + if err != nil { + return fmt.Errorf("%w: verifactu: %s", ErrConnection, err.Error()) + } + + if res.StatusCode() != 200 { + return fmt.Errorf("%w: verifactu: status %d", ErrInvalidRequest, res.StatusCode()) + } + + return nil +} diff --git a/test/schema/SistemaFacturacion.wsdl.xml b/test/schema/SistemaFacturacion.wsdl similarity index 100% rename from test/schema/SistemaFacturacion.wsdl.xml rename to test/schema/SistemaFacturacion.wsdl From 1666847ba9bef7e1af714c8c5c1e5e8a7cb6b112 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 5 Nov 2024 17:15:59 +0000 Subject: [PATCH 05/56] First Conversion --- cmd/{ => gobl.verifactu}/convert.go | 32 ++-- cmd/{ => gobl.verifactu}/main.go | 12 +- cmd/{ => gobl.verifactu}/root.go | 2 +- cmd/{ => gobl.verifactu}/version.go | 0 document.go | 48 ------ go.mod | 1 + go.sum | 2 + internal/doc/cancel.go | 55 ++++-- internal/doc/doc.go | 64 ++++--- internal/doc/document.go | 15 +- internal/doc/invoice.go | 28 ++- internal/doc/qr_code.go | 20 ++- internal/doc/software.go | 16 +- internal/doc/validations.go | 46 ----- internal/gateways/gateways.go | 2 +- test/data/credit-note-es-es-tbai.json | 158 +++++++++++++++++ test/data/invoice-es-es-freelance.json | 138 +++++++++++++++ test/data/invoice-es-es-vateqs-provider.json | 169 +++++++++++++++++++ test/data/invoice-es-es-vateqs-retailer.json | 88 ++++++++++ test/data/invoice-es-es.env.json | 91 ++++++++++ test/data/invoice-es-es.json | 137 +++++++++++++++ test/data/invoice-es-nl-b2b.json | 117 +++++++++++++ test/data/invoice-es-nl-digital-b2c.json | 96 +++++++++++ test/data/invoice-es-pt-digital.json | 122 +++++++++++++ test/data/invoice-es-simplified.json | 112 ++++++++++++ test/data/invoice-es-usd.json | 166 ++++++++++++++++++ test/data/out/invoice-es-es.xml | 72 ++++++++ verifactu.go | 54 ++++-- 28 files changed, 1670 insertions(+), 193 deletions(-) rename cmd/{ => gobl.verifactu}/convert.go (67%) rename cmd/{ => gobl.verifactu}/main.go (66%) rename cmd/{ => gobl.verifactu}/root.go (96%) rename cmd/{ => gobl.verifactu}/version.go (100%) delete mode 100644 document.go delete mode 100644 internal/doc/validations.go create mode 100644 test/data/credit-note-es-es-tbai.json create mode 100644 test/data/invoice-es-es-freelance.json create mode 100644 test/data/invoice-es-es-vateqs-provider.json create mode 100644 test/data/invoice-es-es-vateqs-retailer.json create mode 100644 test/data/invoice-es-es.env.json create mode 100644 test/data/invoice-es-es.json create mode 100644 test/data/invoice-es-nl-b2b.json create mode 100644 test/data/invoice-es-nl-digital-b2c.json create mode 100644 test/data/invoice-es-pt-digital.json create mode 100644 test/data/invoice-es-simplified.json create mode 100644 test/data/invoice-es-usd.json create mode 100755 test/data/out/invoice-es-es.xml diff --git a/cmd/convert.go b/cmd/gobl.verifactu/convert.go similarity index 67% rename from cmd/convert.go rename to cmd/gobl.verifactu/convert.go index 37115e2..16202a4 100644 --- a/cmd/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/invopop/gobl" + verifactu "github.com/invopop/gobl.verifactu" "github.com/spf13/cobra" ) @@ -51,24 +52,25 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("unmarshaling gobl envelope: %w", err) } - // tbai, err := ticketbai.New(&ticketbai.Software{}) - // if err != nil { - // return fmt.Errorf("creating ticketbai client: %w", err) - // } + vf, err := verifactu.New(&verifactu.Software{}) + if err != nil { + return fmt.Errorf("creating verifactu client: %w", err) + } - // doc, err := tbai.NewDocument(env) - // if err != nil { - // panic(err) - // } + // rework + doc, err := vf.NewVerifactu(env) + if err != nil { + return fmt.Errorf("creating verifactu document: %w", err) + } - // data, err := doc.BytesIndent() - // if err != nil { - // return fmt.Errorf("generating ticketbai xml: %w", err) - // } + data, err := doc.BytesIndent() + if err != nil { + return fmt.Errorf("generating verifactu xml: %w", err) + } - // if _, err = out.Write(append(data, '\n')); err != nil { - // return fmt.Errorf("writing ticketbai xml: %w", err) - // } + if _, err = out.Write(append(data, '\n')); err != nil { + return fmt.Errorf("writing verifactu xml: %w", err) + } return nil } diff --git a/cmd/main.go b/cmd/gobl.verifactu/main.go similarity index 66% rename from cmd/main.go rename to cmd/gobl.verifactu/main.go index b3536e4..3b5e84f 100644 --- a/cmd/main.go +++ b/cmd/gobl.verifactu/main.go @@ -1,16 +1,20 @@ -// Package main is the entry point for the gobl.verifactu command. +// Package main provides a CLI interface for the library package main import ( "context" + "errors" "fmt" "os" "os/signal" "syscall" + + "github.com/joho/godotenv" ) // build data provided by goreleaser and mage setup var ( + name = "gobl.verifactu" version = "dev" date = "" ) @@ -26,6 +30,12 @@ func run() error { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + if err := godotenv.Load(".env"); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to load .env file: %w", err) + } + } + return root().cmd().ExecuteContext(ctx) } diff --git a/cmd/root.go b/cmd/gobl.verifactu/root.go similarity index 96% rename from cmd/root.go rename to cmd/gobl.verifactu/root.go index a11aed6..003c10a 100644 --- a/cmd/root.go +++ b/cmd/gobl.verifactu/root.go @@ -16,7 +16,7 @@ func root() *rootOpts { func (o *rootOpts) cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "gobl.verifactu", + Use: name, SilenceUsage: true, SilenceErrors: true, } diff --git a/cmd/version.go b/cmd/gobl.verifactu/version.go similarity index 100% rename from cmd/version.go rename to cmd/gobl.verifactu/version.go diff --git a/document.go b/document.go deleted file mode 100644 index 90987b2..0000000 --- a/document.go +++ /dev/null @@ -1,48 +0,0 @@ -package verifactu - -import ( - "github.com/invopop/gobl" - "github.com/invopop/gobl.verifactu/internal/doc" - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/l10n" -) - -// Document is a wrapper around the internal TicketBAI document. -type Document struct { - env *gobl.Envelope - inv *bill.Invoice - vf *doc.VeriFactu - client *Client -} - -// NewDocument creates a new TicketBAI document from the provided GOBL Envelope. -// The envelope must contain a valid Invoice. -func (c *Client) NewDocument(env *gobl.Envelope) (*Document, error) { - d := new(Document) - - // Set the client for later use - d.client = c - - var ok bool - d.env = env - d.inv, ok = d.env.Extract().(*bill.Invoice) - if !ok { - return nil, ErrOnlyInvoices - } - - // Check the existing stamps, we might not need to do anything - // if d.hasExistingStamps() { - // return nil, ErrAlreadyProcessed - // } - if d.inv.Supplier.TaxID.Country != l10n.ES.Tax() { - return nil, ErrNotSpanish - } - - var err error - d.vf, err = doc.NewVeriFactu(d.inv, c.CurrentTime()) - if err != nil { - return nil, err - } - - return d, nil -} diff --git a/go.mod b/go.mod index 7f92370..f6e9c0e 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/invopop/jsonschema v0.12.0 // indirect github.com/invopop/validation v0.7.0 // indirect github.com/invopop/yaml v0.3.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index 0b81fca..fc73dba 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/invopop/xmldsig v0.10.0 h1:pPKX4dLWi4GEQQVs1dXbH3EW/Thm/5Bf9db1nYqfoU github.com/invopop/xmldsig v0.10.0/go.mod h1:YBUFOoEo8mJ0B6ssQzReE2EICznYdxwyY6Y/LAn2/Z0= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= diff --git a/internal/doc/cancel.go b/internal/doc/cancel.go index 05e0ad9..e471edb 100644 --- a/internal/doc/cancel.go +++ b/internal/doc/cancel.go @@ -1,17 +1,46 @@ package doc +import ( + "time" + + "github.com/invopop/gobl/bill" +) + type RegistroAnulacion struct { - IDVersion string `xml:"IDVersion"` - IDFactura IDFactura `xml:"IDFactura"` - RefExterna string `xml:"RefExterna,omitempty"` - SinRegistroPrevio string `xml:"SinRegistroPrevio"` - RechazoPrevio string `xml:"RechazoPrevio,omitempty"` - GeneradoPor string `xml:"GeneradoPor"` - Generador *Tercero `xml:"Generador"` - Encadenamiento Encadenamiento `xml:"Encadenamiento"` - SistemaInformatico Software `xml:"SistemaInformatico"` - FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` - TipoHuella string `xml:"TipoHuella"` - Huella string `xml:"Huella"` - Signature string `xml:"Signature"` + IDVersion string `xml:"IDVersion"` + IDFactura *IDFactura `xml:"IDFactura"` + RefExterna string `xml:"RefExterna,omitempty"` + SinRegistroPrevio string `xml:"SinRegistroPrevio"` + RechazoPrevio string `xml:"RechazoPrevio,omitempty"` + GeneradoPor string `xml:"GeneradoPor"` + Generador *Tercero `xml:"Generador"` + Encadenamiento *Encadenamiento `xml:"Encadenamiento"` + SistemaInformatico *Software `xml:"SistemaInformatico"` + FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` + TipoHuella string `xml:"TipoHuella"` + Huella string `xml:"Huella"` + Signature string `xml:"Signature"` +} + +// NewRegistroAnulacion provides support for credit notes +func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time) (*RegistroAnulacion, error) { + reg := &RegistroAnulacion{ + IDVersion: "1.0", + IDFactura: &IDFactura{ + IDEmisorFactura: inv.Supplier.TaxID.Code.String(), + NumSerieFactura: invoiceNumber(inv.Series, inv.Code), + FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), + }, + SinRegistroPrevio: "N", + GeneradoPor: "1", // Generated by issuer + Generador: &Tercero{ + Nif: inv.Supplier.TaxID.Code.String(), + NombreRazon: inv.Supplier.Name, + }, + FechaHoraHusoGenRegistro: formatDateTimeZone(ts), + TipoHuella: "01", + } + + return reg, nil + } diff --git a/internal/doc/doc.go b/internal/doc/doc.go index 9fced52..dc1b27f 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -12,14 +12,30 @@ import ( // for needed for timezones var location *time.Location -type VeriFactu struct { - Cabecera *Cabecera - RegistroFactura *RegistroFactura +// IssuerRole defines the role of the issuer in the invoice. +type IssuerRole string + +// IssuerRole constants +const ( + IssuerRoleSupplier IssuerRole = "E" + IssuerRoleCustomer IssuerRole = "D" + IssuerRoleThirdParty IssuerRole = "T" +) + +// ValidationError is a simple wrapper around validation errors +type ValidationError struct { + text string +} + +// Error implements the error interface for ValidationError. +func (e *ValidationError) Error() string { + return e.text } -type RegistroFactura struct { - RegistroAlta *RegistroAlta - RegistroAnulacion *RegistroAnulacion +func validationErr(text string, args ...any) error { + return &ValidationError{ + text: fmt.Sprintf(text, args...), + } } func init() { @@ -31,19 +47,10 @@ func init() { } func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { - if inv.Type == bill.InvoiceTypeCreditNote { - - if err := inv.Invert(); err != nil { - return nil, err - } - } - - // goblWithoutIncludedTaxes, err := inv.RemoveIncludedTaxes() - // if err != nil { - // return nil, err - // } doc := &VeriFactu{ + SUMNamespace: SUM, + SUM1Namespace: SUM1, Cabecera: &Cabecera{ Obligado: Obligado{ NombreRazon: inv.Supplier.Name, @@ -53,7 +60,19 @@ func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { RegistroFactura: &RegistroFactura{}, } - doc.RegistroFactura.RegistroAlta.FechaHoraHusoGenRegistro = formatDateTimeZone(ts) + if inv.Type == bill.InvoiceTypeCreditNote { + reg, err := NewRegistroAnulacion(inv, ts) + if err != nil { + return nil, err + } + doc.RegistroFactura.RegistroAnulacion = reg + } else { + reg, err := NewRegistroAlta(inv, ts) + if err != nil { + return nil, err + } + doc.RegistroFactura.RegistroAlta = reg + } return doc, nil } @@ -93,15 +112,6 @@ func toBytesIndent(doc any) ([]byte, error) { return buf.Bytes(), nil } -func toBytesCanonical(doc any) ([]byte, error) { - buf, err := buffer(doc, "", false) - if err != nil { - return nil, err - } - - return buf.Bytes(), nil -} - func buffer(doc any, base string, indent bool) (*bytes.Buffer, error) { buf := bytes.NewBufferString(base) diff --git a/internal/doc/document.go b/internal/doc/document.go index 9cedd58..58e09ca 100644 --- a/internal/doc/document.go +++ b/internal/doc/document.go @@ -1,12 +1,25 @@ package doc -// "github.com/invopop/gobl/pkg/xmldsig" +import "encoding/xml" const ( SUM = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" SUM1 = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" ) +type VeriFactu struct { + XMLName xml.Name `xml:"sum:Verifactu"` + Cabecera *Cabecera `xml:"sum:Cabecera"` + RegistroFactura *RegistroFactura `xml:"sum:RegistroFactura"` + SUMNamespace string `xml:"xmlns:sum,attr"` + SUM1Namespace string `xml:"xmlns:sum1,attr"` +} + +type RegistroFactura struct { + RegistroAlta *RegistroAlta `xml:"sum1:RegistroAlta,omitempty"` + RegistroAnulacion *RegistroAnulacion `xml:"sum1:RegistroAnulacion,omitempty"` +} + type Cabecera struct { Obligado Obligado `xml:"sum1:Obligado"` Representante *Obligado `xml:"sum1:Representante,omitempty"` diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index 25e1f0e..2768377 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -2,6 +2,7 @@ package doc import ( "fmt" + "time" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" @@ -10,12 +11,17 @@ import ( "github.com/invopop/gobl/tax" ) -func newInvoice(inv *bill.Invoice) (*RegistroAlta, error) { +func NewRegistroAlta(inv *bill.Invoice, ts time.Time) (*RegistroAlta, error) { description, err := newDescription(inv.Notes) if err != nil { return nil, err } + desglose, err := newDesglose(inv) + if err != nil { + return nil, err + } + reg := &RegistroAlta{ IDVersion: "1.0", IDFactura: &IDFactura{ @@ -23,12 +29,14 @@ func newInvoice(inv *bill.Invoice) (*RegistroAlta, error) { NumSerieFactura: invoiceNumber(inv.Series, inv.Code), FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), }, - NombreRazonEmisor: inv.Supplier.Name, - TipoFactura: mapInvoiceType(inv), - DescripcionOperacion: description, - ImporteTotal: newImporteTotal(inv), - CuotaTotal: newTotalTaxes(inv), - SistemaInformatico: newSoftware(inv), + NombreRazonEmisor: inv.Supplier.Name, + TipoFactura: mapInvoiceType(inv), + DescripcionOperacion: description, + ImporteTotal: newImporteTotal(inv), + CuotaTotal: newTotalTaxes(inv), + SistemaInformatico: newSoftware(), + Desglose: desglose, + FechaHoraHusoGenRegistro: formatDateTimeZone(ts), } if inv.Customer != nil { @@ -54,7 +62,11 @@ func newInvoice(inv *bill.Invoice) (*RegistroAlta, error) { } if inv.HasTags(tax.TagSimplified) { - reg.FacturaSimplificadaArt7273 = "S" + if inv.Type == bill.InvoiceTypeStandard { + reg.FacturaSimplificadaArt7273 = "S" + } else { + reg.FacturaSinIdentifDestinatarioArt61d = "S" + } } return reg, nil diff --git a/internal/doc/qr_code.go b/internal/doc/qr_code.go index 3ff6e00..6381d1d 100644 --- a/internal/doc/qr_code.go +++ b/internal/doc/qr_code.go @@ -17,14 +17,15 @@ type Codes struct { } const ( - BaseURL = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?" + TestURL = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?" + ProdURL = "https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?nif=89890001K&numserie=12345678-G33&fecha=01-09-2024&importe=241.4" ) // var crcTable = crc8.MakeTable(crc8.CRC8) // generateCodes will generate the QR and URL codes for the invoice -func (doc *VeriFactu) generateCodes(inv *RegistroAlta) *Codes { - urlCode := doc.generateURLCode(inv) +func (doc *VeriFactu) generateCodes() *Codes { + urlCode := doc.generateURLCodeAlta() // qrCode := doc.generateQRCode(urlCode) return &Codes{ @@ -34,16 +35,17 @@ func (doc *VeriFactu) generateCodes(inv *RegistroAlta) *Codes { } // generateURLCode generates the encoded URL code with parameters. -func (doc *VeriFactu) generateURLCode(inv *RegistroAlta) string { +func (doc *VeriFactu) generateURLCodeAlta() string { + // URL encode each parameter - nif := url.QueryEscape(doc.Cabecera.Obligado.NIF) - numSerie := url.QueryEscape(inv.IDFactura.NumSerieFactura) - fecha := url.QueryEscape(inv.IDFactura.FechaExpedicionFactura) - importe := url.QueryEscape(fmt.Sprintf("%.2f", inv.ImporteTotal)) + nif := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) + numSerie := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) + fecha := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) + importe := url.QueryEscape(doc.RegistroFactura.RegistroAlta.ImporteTotal) // Build the URL urlCode := fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", - BaseURL, nif, numSerie, fecha, importe) + TestURL, nif, numSerie, fecha, importe) return urlCode } diff --git a/internal/doc/software.go b/internal/doc/software.go index cd092d4..eb0c238 100644 --- a/internal/doc/software.go +++ b/internal/doc/software.go @@ -1,7 +1,5 @@ package doc -import "github.com/invopop/gobl/bill" - type Software struct { NombreRazon string NIF string @@ -14,14 +12,14 @@ type Software struct { Version string } -func newSoftware(inv *bill.Invoice) *Software { +func newSoftware() *Software { software := &Software{ - NombreRazon: "Invopop SL", - NIF: inv.Supplier.TaxID.Code.String(), - IDOtro: "04", - IDSistemaInformatico: "F1", - Version: "1.0", - NumeroInstalacion: "1", + NombreRazon: "xxxxxxxx", + NIF: "0123456789", + // IDOtro: "04", + // IDSistemaInformatico: "F1", + Version: "1.0", + NumeroInstalacion: "1", } return software } diff --git a/internal/doc/validations.go b/internal/doc/validations.go deleted file mode 100644 index 895fb68..0000000 --- a/internal/doc/validations.go +++ /dev/null @@ -1,46 +0,0 @@ -package doc - -import ( - "fmt" - - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/l10n" -) - -// ValidationError is a simple wrapper around validation errors -type ValidationError struct { - text string -} - -// Error implements the error interface for ValidationError. -func (e *ValidationError) Error() string { - return e.text -} - -func validationErr(text string, args ...any) error { - return &ValidationError{ - text: fmt.Sprintf(text, args...), - } -} - -func validate(inv *bill.Invoice, zone l10n.Code) error { - if inv.Type == bill.InvoiceTypeCorrective { - return validationErr("corrective invoices not supported, use credit or debit notes") - } - - if inv.Supplier == nil || inv.Supplier.TaxID == nil { - return nil // ignore - } - - for _, l := range inv.Lines { - if len(l.Charges) > 0 { - return validationErr("charges are not supported") - } - } - - if len(inv.Charges) > 0 { - return validationErr("charges are not supported") - } - - return nil -} diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 2910cee..68e1364 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -58,7 +58,7 @@ func (v *VerifactuConn) Post(ctx context.Context, doc doc.VeriFactu) error { res, err := v.client.R(). SetContext(ctx). SetBody(payload). - Post() + Post(v.client.BaseURL) if err != nil { return fmt.Errorf("%w: verifactu: %s", ErrConnection, err.Error()) diff --git a/test/data/credit-note-es-es-tbai.json b/test/data/credit-note-es-es-tbai.json new file mode 100644 index 0000000..bd0e4db --- /dev/null +++ b/test/data/credit-note-es-es-tbai.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "96ae76238c90546f3fa238637b07a0080e9d41670ca32029a274c705453cb225" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-tbai-v1" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "credit-note", + "series": "FR", + "code": "012", + "issue_date": "2022-02-01", + "currency": "EUR", + "preceding": [ + { + "type": "standard", + "issue_date": "2022-01-10", + "series": "SAMPLE", + "code": "085", + "ext": { + "es-tbai-correction": "R2" + } + } + ], + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "San Frantzisko", + "locality": "Bilbo", + "region": "Bizkaia", + "code": "48003", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Customer", + "tax_id": { + "country": "ES", + "code": "54387763P" + }, + "addresses": [ + { + "num": "13", + "street": "Calle del Barro", + "locality": "Alcañiz", + "region": "Teruel", + "code": "44600", + "country": "ES" + } + ], + "emails": [ + { + "addr": "customer@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "1620.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "zero", + "percent": "0.0%" + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + }, + { + "key": "zero", + "base": "10.00", + "percent": "0.0%", + "amount": "0.00" + } + ], + "amount": "340.20" + } + ], + "sum": "340.20" + }, + "tax": "340.20", + "total_with_tax": "1970.20", + "payable": "1970.20" + }, + "notes": [ + { + "key": "general", + "text": "Some random description" + } + ] + } +} \ No newline at end of file diff --git a/test/data/invoice-es-es-freelance.json b/test/data/invoice-es-es-freelance.json new file mode 100644 index 0000000..4fd2316 --- /dev/null +++ b/test/data/invoice-es-es-freelance.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "65b068a83bb3db05f03e0c7c9cbe04763e204db186c773fa825e1b3d5aeb60fc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-001", + "issue_date": "2022-02-01", + "currency": "EUR", + "supplier": { + "name": "MªF. Services", + "tax_id": { + "country": "ES", + "code": "58384285G" + }, + "people": [ + { + "name": { + "given": "MARIA FRANCISCA", + "surname": "MONTERO", + "surname2": "ESTEBAN" + } + } + ], + "addresses": [ + { + "num": "9", + "street": "CAMÍ MADRID", + "locality": "CANENA", + "region": "JAÉN", + "code": "23480", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + }, + { + "cat": "IRPF", + "percent": "15.0%" + } + ], + "total": "1620.00" + } + ], + "payment": { + "terms": { + "key": "instant" + }, + "instructions": { + "key": "credit-transfer", + "credit_transfer": [ + { + "iban": "ES06 0128 0011 3901 0008 1391", + "name": "Bankinter" + } + ] + } + }, + "totals": { + "sum": "1620.00", + "total": "1620.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + } + ], + "amount": "340.20" + }, + { + "code": "IRPF", + "retained": true, + "rates": [ + { + "base": "1620.00", + "percent": "15.0%", + "amount": "243.00" + } + ], + "amount": "243.00" + } + ], + "sum": "97.20" + }, + "tax": "97.20", + "total_with_tax": "1717.20", + "payable": "1717.20" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-es-es-vateqs-provider.json b/test/data/invoice-es-es-vateqs-provider.json new file mode 100644 index 0000000..ae24a1b --- /dev/null +++ b/test/data/invoice-es-es-vateqs-provider.json @@ -0,0 +1,169 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "d2cf7a48f6435ddfd9809dd9589be5c329098895ba6da96291b882d43db0151f" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-001", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": { + "prices_include": "VAT" + }, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Simple Goods Store", + "tax_id": { + "country": "ES", + "code": "54387763P" + }, + "addresses": [ + { + "num": "43", + "street": "Calle Mayor", + "locality": "Madrid", + "region": "Madrid", + "code": "28003" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Mugs from provider", + "price": "10.00" + }, + "sum": "100.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard+eqs", + "percent": "21.0%", + "surcharge": "5.2%" + } + ], + "total": "100.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Delivery Costs", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "10.00" + } + ], + "payment": { + "terms": { + "key": "due-date", + "due_dates": [ + { + "date": "2021-10-30", + "amount": "45.72", + "percent": "40%" + }, + { + "date": "2021-11-30", + "amount": "68.58", + "percent": "60%" + } + ] + }, + "advances": [ + { + "date": "2021-09-01", + "description": "Deposit paid upfront", + "amount": "25.00" + } + ], + "instructions": { + "key": "credit-transfer", + "credit_transfer": [ + { + "iban": "ES06 0128 0011 3901 0008 1391", + "bic": "BKBKESMMXXX", + "name": "Bankinter" + } + ] + } + }, + "totals": { + "sum": "110.00", + "tax_included": "19.09", + "total": "90.91", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard+eqs", + "base": "82.64", + "percent": "21.0%", + "surcharge": { + "percent": "5.2%", + "amount": "4.30" + }, + "amount": "17.36" + }, + { + "key": "standard", + "base": "8.26", + "percent": "21.0%", + "amount": "1.74" + } + ], + "amount": "19.09", + "surcharge": "4.30" + } + ], + "sum": "23.39" + }, + "tax": "23.39", + "total_with_tax": "114.30", + "payable": "114.30", + "advance": "25.00", + "due": "89.30" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-es-es-vateqs-retailer.json b/test/data/invoice-es-es-vateqs-retailer.json new file mode 100644 index 0000000..ff5d09e --- /dev/null +++ b/test/data/invoice-es-es-vateqs-retailer.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "9933198da084cea1342a62d80014ae53262de1c33972b52ab7093bb002136ff5" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$tags": [ + "simplified" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-001", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": { + "prices_include": "VAT" + }, + "supplier": { + "name": "Simple Goods Store", + "tax_id": { + "country": "ES", + "code": "54387763P" + }, + "addresses": [ + { + "num": "43", + "street": "Calle Mayor", + "locality": "Madrid", + "region": "Madrid", + "code": "28003" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Mugs from provider", + "price": "16.00", + "meta": { + "source": "provider" + } + }, + "sum": "160.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "160.00" + } + ], + "totals": { + "sum": "160.00", + "tax_included": "27.77", + "total": "132.23", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "132.23", + "percent": "21.0%", + "amount": "27.77" + } + ], + "amount": "27.77" + } + ], + "sum": "27.77" + }, + "tax": "27.77", + "total_with_tax": "160.00", + "payable": "160.00" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-es-es.env.json b/test/data/invoice-es-es.env.json new file mode 100644 index 0000000..7549812 --- /dev/null +++ b/test/data/invoice-es-es.env.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "892491c186d6e68b8b1b33098c25c09467278962884aee99e1c51026148086ae" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-001", + "issue_date": "2022-02-01", + "currency": "EUR", + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Item being purchased", + "price": "100.00" + }, + "sum": "1000.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "1000.00" + } + ], + "totals": { + "sum": "1000.00", + "total": "1000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1000.00", + "percent": "21.0%", + "amount": "210.00" + } + ], + "amount": "210.00" + } + ], + "sum": "210.00" + }, + "tax": "210.00", + "total_with_tax": "1210.00", + "payable": "1210.00" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-es-es.json b/test/data/invoice-es-es.json new file mode 100644 index 0000000..1aca426 --- /dev/null +++ b/test/data/invoice-es-es.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-facturae-v3" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-001", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": { + "ext": { + "es-facturae-doc-type": "FC", + "es-facturae-invoice-class": "OO" + } + }, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "1620.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "zero", + "percent": "0.0%" + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + }, + { + "key": "zero", + "base": "10.00", + "percent": "0.0%", + "amount": "0.00" + } + ], + "amount": "340.20" + } + ], + "sum": "340.20" + }, + "tax": "340.20", + "total_with_tax": "1970.20", + "payable": "1970.20" + }, + "notes": [ + { + "key": "general", + "text": "This is a sample invoice" + } + ] + } +} \ No newline at end of file diff --git a/test/data/invoice-es-nl-b2b.json b/test/data/invoice-es-nl-b2b.json new file mode 100644 index 0000000..8aec2d7 --- /dev/null +++ b/test/data/invoice-es-nl-b2b.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "320cd30d7d2713ab79966ddb727d25194f79fe472bf3b1700344d5776e956c6c" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$tags": [ + "reverse-charge" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-X-002", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": {}, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "NL", + "code": "000099995B57" + } + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Services exported", + "price": "20.00", + "unit": "day" + }, + "sum": "200.00", + "taxes": [ + { + "cat": "VAT", + "rate": "exempt" + } + ], + "total": "200.00" + }, + { + "i": 2, + "quantity": "50", + "item": { + "name": "Branded Mugs", + "price": "7.50" + }, + "sum": "375.00", + "taxes": [ + { + "cat": "VAT", + "rate": "exempt" + } + ], + "total": "375.00" + } + ], + "totals": { + "sum": "575.00", + "total": "575.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "exempt", + "base": "575.00", + "amount": "0.00" + } + ], + "amount": "0.00" + } + ], + "sum": "0.00" + }, + "tax": "0.00", + "total_with_tax": "575.00", + "payable": "575.00" + }, + "notes": [ + { + "key": "legal", + "src": "reverse-charge", + "text": "Reverse Charge / Inversión del sujeto pasivo." + } + ] + } +} \ No newline at end of file diff --git a/test/data/invoice-es-nl-digital-b2c.json b/test/data/invoice-es-nl-digital-b2c.json new file mode 100644 index 0000000..5ebed34 --- /dev/null +++ b/test/data/invoice-es-nl-digital-b2c.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "1c315a855ea6df7b7df5f6645d5c63552ca9cd58f14d6c11db7c3ff03ad12b9f" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$tags": [ + "customer-rates" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-X-002", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": {}, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "NL" + } + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Services exported", + "price": "100.00" + }, + "sum": "1000.00", + "taxes": [ + { + "cat": "VAT", + "country": "NL", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "1000.00" + } + ], + "totals": { + "sum": "1000.00", + "total": "1000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "country": "NL", + "base": "1000.00", + "percent": "21.0%", + "amount": "210.00" + } + ], + "amount": "210.00" + } + ], + "sum": "210.00" + }, + "tax": "210.00", + "total_with_tax": "1210.00", + "payable": "1210.00" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-es-pt-digital.json b/test/data/invoice-es-pt-digital.json new file mode 100644 index 0000000..a26ee5a --- /dev/null +++ b/test/data/invoice-es-pt-digital.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "5d6d6423d5895dbb2c8ca93b5d85fc1df8b57e414b2f0303e496e48614850f05" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$tags": [ + "customer-rates" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-X-002", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": {}, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "PT" + }, + "addresses": [ + { + "street": "Rua do Hotelzinho", + "locality": "Lisboa", + "code": "1000-000" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "10", + "item": { + "name": "Services exported", + "price": "20.00", + "unit": "day" + }, + "sum": "200.00", + "taxes": [ + { + "cat": "VAT", + "country": "PT", + "rate": "standard", + "percent": "23.0%" + } + ], + "total": "200.00" + }, + { + "i": 2, + "quantity": "50", + "item": { + "name": "Branded Mugs", + "price": "7.50" + }, + "sum": "375.00", + "taxes": [ + { + "cat": "VAT", + "country": "PT", + "rate": "standard", + "percent": "23.0%" + } + ], + "total": "375.00" + } + ], + "totals": { + "sum": "575.00", + "total": "575.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "country": "PT", + "base": "575.00", + "percent": "23.0%", + "amount": "132.25" + } + ], + "amount": "132.25" + } + ], + "sum": "132.25" + }, + "tax": "132.25", + "total_with_tax": "707.25", + "payable": "707.25" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-es-simplified.json b/test/data/invoice-es-simplified.json new file mode 100644 index 0000000..d653c55 --- /dev/null +++ b/test/data/invoice-es-simplified.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "b358beb476a00bb382625762c55082a5ed78a74b620a3552146e5de9afcb7e17" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$tags": [ + "simplified" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-001", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": {}, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Main product", + "price": "90.00" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "1620.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Something else", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1630.00", + "percent": "21.0%", + "amount": "342.30" + } + ], + "amount": "342.30" + } + ], + "sum": "342.30" + }, + "tax": "342.30", + "total_with_tax": "1972.30", + "payable": "1972.30" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-es-usd.json b/test/data/invoice-es-usd.json new file mode 100644 index 0000000..499df5d --- /dev/null +++ b/test/data/invoice-es-usd.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "b45bc74199faed76717bf154aeeb8e5e4a15d393cd423b8509f049f8f9a068bc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "series": "EXPORT", + "code": "001", + "issue_date": "2024-05-09", + "currency": "USD", + "exchange_rates": [ + { + "from": "USD", + "to": "EUR", + "amount": "0.875967" + }, + { + "from": "MXN", + "to": "USD", + "amount": "0.059197" + } + ], + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer Inc.", + "tax_id": { + "country": "US" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services from Spain", + "currency": "USD", + "price": "100.00", + "alt_prices": [ + { + "currency": "EUR", + "value": "90.00" + } + ], + "unit": "h" + }, + "sum": "2000.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "200.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "1800.00" + }, + { + "i": 2, + "quantity": "10", + "item": { + "name": "Development services from Mexico", + "currency": "USD", + "price": "88.80", + "alt_prices": [ + { + "currency": "MXN", + "value": "1500.00" + } + ], + "unit": "h" + }, + "sum": "888.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "888.00" + }, + { + "i": 3, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "zero", + "percent": "0.0%" + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "2698.00", + "total": "2698.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "2688.00", + "percent": "21.0%", + "amount": "564.48" + }, + { + "key": "zero", + "base": "10.00", + "percent": "0.0%", + "amount": "0.00" + } + ], + "amount": "564.48" + } + ], + "sum": "564.48" + }, + "tax": "564.48", + "total_with_tax": "3262.48", + "payable": "3262.48" + } + } +} \ No newline at end of file diff --git a/test/data/out/invoice-es-es.xml b/test/data/out/invoice-es-es.xml new file mode 100755 index 0000000..7056a4e --- /dev/null +++ b/test/data/out/invoice-es-es.xml @@ -0,0 +1,72 @@ + + + + + Provide One S.L. + B98602642 + + + + + 1.0 + + B98602642 + SAMPLE-001 + 01-02-2022 + + Provide One S.L. + F1 + + + + This is a sample invoice + + + + 54387763P + Sample Consumer + + + + + + + + + + + VAT + + 1 + 21.0% + 1620.00 + 340.20 + + + VAT + + 1 + 0.0% + 10.00 + 0.00 + + + 340.20 + 1970.20 + + xxxxxxxx + 0123456789 + + + 1 + + + + 1.0 + + 2024-11-05T18:13:09+01:00 + + + + + diff --git a/verifactu.go b/verifactu.go index a71119d..4e4b23d 100644 --- a/verifactu.go +++ b/verifactu.go @@ -2,11 +2,14 @@ package verifactu import ( "errors" + "fmt" "time" + "github.com/invopop/gobl" "github.com/invopop/gobl.verifactu/internal/doc" - "github.com/invopop/gobl/l10n" - "github.com/invopop/xmldsig" + "github.com/invopop/gobl.verifactu/internal/gateways" + "github.com/invopop/gobl/bill" + // "github.com/invopop/gobl/l10n" ) // Standard error responses. @@ -23,6 +26,18 @@ type ValidationError struct { err error } +type Software struct { + NombreRazon string + NIF string + IdSistemaInformatico string + NombreSistemaInformatico string + NumeroInstalacion string + TipoUsoPosibleSoloVerifactu string + TipoUsoPosibleMultiOT string + IndicadorMultiplesOT string + Version string +} + // Error implements the error interface for ClientError. func (e *ValidationError) Error() string { return e.err.Error() @@ -34,13 +49,12 @@ func newValidationError(text string) error { // Client provides the main interface to the VeriFactu package. type Client struct { - software *doc.Software + software *Software // list *gateways.List - cert *xmldsig.Certificate - // env gateways.Environment - // issuerRole doc.IssuerRole - curTime time.Time - zone l10n.Code + env gateways.Environment + issuerRole doc.IssuerRole + curTime time.Time + // zone l10n.Code } // Option is used to configure the client. @@ -65,17 +79,17 @@ type PreviousInvoice struct { // New creates a new VeriFactu client with shared software and configuration // options for creating and sending new documents. -func New(software *doc.Software, opts ...Option) (*Client, error) { +func New(software *Software, opts ...Option) (*Client, error) { c := new(Client) c.software = software // Set default values that can be overwritten by the options - // c.env = gateways.EnvironmentTesting - // c.issuerRole = doc.IssuerRoleSupplier + c.env = gateways.EnvironmentTesting + c.issuerRole = doc.IssuerRoleSupplier - // for _, opt := range opts { - // opt(c) - // } + for _, opt := range opts { + opt(c) + } // // Create a new gateway list if none was created by the options // if c.list == nil && c.cert != nil { @@ -90,6 +104,18 @@ func New(software *doc.Software, opts ...Option) (*Client, error) { return c, nil } +func (c *Client) NewVerifactu(env *gobl.Envelope) (*doc.VeriFactu, error) { + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + return nil, fmt.Errorf("invalid type %T", env.Document) + } + doc, err := doc.NewVeriFactu(inv, c.CurrentTime()) + if err != nil { + return nil, err + } + return doc, nil +} + // CurrentTime returns the current time to use when generating // the VeriFactu document. func (c *Client) CurrentTime() time.Time { From 8afdeea0d392c99c299ebf0f6828169fe662ad17 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 5 Nov 2024 19:09:47 +0000 Subject: [PATCH 06/56] Breakdown Advances --- internal/doc/breakdown.go | 25 +++++++++++++++--- internal/doc/cancel.go | 4 +-- internal/doc/doc.go | 6 ++--- internal/doc/document.go | 4 +-- internal/doc/invoice.go | 26 +++++-------------- internal/doc/party.go | 46 +++++++++++++++++++++++++++++++++ test/data/out/invoice-es-es.xml | 4 ++- 7 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 internal/doc/party.go diff --git a/internal/doc/breakdown.go b/internal/doc/breakdown.go index 1df011d..13c7839 100644 --- a/internal/doc/breakdown.go +++ b/internal/doc/breakdown.go @@ -1,8 +1,10 @@ package doc import ( + "github.com/invopop/gobl/addon/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" ) @@ -42,15 +44,17 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl CuotaRepercutida: r.Amount.String(), } - // MAL - mapear a codigo if c.Code != cbc.CodeEmpty { - detalle.Impuesto = c.Code.String() + detalle.Impuesto = taxCategoryCode(c.Code) } if r.Key == tax.RateExempt { - detalle.OperacionExenta = "1" + detalle.OperacionExenta = r.Ext[verifactu.ExtKeyExemption].String() } else { - detalle.CalificacionOperacion = "1" + // TODO: fix + if inv.HasTag(tax.TagReverseCharge) { + detalle.OperacionCorregida = r.Ext[verifactu.ExtKeyCorrection].String() + } } if r.Percent != nil { @@ -58,3 +62,16 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl } return detalle, nil } + +func taxCategoryCode(code cbc.Code) string { + switch code { + case tax.CategoryVAT: + return "01" + case es.TaxCategoryIGIC: + return "02" + case es.TaxCategoryIPSI: + return "03" + default: + return "05" + } +} diff --git a/internal/doc/cancel.go b/internal/doc/cancel.go index e471edb..967912a 100644 --- a/internal/doc/cancel.go +++ b/internal/doc/cancel.go @@ -23,7 +23,7 @@ type RegistroAnulacion struct { } // NewRegistroAnulacion provides support for credit notes -func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time) (*RegistroAnulacion, error) { +func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, role IssuerRole) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ IDVersion: "1.0", IDFactura: &IDFactura{ @@ -34,7 +34,7 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time) (*RegistroAnulacion, SinRegistroPrevio: "N", GeneradoPor: "1", // Generated by issuer Generador: &Tercero{ - Nif: inv.Supplier.TaxID.Code.String(), + NIF: inv.Supplier.TaxID.Code.String(), NombreRazon: inv.Supplier.Name, }, FechaHoraHusoGenRegistro: formatDateTimeZone(ts), diff --git a/internal/doc/doc.go b/internal/doc/doc.go index dc1b27f..bdfe936 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -46,7 +46,7 @@ func init() { } } -func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { +func NewVeriFactu(inv *bill.Invoice, ts time.Time, role IssuerRole) (*VeriFactu, error) { doc := &VeriFactu{ SUMNamespace: SUM, @@ -61,13 +61,13 @@ func NewVeriFactu(inv *bill.Invoice, ts time.Time) (*VeriFactu, error) { } if inv.Type == bill.InvoiceTypeCreditNote { - reg, err := NewRegistroAnulacion(inv, ts) + reg, err := NewRegistroAnulacion(inv, ts, role) if err != nil { return nil, err } doc.RegistroFactura.RegistroAnulacion = reg } else { - reg, err := NewRegistroAlta(inv, ts) + reg, err := NewRegistroAlta(inv, ts, role) if err != nil { return nil, err } diff --git a/internal/doc/document.go b/internal/doc/document.go index 58e09ca..7bf5d2b 100644 --- a/internal/doc/document.go +++ b/internal/doc/document.go @@ -97,9 +97,9 @@ type ImporteRectificacion struct { } type Tercero struct { - Nif string `xml:"sum1:Nif,omitempty"` + NIF string `xml:"sum1:Nif,omitempty"` NombreRazon string `xml:"sum1:NombreRazon"` - IDOtro string `xml:"sum1:IDOtro,omitempty"` + IDOtro IDOtro `xml:"sum1:IDOtro,omitempty"` } type Destinatario struct { diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index 2768377..50167a0 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -6,12 +6,11 @@ import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/num" "github.com/invopop/gobl/tax" ) -func NewRegistroAlta(inv *bill.Invoice, ts time.Time) (*RegistroAlta, error) { +func NewRegistroAlta(inv *bill.Invoice, ts time.Time, role IssuerRole) (*RegistroAlta, error) { description, err := newDescription(inv.Notes) if err != nil { return nil, err @@ -40,25 +39,12 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time) (*RegistroAlta, error) { } if inv.Customer != nil { - dest := &Destinatario{ - IDDestinatario: IDDestinatario{ - NombreRazon: inv.Customer.Name, - }, - } - - if inv.Customer.TaxID != nil { - if inv.Customer.TaxID.Country == l10n.ES.Tax() { - dest.IDDestinatario.NIF = inv.Customer.TaxID.Code.String() - } else { - dest.IDDestinatario.IDOtro = IDOtro{ - CodigoPais: inv.Customer.TaxID.Country.String(), - IDType: "04", // Code for foreign tax IDs L7 - ID: inv.Customer.TaxID.Code.String(), - } - } - } + reg.Destinatarios = newDestinatario(inv.Customer) + } - reg.Destinatarios = []*Destinatario{dest} + if role == IssuerRoleThirdParty { + reg.EmitidaPorTerceroODestinatario = "T" + reg.Tercero = newTercero(inv.Supplier) } if inv.HasTags(tax.TagSimplified) { diff --git a/internal/doc/party.go b/internal/doc/party.go new file mode 100644 index 0000000..cee9373 --- /dev/null +++ b/internal/doc/party.go @@ -0,0 +1,46 @@ +package doc + +import ( + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" +) + +func newDestinatario(party *org.Party) []*Destinatario { + dest := &Destinatario{ + IDDestinatario: IDDestinatario{ + NombreRazon: party.Name, + }, + } + + if party.TaxID != nil { + if party.TaxID.Country == l10n.ES.Tax() { + dest.IDDestinatario.NIF = party.TaxID.Code.String() + } else { + dest.IDDestinatario.IDOtro = IDOtro{ + CodigoPais: party.TaxID.Country.String(), + IDType: "04", // Code for foreign tax IDs L7 + ID: party.TaxID.Code.String(), + } + } + } + return []*Destinatario{dest} +} + +func newTercero(party *org.Party) *Tercero { + t := &Tercero{ + NombreRazon: party.Name, + } + + if party.TaxID != nil { + if party.TaxID.Country == l10n.ES.Tax() { + t.NIF = party.TaxID.Code.String() + } else { + t.IDOtro = IDOtro{ + CodigoPais: party.TaxID.Country.String(), + IDType: "04", // Code for foreign tax IDs L7 + ID: party.TaxID.Code.String(), + } + } + } + return t +} diff --git a/test/data/out/invoice-es-es.xml b/test/data/out/invoice-es-es.xml index 7056a4e..3fdbc1d 100755 --- a/test/data/out/invoice-es-es.xml +++ b/test/data/out/invoice-es-es.xml @@ -1,5 +1,7 @@ - + Provide One S.L. From c7d7db500d18c8479b70bb2efd38c0e46c4ccc6a Mon Sep 17 00:00:00 2001 From: apardods Date: Fri, 8 Nov 2024 11:08:42 +0000 Subject: [PATCH 07/56] Breakdown fix --- internal/doc/breakdown.go | 5 ----- verifactu.go | 1 - 2 files changed, 6 deletions(-) diff --git a/internal/doc/breakdown.go b/internal/doc/breakdown.go index 13c7839..43587ae 100644 --- a/internal/doc/breakdown.go +++ b/internal/doc/breakdown.go @@ -50,11 +50,6 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl if r.Key == tax.RateExempt { detalle.OperacionExenta = r.Ext[verifactu.ExtKeyExemption].String() - } else { - // TODO: fix - if inv.HasTag(tax.TagReverseCharge) { - detalle.OperacionCorregida = r.Ext[verifactu.ExtKeyCorrection].String() - } } if r.Percent != nil { diff --git a/verifactu.go b/verifactu.go index 4e4b23d..efa43dc 100644 --- a/verifactu.go +++ b/verifactu.go @@ -17,7 +17,6 @@ var ( ErrNotSpanish = newValidationError("only spanish invoices are supported") ErrAlreadyProcessed = newValidationError("already processed") ErrOnlyInvoices = newValidationError("only invoices are supported") - ErrInvalidZone = newValidationError("invalid zone") ) // ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed From 71ff523112f6d2afcd1d5e2b1ccaf63b4bf2f2fb Mon Sep 17 00:00:00 2001 From: apardods Date: Fri, 8 Nov 2024 16:14:03 +0000 Subject: [PATCH 08/56] WIP: Strcuture changes --- cmd/gobl.verifactu/convert.go | 7 +- cmd/gobl.verifactu/root.go | 14 +++ cmd/gobl.verifactu/send.go | 96 ++++++++++++++++++++ document.go | 109 +++++++++++++++++++++++ go.mod | 2 +- go.sum | 2 + internal/doc/breakdown.go | 2 +- internal/doc/cancel.go | 13 ++- internal/doc/doc.go | 28 +++--- internal/doc/{hash.go => fingerprint.go} | 48 +++++----- internal/doc/invoice.go | 2 +- internal/doc/software.go | 21 ++--- internal/gateways/gateways.go | 8 +- verifactu.go | 58 +++++++++--- 14 files changed, 346 insertions(+), 64 deletions(-) create mode 100644 cmd/gobl.verifactu/send.go create mode 100644 document.go rename internal/doc/{hash.go => fingerprint.go} (56%) diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go index 16202a4..f7c0bb7 100644 --- a/cmd/gobl.verifactu/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -57,15 +57,14 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("creating verifactu client: %w", err) } - // rework - doc, err := vf.NewVerifactu(env) + doc, err := vf.Convert(env) if err != nil { - return fmt.Errorf("creating verifactu document: %w", err) + panic(err) } data, err := doc.BytesIndent() if err != nil { - return fmt.Errorf("generating verifactu xml: %w", err) + return fmt.Errorf("generating ticketbai xml: %w", err) } if _, err = out.Write(append(data, '\n')); err != nil { diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 003c10a..8d47a4f 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -4,10 +4,16 @@ import ( "io" "os" + verifactu "github.com/invopop/gobl.verifactu" "github.com/spf13/cobra" ) type rootOpts struct { + swNIF string + swCompanyName string + swVersion string + swLicense string + production bool } func root() *rootOpts { @@ -49,6 +55,14 @@ func (o *rootOpts) openOutput(cmd *cobra.Command, args []string) (io.WriteCloser return writeCloser{cmd.OutOrStdout()}, nil } +func (o *rootOpts) software() *verifactu.Software { + return &verifactu.Software{ + NombreRazon: o.swCompanyName, + NIF: o.swNIF, + Version: o.swVersion, + } +} + type writeCloser struct { io.Writer } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go new file mode 100644 index 0000000..3739b2b --- /dev/null +++ b/cmd/gobl.verifactu/send.go @@ -0,0 +1,96 @@ +// Package main provides the command line interface to the VeriFactu package. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/invopop/gobl" + verifactu "github.com/invopop/gobl.verifactu" + "github.com/spf13/cobra" +) + +type sendOpts struct { + *rootOpts + previous string +} + +func send(o *rootOpts) *sendOpts { + return &sendOpts{rootOpts: o} +} + +func (c *sendOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "send [infile]", + Short: "Sends the GOBL invoice to the VeriFactu service", + RunE: c.runE, + } + + f := cmd.Flags() + + f.StringVar(&c.previous, "prev", "", "Previous document fingerprint to chain with") + f.BoolVarP(&c.production, "production", "p", false, "Production environment") + + return cmd +} + +func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer input.Close() // nolint:errcheck + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(input); err != nil { + return fmt.Errorf("reading input: %w", err) + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(buf.Bytes(), env); err != nil { + return fmt.Errorf("unmarshaling gobl envelope: %w", err) + } + + opts := []verifactu.Option{ + verifactu.WithThirdPartyIssuer(), + } + + if c.production { + opts = append(opts, verifactu.InProduction()) + } else { + opts = append(opts, verifactu.InTesting()) + } + + tc, err := verifactu.New(c.software()) + if err != nil { + return err + } + + td, err := tc.Convert(env) + if err != nil { + return err + } + + err = tc.Fingerprint(td, c.previous) + if err != nil { + return err + } + + // if err := tc.Sign(td, env); err != nil { + // return err + // } + + err = tc.Post(cmd.Context(), td) + if err != nil { + return err + } + + data, err := json.Marshal(td.ChainData()) + if err != nil { + return err + } + fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) + + return nil +} diff --git a/document.go b/document.go new file mode 100644 index 0000000..03ba0d6 --- /dev/null +++ b/document.go @@ -0,0 +1,109 @@ +package verifactu + +import ( + "errors" + "fmt" + + "github.com/invopop/gobl" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl/addons/es/verifactu" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/l10n" + "github.com/invopop/xmldsig" +) + +// NewDocument creates a new Tickeverifactu document from the provided GOBL Envelope. +// The envelope must contain a valid Invoice. +func (c *Client) Convert(env *gobl.Envelope) (*doc.Tickeverifactu, error) { + // Extract the Invoice + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + return nil, errors.New("only invoices are supported") + } + // Check the existing stamps, we might not need to do anything + if hasExistingStamps(env) { + return nil, errors.New("already has stamps") + } + if inv.Supplier.TaxID.Country != l10n.ES.Tax() { + return nil, errors.New("only spanish invoices are supported") + } + + out, err := doc.NewDocument(inv, c.CurrentTime(), c.issuerRole) + if err != nil { + return nil, err + } + + return out, nil +} + +// ZoneFor determines the zone of the envelope. +func ZoneFor(env *gobl.Envelope) l10n.Code { + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + return "" + } + return zoneFor(inv) +} + +// zoneFor determines the zone of the invoice. +func zoneFor(inv *bill.Invoice) l10n.Code { + // Figure out the zone + if inv == nil || + inv.Tax == nil || + inv.Tax.Ext == nil || + inv.Tax.Ext[verifactu.ExtKeyRegion] == "" { + return "" + } + return l10n.Code(inv.Tax.Ext[verifactu.ExtKeyRegion]) +} + +// Fingerprint generates a fingerprint for the Tickeverifactu document using the +// data provided from the previous chain data. If there was no previous +// document in the chain, the parameter should be nil. The document is updated +// in place. +func (c *Client) Fingerprint(d *doc.Tickeverifactu, prev *doc.ChainData) error { + soft := &doc.Software{ + License: c.software.License, + NIF: c.software.NIF, + Name: c.software.Name, + Version: c.software.Version, + } + return d.Fingerprint(soft, prev) +} + +// Sign is used to generate the XML DSig components of the final XML document. +// This method will also update the GOBL Envelope with the QR codes that are +// generated. +func (c *Client) Sign(d *doc.Tickeverifactu, env *gobl.Envelope) error { + zone := ZoneFor(env) + dID := env.Head.UUID.String() + if err := d.Sign(dID, c.cert, c.issuerRole, zone, xmldsig.WithCurrentTime(d.IssueTimestamp)); err != nil { + return fmt.Errorf("signing: %w", err) + } + + // now generate the QR codes and add them to the envelope + codes := d.QRCodes(zone) + env.Head.AddStamp( + &head.Stamp{ + Provider: verifactu.StampCode, + Value: codes.verifactuCode, + }, + ) + env.Head.AddStamp( + &head.Stamp{ + Provider: verifactu.StampQR, + Value: codes.QRCode, + }, + ) + return nil +} + +func hasExistingStamps(env *gobl.Envelope) bool { + for _, stamp := range env.Head.Stamps { + if stamp.Provider.In(verifactu.StampCode, verifactu.StampQR) { + return true + } + } + return false +} diff --git a/go.mod b/go.mod index f6e9c0e..16daf07 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.22.1 // replace github.com/invopop/gobl => ../gobl require ( - github.com/invopop/gobl v0.204.0 + github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807 github.com/invopop/xmldsig v0.10.0 github.com/spf13/cobra v1.8.1 ) diff --git a/go.sum b/go.sum index fc73dba..e90ccc4 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/gobl v0.204.0 h1:xvpq2EtRgc3eQ/rbWjWAKtTXc9OX02NVumTSvEk3U7g= github.com/invopop/gobl v0.204.0/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807 h1:p2bmC9OhAt7IxorD5PhxstP5smfI2FGnvXK9+KuB8iM= +github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= diff --git a/internal/doc/breakdown.go b/internal/doc/breakdown.go index 43587ae..31744c1 100644 --- a/internal/doc/breakdown.go +++ b/internal/doc/breakdown.go @@ -1,7 +1,7 @@ package doc import ( - "github.com/invopop/gobl/addon/es/verifactu" + "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/regimes/es" diff --git a/internal/doc/cancel.go b/internal/doc/cancel.go index 967912a..8db5261 100644 --- a/internal/doc/cancel.go +++ b/internal/doc/cancel.go @@ -25,7 +25,7 @@ type RegistroAnulacion struct { // NewRegistroAnulacion provides support for credit notes func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, role IssuerRole) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ - IDVersion: "1.0", + IDVersion: CurrentVersion, IDFactura: &IDFactura{ IDEmisorFactura: inv.Supplier.TaxID.Code.String(), NumSerieFactura: invoiceNumber(inv.Series, inv.Code), @@ -41,6 +41,17 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, role IssuerRole) (*Re TipoHuella: "01", } + if err := reg.getEncadenamiento(); err != nil { + return nil, err + } + return reg, nil } + +func (r *RegistroAnulacion) getEncadenamiento() error { + r.Encadenamiento = &Encadenamiento{ + PrimerRegistro: "1", + } + return nil +} diff --git a/internal/doc/doc.go b/internal/doc/doc.go index bdfe936..5d787ba 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -22,6 +22,10 @@ const ( IssuerRoleThirdParty IssuerRole = "T" ) +const ( + CurrentVersion = "1.0" +) + // ValidationError is a simple wrapper around validation errors type ValidationError struct { text string @@ -46,7 +50,7 @@ func init() { } } -func NewVeriFactu(inv *bill.Invoice, ts time.Time, role IssuerRole) (*VeriFactu, error) { +func NewDocument(inv *bill.Invoice, ts time.Time, role IssuerRole) (*VeriFactu, error) { doc := &VeriFactu{ SUMNamespace: SUM, @@ -77,21 +81,25 @@ func NewVeriFactu(inv *bill.Invoice, ts time.Time, role IssuerRole) (*VeriFactu, return doc, nil } -func (doc *VeriFactu) QRCodes() *Codes { - if doc.RegistroFactura.RegistroAlta.Encadenamiento == nil { +func (d *VeriFactu) QRCodes() *Codes { + if d.RegistroFactura.RegistroAlta.Encadenamiento == nil { return nil } - return doc.generateCodes() + return d.generateCodes() +} + +func (d *VeriFactu) Fingerprint() error { + return d.GenerateHash() } // Bytes returns the XML document bytes -func (doc *VeriFactu) Bytes() ([]byte, error) { - return toBytes(doc) +func (d *VeriFactu) Bytes() ([]byte, error) { + return toBytes(d) } // BytesIndent returns the indented XML document bytes -func (doc *VeriFactu) BytesIndent() ([]byte, error) { - return toBytesIndent(doc) +func (d *VeriFactu) BytesIndent() ([]byte, error) { + return toBytesIndent(d) } func toBytes(doc any) ([]byte, error) { @@ -103,8 +111,8 @@ func toBytes(doc any) ([]byte, error) { return buf.Bytes(), nil } -func toBytesIndent(doc any) ([]byte, error) { - buf, err := buffer(doc, xml.Header, true) +func toBytesIndent(d any) ([]byte, error) { + buf, err := buffer(d, xml.Header, true) if err != nil { return nil, err } diff --git a/internal/doc/hash.go b/internal/doc/fingerprint.go similarity index 56% rename from internal/doc/hash.go rename to internal/doc/fingerprint.go index 5271ffb..0be1f8f 100644 --- a/internal/doc/hash.go +++ b/internal/doc/fingerprint.go @@ -16,9 +16,9 @@ func FormatField(key, value string) string { return fmt.Sprintf("%s=%s", key, value) } -// ConcatenateFields builds the concatenated string based on Verifactu requirements. -func makeRegistroAltaFields(inv *RegistroAlta) string { - fields := []string{ +// Concatenatef builds the concatenated string based on Verifactu requirements. +func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { + f := []string{ FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), @@ -28,34 +28,42 @@ func makeRegistroAltaFields(inv *RegistroAlta) string { FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } - return strings.Join(fields, "&") + st := strings.Join(f, "&") + hash := sha256.New() + hash.Write([]byte(st)) + + d.RegistroFactura.RegistroAlta.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) + return nil } -func makeRegistroAnulacionFields(inv *RegistroAnulacion) string { - fields := []string{ +func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { + f := []string{ FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } - return strings.Join(fields, "&") + st := strings.Join(f, "&") + hash := sha256.New() + hash.Write([]byte(st)) + + d.RegistroFactura.RegistroAnulacion.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) + return nil } // GenerateHash generates the SHA-256 hash for the invoice data. -func GenerateHash(inv *RegistroFactura) string { - // Concatenate fields according to Verifactu specifications - var concatenatedString string - if inv.RegistroAlta != nil { - concatenatedString = makeRegistroAltaFields(inv.RegistroAlta) - } else if inv.RegistroAnulacion != nil { - concatenatedString = makeRegistroAnulacionFields(inv.RegistroAnulacion) +func (d *VeriFactu) GenerateHash() error { + // Concatenate f according to Verifactu specifications + if d.RegistroFactura.RegistroAlta != nil { + if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { + return err + } + } else if d.RegistroFactura.RegistroAnulacion != nil { + if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { + return err + } } - // Convert to UTF-8 byte array and hash it with SHA-256 - hash := sha256.New() - hash.Write([]byte(concatenatedString)) - - // Convert the hash to hexadecimal and make it uppercase - return strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) + return nil } diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index 50167a0..247cbe1 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -22,7 +22,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, role IssuerRole) (*Registr } reg := &RegistroAlta{ - IDVersion: "1.0", + IDVersion: CurrentVersion, IDFactura: &IDFactura{ IDEmisorFactura: inv.Supplier.TaxID.Code.String(), NumSerieFactura: invoiceNumber(inv.Series, inv.Code), diff --git a/internal/doc/software.go b/internal/doc/software.go index eb0c238..52b12fe 100644 --- a/internal/doc/software.go +++ b/internal/doc/software.go @@ -1,15 +1,16 @@ package doc type Software struct { - NombreRazon string - NIF string - IdSistemaInformatico string - NombreSistemaInformatico string - NumeroInstalacion string - TipoUsoPosibleSoloVerifactu string - TipoUsoPosibleMultiOT string - IndicadorMultiplesOT string - Version string + NombreRazon string + NIF string + // IDOtro string + // NombreSistemaInformatico string + IdSistemaInformatico string + Version string + NumeroInstalacion string + // TipoUsoPosibleSoloVerifactu string + // TipoUsoPosibleMultiOT string + // IndicadorMultiplesOT string } func newSoftware() *Software { @@ -18,7 +19,7 @@ func newSoftware() *Software { NIF: "0123456789", // IDOtro: "04", // IDSistemaInformatico: "F1", - Version: "1.0", + Version: CurrentVersion, NumeroInstalacion: "1", } return software diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 68e1364..01e7bf5 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -30,13 +30,13 @@ var ( ) // Connection defines what is expected from a connection to a gateway. -type VerifactuConn struct { +type Conection struct { client *resty.Client } // New instantiates a new connection using the provided config. -func NewVerifactu(env Environment) *VerifactuConn { - c := new(VerifactuConn) +func NewVerifactu(env Environment) *Conection { + c := new(Conection) c.client = resty.New() switch env { @@ -49,7 +49,7 @@ func NewVerifactu(env Environment) *VerifactuConn { return c } -func (v *VerifactuConn) Post(ctx context.Context, doc doc.VeriFactu) error { +func (v *Conection) Post(ctx context.Context, doc doc.VeriFactu) error { payload, err := doc.Bytes() if err != nil { return fmt.Errorf("generating payload: %w", err) diff --git a/verifactu.go b/verifactu.go index efa43dc..7f32e5c 100644 --- a/verifactu.go +++ b/verifactu.go @@ -1,15 +1,12 @@ package verifactu import ( + "context" "errors" - "fmt" "time" - "github.com/invopop/gobl" "github.com/invopop/gobl.verifactu/internal/doc" "github.com/invopop/gobl.verifactu/internal/gateways" - "github.com/invopop/gobl/bill" - // "github.com/invopop/gobl/l10n" ) // Standard error responses. @@ -54,6 +51,7 @@ type Client struct { issuerRole doc.IssuerRole curTime time.Time // zone l10n.Code + gw *gateways.Conection } // Option is used to configure the client. @@ -103,18 +101,54 @@ func New(software *Software, opts ...Option) (*Client, error) { return c, nil } -func (c *Client) NewVerifactu(env *gobl.Envelope) (*doc.VeriFactu, error) { - inv, ok := env.Extract().(*bill.Invoice) - if !ok { - return nil, fmt.Errorf("invalid type %T", env.Document) +// WithSupplierIssuer set the issuer type to supplier. +func WithSupplierIssuer() Option { + return func(c *Client) { + c.issuerRole = doc.IssuerRoleSupplier + } +} + +// WithCustomerIssuer set the issuer type to customer. +func WithCustomerIssuer() Option { + return func(c *Client) { + c.issuerRole = doc.IssuerRoleCustomer + } +} + +// WithThirdPartyIssuer set the issuer type to third party. +func WithThirdPartyIssuer() Option { + return func(c *Client) { + c.issuerRole = doc.IssuerRoleThirdParty + } +} + +// InProduction defines the connection to use the production environment. +func InProduction() Option { + return func(c *Client) { + c.env = gateways.EnvironmentProduction + } +} + +// InTesting defines the connection to use the testing environment. +func InTesting() Option { + return func(c *Client) { + c.env = gateways.EnvironmentTesting } - doc, err := doc.NewVeriFactu(inv, c.CurrentTime()) - if err != nil { - return nil, err +} + +// Post will send the document to the VeriFactu gateway. +func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { + if err := c.gw.Post(ctx, *d); err != nil { + return err } - return doc, nil + return nil } +// Cancel will send the cancel document in the VeriFactu gateway. +// func (c *Client) Cancel(ctx context.Context, d *doc.AnulaTicketBAI) error { +// return c.gw.Cancel(ctx, d) +// } + // CurrentTime returns the current time to use when generating // the VeriFactu document. func (c *Client) CurrentTime() time.Time { From ee4b650fadd75ef0fd7f2c3bf0602263e6c21c1d Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 11 Nov 2024 19:00:22 +0000 Subject: [PATCH 09/56] Updated Cobra Commands and Gateways --- cmd/gobl.verifactu/root.go | 26 +++++--- cmd/gobl.verifactu/send.go | 8 +-- {internal/doc => doc}/breakdown.go | 23 ++----- {internal/doc => doc}/cancel.go | 0 {internal/doc => doc}/doc.go | 16 +++-- {internal/doc => doc}/document.go | 0 {internal/doc => doc}/fingerprint.go | 0 {internal/doc => doc}/invoice.go | 0 {internal/doc => doc}/parties.go | 0 {internal/doc => doc}/party.go | 0 {internal/doc => doc}/qr_code.go | 0 {internal/doc => doc}/software.go | 0 document.go | 69 ++------------------- go.mod | 2 +- go.sum | 2 + internal/gateways/errors.go | 89 ++++++++++++++++++++++++++++ internal/gateways/gateways.go | 57 +++++++++++++----- test/schema/RespuestaSuministro.xsd | 2 +- verifactu.go | 4 +- 19 files changed, 178 insertions(+), 120 deletions(-) rename {internal/doc => doc}/breakdown.go (68%) rename {internal/doc => doc}/cancel.go (100%) rename {internal/doc => doc}/doc.go (90%) rename {internal/doc => doc}/document.go (100%) rename {internal/doc => doc}/fingerprint.go (100%) rename {internal/doc => doc}/invoice.go (100%) rename {internal/doc => doc}/parties.go (100%) rename {internal/doc => doc}/party.go (100%) rename {internal/doc => doc}/qr_code.go (100%) rename {internal/doc => doc}/software.go (100%) diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 8d47a4f..b395863 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -5,7 +5,9 @@ import ( "os" verifactu "github.com/invopop/gobl.verifactu" + _ "github.com/joho/godotenv/autoload" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) type rootOpts struct { @@ -33,6 +35,22 @@ func (o *rootOpts) cmd() *cobra.Command { return cmd } +func (o *rootOpts) prepareFlags(f *pflag.FlagSet) { + f.StringVar(&o.swNIF, "sw-nif", os.Getenv("SOFTWARE_COMPANY_NIF"), "NIF of the software company") + f.StringVar(&o.swCompanyName, "sw-company-name", os.Getenv("SOFTWARE_COMPANY_NAME"), "Name of the software company") + f.StringVar(&o.swVersion, "sw-version", os.Getenv("SOFTWARE_VERSION"), "Version of the software") + f.StringVar(&o.swLicense, "sw-license", os.Getenv("SOFTWARE_LICENSE"), "License of the software") + f.BoolVarP(&o.production, "production", "p", false, "Production environment") +} + +func (o *rootOpts) software() *verifactu.Software { + return &verifactu.Software{ + NombreRazon: o.swCompanyName, + NIF: o.swNIF, + Version: o.swVersion, + } +} + func (o *rootOpts) outputFilename(args []string) string { if len(args) >= 2 && args[1] != "-" { return args[1] @@ -55,14 +73,6 @@ func (o *rootOpts) openOutput(cmd *cobra.Command, args []string) (io.WriteCloser return writeCloser{cmd.OutOrStdout()}, nil } -func (o *rootOpts) software() *verifactu.Software { - return &verifactu.Software{ - NombreRazon: o.swCompanyName, - NIF: o.swNIF, - Version: o.swVersion, - } -} - type writeCloser struct { io.Writer } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index 3739b2b..d7e5ea0 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -29,8 +29,8 @@ func (c *sendOpts) cmd() *cobra.Command { f := cmd.Flags() - f.StringVar(&c.previous, "prev", "", "Previous document fingerprint to chain with") - f.BoolVarP(&c.production, "production", "p", false, "Production environment") + f.StringVar(&c.previous, "prev", "p", "Previous document fingerprint to chain with") + f.BoolVarP(&c.production, "production", "prod", false, "Production environment") return cmd } @@ -62,7 +62,7 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { opts = append(opts, verifactu.InTesting()) } - tc, err := verifactu.New(c.software()) + tc, err := verifactu.New(c.software(), opts...) if err != nil { return err } @@ -72,7 +72,7 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { return err } - err = tc.Fingerprint(td, c.previous) + err = tc.Fingerprint(td) if err != nil { return err } diff --git a/internal/doc/breakdown.go b/doc/breakdown.go similarity index 68% rename from internal/doc/breakdown.go rename to doc/breakdown.go index 31744c1..71ce188 100644 --- a/internal/doc/breakdown.go +++ b/doc/breakdown.go @@ -3,8 +3,6 @@ package doc import ( "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" ) @@ -27,7 +25,7 @@ func newDesglose(inv *bill.Invoice) (*Desglose, error) { for _, c := range inv.Totals.Taxes.Categories { for _, r := range c.Rates { - detalleDesglose, err := buildDetalleDesglose(c, r) + detalleDesglose, err := buildDetalleDesglose(r) if err != nil { return nil, err } @@ -38,14 +36,14 @@ func newDesglose(inv *bill.Invoice) (*Desglose, error) { return desglose, nil } -func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesglose, error) { +func buildDetalleDesglose(r *tax.RateTotal) (*DetalleDesglose, error) { detalle := &DetalleDesglose{ BaseImponibleOImporteNoSujeto: r.Base.String(), CuotaRepercutida: r.Amount.String(), } - if c.Code != cbc.CodeEmpty { - detalle.Impuesto = taxCategoryCode(c.Code) + if r.Ext != nil && r.Ext[verifactu.ExtKeyTaxCategory] != "" { + detalle.Impuesto = r.Ext[verifactu.ExtKeyTaxCategory].String() } if r.Key == tax.RateExempt { @@ -57,16 +55,3 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl } return detalle, nil } - -func taxCategoryCode(code cbc.Code) string { - switch code { - case tax.CategoryVAT: - return "01" - case es.TaxCategoryIGIC: - return "02" - case es.TaxCategoryIPSI: - return "03" - default: - return "05" - } -} diff --git a/internal/doc/cancel.go b/doc/cancel.go similarity index 100% rename from internal/doc/cancel.go rename to doc/cancel.go diff --git a/internal/doc/doc.go b/doc/doc.go similarity index 90% rename from internal/doc/doc.go rename to doc/doc.go index 5d787ba..0f540f0 100644 --- a/internal/doc/doc.go +++ b/doc/doc.go @@ -88,6 +88,15 @@ func (d *VeriFactu) QRCodes() *Codes { return d.generateCodes() } +// ChainData generates the data to be used to link to this one +// in the next entry. +func (d *VeriFactu) ChainData() string { + if d.RegistroFactura.RegistroAlta != nil { + return d.RegistroFactura.RegistroAlta.Huella + } + return d.RegistroFactura.RegistroAnulacion.Huella +} + func (d *VeriFactu) Fingerprint() error { return d.GenerateHash() } @@ -97,11 +106,6 @@ func (d *VeriFactu) Bytes() ([]byte, error) { return toBytes(d) } -// BytesIndent returns the indented XML document bytes -func (d *VeriFactu) BytesIndent() ([]byte, error) { - return toBytesIndent(d) -} - func toBytes(doc any) ([]byte, error) { buf, err := buffer(doc, xml.Header, false) if err != nil { @@ -111,7 +115,7 @@ func toBytes(doc any) ([]byte, error) { return buf.Bytes(), nil } -func toBytesIndent(d any) ([]byte, error) { +func (d *VeriFactu) BytesIndent() ([]byte, error) { buf, err := buffer(d, xml.Header, true) if err != nil { return nil, err diff --git a/internal/doc/document.go b/doc/document.go similarity index 100% rename from internal/doc/document.go rename to doc/document.go diff --git a/internal/doc/fingerprint.go b/doc/fingerprint.go similarity index 100% rename from internal/doc/fingerprint.go rename to doc/fingerprint.go diff --git a/internal/doc/invoice.go b/doc/invoice.go similarity index 100% rename from internal/doc/invoice.go rename to doc/invoice.go diff --git a/internal/doc/parties.go b/doc/parties.go similarity index 100% rename from internal/doc/parties.go rename to doc/parties.go diff --git a/internal/doc/party.go b/doc/party.go similarity index 100% rename from internal/doc/party.go rename to doc/party.go diff --git a/internal/doc/qr_code.go b/doc/qr_code.go similarity index 100% rename from internal/doc/qr_code.go rename to doc/qr_code.go diff --git a/internal/doc/software.go b/doc/software.go similarity index 100% rename from internal/doc/software.go rename to doc/software.go diff --git a/document.go b/document.go index 03ba0d6..08c6153 100644 --- a/document.go +++ b/document.go @@ -2,20 +2,17 @@ package verifactu import ( "errors" - "fmt" "github.com/invopop/gobl" "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/head" "github.com/invopop/gobl/l10n" - "github.com/invopop/xmldsig" ) -// NewDocument creates a new Tickeverifactu document from the provided GOBL Envelope. +// NewDocument creates a new document from the provided GOBL Envelope. // The envelope must contain a valid Invoice. -func (c *Client) Convert(env *gobl.Envelope) (*doc.Tickeverifactu, error) { +func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { // Extract the Invoice inv, ok := env.Extract().(*bill.Invoice) if !ok { @@ -37,71 +34,17 @@ func (c *Client) Convert(env *gobl.Envelope) (*doc.Tickeverifactu, error) { return out, nil } -// ZoneFor determines the zone of the envelope. -func ZoneFor(env *gobl.Envelope) l10n.Code { - inv, ok := env.Extract().(*bill.Invoice) - if !ok { - return "" - } - return zoneFor(inv) -} - -// zoneFor determines the zone of the invoice. -func zoneFor(inv *bill.Invoice) l10n.Code { - // Figure out the zone - if inv == nil || - inv.Tax == nil || - inv.Tax.Ext == nil || - inv.Tax.Ext[verifactu.ExtKeyRegion] == "" { - return "" - } - return l10n.Code(inv.Tax.Ext[verifactu.ExtKeyRegion]) -} - -// Fingerprint generates a fingerprint for the Tickeverifactu document using the +// Fingerprint generates a fingerprint for the document using the // data provided from the previous chain data. If there was no previous // document in the chain, the parameter should be nil. The document is updated // in place. -func (c *Client) Fingerprint(d *doc.Tickeverifactu, prev *doc.ChainData) error { - soft := &doc.Software{ - License: c.software.License, - NIF: c.software.NIF, - Name: c.software.Name, - Version: c.software.Version, - } - return d.Fingerprint(soft, prev) -} - -// Sign is used to generate the XML DSig components of the final XML document. -// This method will also update the GOBL Envelope with the QR codes that are -// generated. -func (c *Client) Sign(d *doc.Tickeverifactu, env *gobl.Envelope) error { - zone := ZoneFor(env) - dID := env.Head.UUID.String() - if err := d.Sign(dID, c.cert, c.issuerRole, zone, xmldsig.WithCurrentTime(d.IssueTimestamp)); err != nil { - return fmt.Errorf("signing: %w", err) - } - - // now generate the QR codes and add them to the envelope - codes := d.QRCodes(zone) - env.Head.AddStamp( - &head.Stamp{ - Provider: verifactu.StampCode, - Value: codes.verifactuCode, - }, - ) - env.Head.AddStamp( - &head.Stamp{ - Provider: verifactu.StampQR, - Value: codes.QRCode, - }, - ) - return nil +func (c *Client) Fingerprint(d *doc.VeriFactu) error { + return d.Fingerprint() } func hasExistingStamps(env *gobl.Envelope) bool { for _, stamp := range env.Head.Stamps { - if stamp.Provider.In(verifactu.StampCode, verifactu.StampQR) { + if stamp.Provider.In(verifactu.StampQR) { return true } } diff --git a/go.mod b/go.mod index 16daf07..5faed7f 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.22.1 // replace github.com/invopop/gobl => ../gobl require ( - github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807 + github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c github.com/invopop/xmldsig v0.10.0 github.com/spf13/cobra v1.8.1 ) diff --git a/go.sum b/go.sum index e90ccc4..774fcd5 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/invopop/gobl v0.204.0 h1:xvpq2EtRgc3eQ/rbWjWAKtTXc9OX02NVumTSvEk3U7g= github.com/invopop/gobl v0.204.0/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807 h1:p2bmC9OhAt7IxorD5PhxstP5smfI2FGnvXK9+KuB8iM= github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c h1:oPGUQlyzz0MLcN/0MwOYN1RVrfZjDrWCeUkivfO5bCI= +github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= diff --git a/internal/gateways/errors.go b/internal/gateways/errors.go index 9479b44..e90b47f 100644 --- a/internal/gateways/errors.go +++ b/internal/gateways/errors.go @@ -1,5 +1,10 @@ package gateways +import ( + "errors" + "strings" +) + // Error codes and their descriptions from VeriFactu var ErrorCodes = map[string]string{ // Errors that cause rejection of the entire submission @@ -53,3 +58,87 @@ var ErrorCodes = map[string]string{ "1110": "El NIF no está identificado en el censo de la AEAT.", "1111": "El campo CodigoPais es obligatorio cuando IDType es distinto de 02.", } + +// Standard gateway error responses +var ( + ErrConnection = newError("connection") + ErrInvalid = newError("invalid") + ErrDuplicate = newError("duplicate") +) + +// Error allows for structured responses from the gateway to be able to +// response codes and messages. +type Error struct { + key string + code string + message string + cause error +} + +// Error produces a human readable error message. +func (e *Error) Error() string { + out := []string{e.key} + if e.code != "" { + out = append(out, e.code) + } + if e.message != "" { + out = append(out, e.message) + } + return strings.Join(out, ": ") +} + +// Key returns the key for the error. +func (e *Error) Key() string { + return e.key +} + +// Message returns the human message for the error. +func (e *Error) Message() string { + return e.message +} + +// Code returns the code provided by the remote service. +func (e *Error) Code() string { + return e.code +} + +func newError(key string) *Error { + return &Error{key: key} +} + +// withCode duplicates and adds the code to the error. +func (e *Error) withCode(code string) *Error { + e = e.clone() + e.code = code + return e +} + +// withMessage duplicates and adds the message to the error. +func (e *Error) withMessage(msg string) *Error { + e = e.clone() + e.message = msg + return e +} + +func (e *Error) withCause(err error) *Error { + e = e.clone() + e.cause = err + e.message = err.Error() + return e +} + +func (e *Error) clone() *Error { + ne := new(Error) + *ne = *e + return ne +} + +// Is checks to see if the target error is the same as the current one +// or forms part of the chain. +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + if !ok { + return errors.Is(e.cause, target) + } + return e.key == t.key +} diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 01e7bf5..cde0022 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -2,12 +2,14 @@ package gateways import ( "context" - "errors" + "encoding/xml" "fmt" + "net/http" "os" + "strconv" "github.com/go-resty/resty/v2" - "github.com/invopop/gobl.verifactu/internal/doc" + "github.com/invopop/gobl.verifactu/doc" ) // Environment defines the environment to use for connections @@ -21,22 +23,29 @@ const ( // Production environment not published yet ProductionBaseURL = "xxxxxxxx" TestingBaseURL = "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP" -) -// Standard gateway error responses -var ( - ErrConnection = errors.New("connection") - ErrInvalidRequest = errors.New("invalid request") + correctStatus = "Correcto" ) +// VeriFactuResponse defines the response fields from the VeriFactu gateway. +type Response struct { + XMLName xml.Name `xml:"RespuestaSuministro"` + CSV string `xml:"CSV"` + EstadoEnvio string `xml:"EstadoEnvio"` + RespuestaLinea []struct { + EstadoRegistro string `xml:"EstadoRegistro"` + DescripcionErrorRegistro string `xml:"DescripcionErrorRegistro,omitempty"` + } `xml:"RespuestaLinea"` +} + // Connection defines what is expected from a connection to a gateway. -type Conection struct { +type Connection struct { client *resty.Client } // New instantiates a new connection using the provided config. -func NewVerifactu(env Environment) *Conection { - c := new(Conection) +func NewVerifactu(env Environment) *Connection { + c := new(Connection) c.client = resty.New() switch env { @@ -49,23 +58,39 @@ func NewVerifactu(env Environment) *Conection { return c } -func (v *Conection) Post(ctx context.Context, doc doc.VeriFactu) error { +func (c *Connection) Post(ctx context.Context, doc doc.VeriFactu) error { payload, err := doc.Bytes() if err != nil { return fmt.Errorf("generating payload: %w", err) } + return c.post(ctx, TestingBaseURL, payload) +} - res, err := v.client.R(). +func (c *Connection) post(ctx context.Context, path string, payload []byte) error { + out := new(Response) + req := c.client.R(). SetContext(ctx). + SetDebug(true). + SetHeader("Content-Type", "application/xml"). + SetContentLength(true). SetBody(payload). - Post(v.client.BaseURL) + SetResult(out) + res, err := req.Post(path) if err != nil { - return fmt.Errorf("%w: verifactu: %s", ErrConnection, err.Error()) + return err + } + if res.StatusCode() != http.StatusOK { + return ErrInvalid.withCode(strconv.Itoa(res.StatusCode())) } - if res.StatusCode() != 200 { - return fmt.Errorf("%w: verifactu: status %d", ErrInvalidRequest, res.StatusCode()) + if out.EstadoEnvio != correctStatus { + err := ErrInvalid + if len(out.RespuestaLinea) > 0 { + e1 := out.RespuestaLinea[0] + err = err.withMessage(e1.DescripcionErrorRegistro).withCode(e1.EstadoRegistro) + } + return err } return nil diff --git a/test/schema/RespuestaSuministro.xsd b/test/schema/RespuestaSuministro.xsd index 2dc97e4..d4902b9 100644 --- a/test/schema/RespuestaSuministro.xsd +++ b/test/schema/RespuestaSuministro.xsd @@ -51,7 +51,7 @@ - + diff --git a/verifactu.go b/verifactu.go index 7f32e5c..cc67d1b 100644 --- a/verifactu.go +++ b/verifactu.go @@ -5,7 +5,7 @@ import ( "errors" "time" - "github.com/invopop/gobl.verifactu/internal/doc" + "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/internal/gateways" ) @@ -51,7 +51,7 @@ type Client struct { issuerRole doc.IssuerRole curTime time.Time // zone l10n.Code - gw *gateways.Conection + gw *gateways.Connection } // Option is used to configure the client. From fe620dfc30d9330fbf62f99f75700566ca6ef5c9 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 11 Nov 2024 19:43:31 +0000 Subject: [PATCH 10/56] Refactored Software --- cmd/gobl.verifactu/convert.go | 5 ++-- cmd/gobl.verifactu/root.go | 30 +++++++++++++----------- cmd/gobl.verifactu/send.go | 6 ++--- doc/cancel.go | 3 ++- doc/doc.go | 28 ++++++++++++++++++----- doc/document.go | 11 +++++++++ doc/invoice.go | 16 ++++++++++--- doc/software.go | 26 --------------------- document.go | 16 ++++++++++++- internal/gateways/gateways.go | 4 ++-- verifactu.go | 43 +++++++---------------------------- 11 files changed, 96 insertions(+), 92 deletions(-) delete mode 100644 doc/software.go diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go index f7c0bb7..391720b 100644 --- a/cmd/gobl.verifactu/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -8,6 +8,7 @@ import ( "github.com/invopop/gobl" verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" "github.com/spf13/cobra" ) @@ -52,7 +53,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("unmarshaling gobl envelope: %w", err) } - vf, err := verifactu.New(&verifactu.Software{}) + vf, err := verifactu.New(&doc.Software{}) if err != nil { return fmt.Errorf("creating verifactu client: %w", err) } @@ -64,7 +65,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { data, err := doc.BytesIndent() if err != nil { - return fmt.Errorf("generating ticketbai xml: %w", err) + return fmt.Errorf("generating verifactu xml: %w", err) } if _, err = out.Write(append(data, '\n')); err != nil { diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index b395863..104b370 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -4,18 +4,19 @@ import ( "io" "os" - verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" _ "github.com/joho/godotenv/autoload" "github.com/spf13/cobra" "github.com/spf13/pflag" ) type rootOpts struct { - swNIF string - swCompanyName string - swVersion string - swLicense string - production bool + swNIF string + swNombreRazon string + swVersion string + swIdSistemaInformatico string + swNumeroInstalacion string + production bool } func root() *rootOpts { @@ -37,17 +38,20 @@ func (o *rootOpts) cmd() *cobra.Command { func (o *rootOpts) prepareFlags(f *pflag.FlagSet) { f.StringVar(&o.swNIF, "sw-nif", os.Getenv("SOFTWARE_COMPANY_NIF"), "NIF of the software company") - f.StringVar(&o.swCompanyName, "sw-company-name", os.Getenv("SOFTWARE_COMPANY_NAME"), "Name of the software company") + f.StringVar(&o.swNombreRazon, "sw-name", os.Getenv("SOFTWARE_COMPANY_NAME"), "Name of the software company") f.StringVar(&o.swVersion, "sw-version", os.Getenv("SOFTWARE_VERSION"), "Version of the software") - f.StringVar(&o.swLicense, "sw-license", os.Getenv("SOFTWARE_LICENSE"), "License of the software") + f.StringVar(&o.swIdSistemaInformatico, "sw-id", os.Getenv("SOFTWARE_ID_SISTEMA_INFORMATICO"), "ID of the software system") + f.StringVar(&o.swNumeroInstalacion, "sw-inst", os.Getenv("SOFTWARE_NUMERO_INSTALACION"), "Number of the software installation") f.BoolVarP(&o.production, "production", "p", false, "Production environment") } -func (o *rootOpts) software() *verifactu.Software { - return &verifactu.Software{ - NombreRazon: o.swCompanyName, - NIF: o.swNIF, - Version: o.swVersion, +func (o *rootOpts) software() *doc.Software { + return &doc.Software{ + NIF: o.swNIF, + NombreRazon: o.swNombreRazon, + Version: o.swVersion, + IdSistemaInformatico: o.swIdSistemaInformatico, + NumeroInstalacion: o.swNumeroInstalacion, } } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index d7e5ea0..07ed89b 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -77,9 +77,9 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { return err } - // if err := tc.Sign(td, env); err != nil { - // return err - // } + if err := tc.AddQR(td, env); err != nil { + return err + } err = tc.Post(cmd.Context(), td) if err != nil { diff --git a/doc/cancel.go b/doc/cancel.go index 8db5261..d9e8b3f 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -23,7 +23,7 @@ type RegistroAnulacion struct { } // NewRegistroAnulacion provides support for credit notes -func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, role IssuerRole) (*RegistroAnulacion, error) { +func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ IDVersion: CurrentVersion, IDFactura: &IDFactura{ @@ -37,6 +37,7 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, role IssuerRole) (*Re NIF: inv.Supplier.TaxID.Code.String(), NombreRazon: inv.Supplier.Name, }, + SistemaInformatico: newSoftware(s), FechaHoraHusoGenRegistro: formatDateTimeZone(ts), TipoHuella: "01", } diff --git a/doc/doc.go b/doc/doc.go index 0f540f0..1da7a4a 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -50,7 +50,7 @@ func init() { } } -func NewDocument(inv *bill.Invoice, ts time.Time, role IssuerRole) (*VeriFactu, error) { +func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*VeriFactu, error) { doc := &VeriFactu{ SUMNamespace: SUM, @@ -65,13 +65,13 @@ func NewDocument(inv *bill.Invoice, ts time.Time, role IssuerRole) (*VeriFactu, } if inv.Type == bill.InvoiceTypeCreditNote { - reg, err := NewRegistroAnulacion(inv, ts, role) + reg, err := NewRegistroAnulacion(inv, ts, r, s) if err != nil { return nil, err } doc.RegistroFactura.RegistroAnulacion = reg } else { - reg, err := NewRegistroAlta(inv, ts, role) + reg, err := NewRegistroAlta(inv, ts, r, s) if err != nil { return nil, err } @@ -90,11 +90,27 @@ func (d *VeriFactu) QRCodes() *Codes { // ChainData generates the data to be used to link to this one // in the next entry. -func (d *VeriFactu) ChainData() string { +func (d *VeriFactu) ChainData() Encadenamiento { if d.RegistroFactura.RegistroAlta != nil { - return d.RegistroFactura.RegistroAlta.Huella + return Encadenamiento{ + PrimerRegistro: d.RegistroFactura.RegistroAlta.Huella, + RegistroAnterior: RegistroAnterior{ + IDEmisorFactura: d.Cabecera.Obligado.NIF, + NumSerieFactura: d.RegistroFactura.RegistroAlta.Encadenamiento.RegistroAnterior.NumSerieFactura, + FechaExpedicionFactura: d.RegistroFactura.RegistroAlta.Encadenamiento.RegistroAnterior.FechaExpedicionFactura, + Huella: d.RegistroFactura.RegistroAlta.Encadenamiento.RegistroAnterior.Huella, + }, + } + } + return Encadenamiento{ + PrimerRegistro: d.RegistroFactura.RegistroAnulacion.Huella, + RegistroAnterior: RegistroAnterior{ + IDEmisorFactura: d.Cabecera.Obligado.NIF, + NumSerieFactura: d.RegistroFactura.RegistroAnulacion.Encadenamiento.RegistroAnterior.NumSerieFactura, + FechaExpedicionFactura: d.RegistroFactura.RegistroAnulacion.Encadenamiento.RegistroAnterior.FechaExpedicionFactura, + Huella: d.RegistroFactura.RegistroAnulacion.Encadenamiento.RegistroAnterior.Huella, + }, } - return d.RegistroFactura.RegistroAnulacion.Huella } func (d *VeriFactu) Fingerprint() error { diff --git a/doc/document.go b/doc/document.go index 7bf5d2b..bc48b07 100644 --- a/doc/document.go +++ b/doc/document.go @@ -146,3 +146,14 @@ type RegistroAnterior struct { FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"` Huella string `xml:"sum1:Huella"` } + +// Software contains the details about the software that is using this library to +// generate VeriFactu documents. These details are included in the final +// document. +type Software struct { + NombreRazon string + NIF string + IdSistemaInformatico string + Version string + NumeroInstalacion string +} diff --git a/doc/invoice.go b/doc/invoice.go index 247cbe1..0b0e055 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -10,7 +10,7 @@ import ( "github.com/invopop/gobl/tax" ) -func NewRegistroAlta(inv *bill.Invoice, ts time.Time, role IssuerRole) (*RegistroAlta, error) { +func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { description, err := newDescription(inv.Notes) if err != nil { return nil, err @@ -33,7 +33,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, role IssuerRole) (*Registr DescripcionOperacion: description, ImporteTotal: newImporteTotal(inv), CuotaTotal: newTotalTaxes(inv), - SistemaInformatico: newSoftware(), + SistemaInformatico: newSoftware(s), Desglose: desglose, FechaHoraHusoGenRegistro: formatDateTimeZone(ts), } @@ -42,7 +42,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, role IssuerRole) (*Registr reg.Destinatarios = newDestinatario(inv.Customer) } - if role == IssuerRoleThirdParty { + if r == IssuerRoleThirdParty { reg.EmitidaPorTerceroODestinatario = "T" reg.Tercero = newTercero(inv.Supplier) } @@ -107,3 +107,13 @@ func newTotalTaxes(inv *bill.Invoice) string { return totalTaxes.String() } + +func newSoftware(s *Software) *Software { + return &Software{ + NombreRazon: s.NombreRazon, + NIF: s.NIF, + IdSistemaInformatico: s.IdSistemaInformatico, + Version: s.Version, + NumeroInstalacion: s.NumeroInstalacion, + } +} diff --git a/doc/software.go b/doc/software.go deleted file mode 100644 index 52b12fe..0000000 --- a/doc/software.go +++ /dev/null @@ -1,26 +0,0 @@ -package doc - -type Software struct { - NombreRazon string - NIF string - // IDOtro string - // NombreSistemaInformatico string - IdSistemaInformatico string - Version string - NumeroInstalacion string - // TipoUsoPosibleSoloVerifactu string - // TipoUsoPosibleMultiOT string - // IndicadorMultiplesOT string -} - -func newSoftware() *Software { - software := &Software{ - NombreRazon: "xxxxxxxx", - NIF: "0123456789", - // IDOtro: "04", - // IDSistemaInformatico: "F1", - Version: CurrentVersion, - NumeroInstalacion: "1", - } - return software -} diff --git a/document.go b/document.go index 08c6153..a843d00 100644 --- a/document.go +++ b/document.go @@ -7,6 +7,7 @@ import ( "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/head" "github.com/invopop/gobl/l10n" ) @@ -26,7 +27,7 @@ func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { return nil, errors.New("only spanish invoices are supported") } - out, err := doc.NewDocument(inv, c.CurrentTime(), c.issuerRole) + out, err := doc.NewDocument(inv, c.CurrentTime(), c.issuerRole, c.software) if err != nil { return nil, err } @@ -42,6 +43,19 @@ func (c *Client) Fingerprint(d *doc.VeriFactu) error { return d.Fingerprint() } +// AddQR adds the QR code stamp to the envelope. +func (c *Client) AddQR(d *doc.VeriFactu, env *gobl.Envelope) error { + // now generate the QR codes and add them to the envelope + codes := d.QRCodes() + env.Head.AddStamp( + &head.Stamp{ + Provider: verifactu.StampQR, + Value: codes.QRCode, + }, + ) + return nil +} + func hasExistingStamps(env *gobl.Envelope) bool { for _, stamp := range env.Head.Stamps { if stamp.Provider.In(verifactu.StampQR) { diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index cde0022..bd3b79e 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -44,7 +44,7 @@ type Connection struct { } // New instantiates a new connection using the provided config. -func NewVerifactu(env Environment) *Connection { +func New(env Environment) (*Connection, error) { c := new(Connection) c.client = resty.New() @@ -55,7 +55,7 @@ func NewVerifactu(env Environment) *Connection { c.client.SetBaseURL(TestingBaseURL) } c.client.SetDebug(os.Getenv("DEBUG") == "true") - return c + return c, nil } func (c *Connection) Post(ctx context.Context, doc doc.VeriFactu) error { diff --git a/verifactu.go b/verifactu.go index cc67d1b..67d6bef 100644 --- a/verifactu.go +++ b/verifactu.go @@ -22,18 +22,6 @@ type ValidationError struct { err error } -type Software struct { - NombreRazon string - NIF string - IdSistemaInformatico string - NombreSistemaInformatico string - NumeroInstalacion string - TipoUsoPosibleSoloVerifactu string - TipoUsoPosibleMultiOT string - IndicadorMultiplesOT string - Version string -} - // Error implements the error interface for ClientError. func (e *ValidationError) Error() string { return e.err.Error() @@ -45,13 +33,11 @@ func newValidationError(text string) error { // Client provides the main interface to the VeriFactu package. type Client struct { - software *Software - // list *gateways.List + software *doc.Software env gateways.Environment issuerRole doc.IssuerRole curTime time.Time - // zone l10n.Code - gw *gateways.Connection + gw *gateways.Connection } // Option is used to configure the client. @@ -65,18 +51,9 @@ func WithCurrentTime(curTime time.Time) Option { } } -// PreviousInvoice stores the fields from the previously generated invoice -// document that are linked to in the new document. -type PreviousInvoice struct { - Series string - Code string - IssueDate string - Signature string -} - // New creates a new VeriFactu client with shared software and configuration // options for creating and sending new documents. -func New(software *Software, opts ...Option) (*Client, error) { +func New(software *doc.Software, opts ...Option) (*Client, error) { c := new(Client) c.software = software @@ -88,15 +65,11 @@ func New(software *Software, opts ...Option) (*Client, error) { opt(c) } - // // Create a new gateway list if none was created by the options - // if c.list == nil && c.cert != nil { - // list, err := gateways.New(c.env, c.cert) - // if err != nil { - // return nil, fmt.Errorf("creating gateway list: %w", err) - // } - - // c.list = list - // } + var err error + c.gw, err = gateways.New(c.env) + if err != nil { + return nil, err + } return c, nil } From cc95342e59ca45b04b59e57a108a78688bd4a0ed Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 11 Nov 2024 19:51:53 +0000 Subject: [PATCH 11/56] Added Invoice and Breakdown Fixes --- doc/breakdown.go | 14 -------------- doc/invoice.go | 1 + doc/parties.go | 1 - 3 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 doc/parties.go diff --git a/doc/breakdown.go b/doc/breakdown.go index 71ce188..827e7b9 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -6,20 +6,6 @@ import ( "github.com/invopop/gobl/tax" ) -// DesgloseIVA contains the breakdown of VAT amounts -type DesgloseIVA struct { - DetalleIVA []*DetalleIVA -} - -// DetalleIVA details about taxed amounts -type DetalleIVA struct { - TipoImpositivo string - BaseImponible string - CuotaImpuesto string - TipoRecargoEquiv string - CuotaRecargoEquiv string -} - func newDesglose(inv *bill.Invoice) (*Desglose, error) { desglose := &Desglose{} diff --git a/doc/invoice.go b/doc/invoice.go index 0b0e055..0c87ffe 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -10,6 +10,7 @@ import ( "github.com/invopop/gobl/tax" ) +// NewRegistroAlta creates a new VeriFactu registration for an invoice. func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { description, err := newDescription(inv.Notes) if err != nil { diff --git a/doc/parties.go b/doc/parties.go deleted file mode 100644 index 1025707..0000000 --- a/doc/parties.go +++ /dev/null @@ -1 +0,0 @@ -package doc From dfea8869233c4f0fb17c82483076fe58f447fc1c Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 12 Nov 2024 09:35:16 +0000 Subject: [PATCH 12/56] Added README --- README.md | 198 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f4d6adf..4d2e137 100644 --- a/README.md +++ b/README.md @@ -1 +1,197 @@ -# gobl.verifactu \ No newline at end of file +# GOBL to Veri*Factu + +Go library to convert [GOBL](https://github.com/invopop/gobl) invoices into Veri*Factu declarations and send them to the AEAT (Agencia Estatal de Administración Tributaria) web services. This library assumes that clients will handle a local database of previous invoices in order to comply with the local requirements of chaining all invoices together. + +Copyright [Invopop Ltd.](https://invopop.com) 2023. Released publicly under the [GNU Affero General Public License v3.0](LICENSE). For commercial licenses please contact the [dev team at invopop](mailto:dev@invopop.com). For contributions to this library to be accepted, we will require you to accept transferring your copyright to Invopop Ltd. + +## Source + +The main resources for Veri*Factu can be found in the AEAT website and include: + +- [Veri*Factu documentation](https://www.agenciatributaria.es/AEAT.desarrolladores/Desarrolladores/_menu_/Documentacion/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU.html) +- [Veri*Factu Ministerial Order](https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138) + +## Usage + +### Go Package + +You must have first created a GOBL Envelope containing an Invoice that you'd like to send to the AEAT. For the document to be converted, the supplier contained in the invoice should have a "Tax ID" with the country set to `ES`. + +The following is an example of how the GOBL Veri*Factu package could be used: + +```go +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/invopop/gobl" + verifactu "github.com/invopop/gobl.verifactu" +) + +func main() { + ctx := context.Background() + + // Load sample envelope: + data, _ := os.ReadFile("./test/data/sample-invoice.json") + + env := new(gobl.Envelope) + if err := json.Unmarshal(data, env); err != nil { + panic(err) + } + + // Prepare software configuration: + soft := &verifactu.Software{ + License: "XYZ", // provided by tax agency + NIF: "B123456789", // Software company's tax code + Name: "Invopop", // Name of application + Version: "v0.1.0", // Software version + } + + + // Instantiate the TicketBAI client with sofrward config + // and specific zone. + c, err := verifactu.New(soft, + verifactu.WithSupplierIssuer(), // The issuer is the invoice's supplier + verifactu.InTesting(), // Use the tax agency testing environment + ) + if err != nil { + panic(err) + } + + // Create a new Veri*Factu document: + doc, err := c.Convert(env) + if err != nil { + panic(err) + } + + // Create the document fingerprint + // Assume here that we don't have a previous chain data object. + if err = c.Fingerprint(doc, nil); err != nil { + panic(err) + } + + // Sign the document: + if err := c.AddQR(doc, env); err != nil { + panic(err) + } + + // Create the XML output + bytes, err := doc.BytesIndent() + if err != nil { + panic(err) + } + + // Do something with the output, you probably want to store + // it somewhere. + fmt.Println("Document created:\n", string(bytes)) + + // Grab and persist the Chain Data somewhere so you can use this + // for the next call to the Fingerprint method. + cd := doc.ChainData() + + // Send to Veri*Factu, if rejected, you'll want to fix any + // issues and send in a new XML document. The original + // version should not be modified. + if err := c.Post(ctx, doc); err != nil { + panic(err) + } + +} +``` + +## Command Line + +The GOBL Veri*Factu package tool also includes a command line helper. You can install manually in your Go environment with: + +```bash +go install github.com/invopop/gobl.verifactu +``` + +We recommend using a `.env` file to prepare configuration settings, although all parameters can be set using command line flags. Heres an example: + +``` +SOFTWARE_COMPANY_NIF=B85905495 +SOFTWARE_COMPANY_NAME="Invopop S.L." +SOFTWARE_NAME="Invopop" +SOFTWARE_ID_SISTEMA_INFORMATICO="IP" +SOFTWARE_NUMERO_INSTALACION="12345678" +SOFTWARE_VERSION="1.0" +``` + +To convert a document to XML, run: + +```bash +gobl.verifactu convert ./test/data/sample-invoice.json +``` + +To submit to the tax agency testing environment: + +```bash +gobl.verifactu send ./test/data/sample-invoice.json +``` + +## Limitations + +- Veri*Factu allows more than one customer per invoice, but GOBL only has one possible customer. + +- Invoices should have a note of type general that will be used as a general description of the invoice. If an invoice is missing this info, it will be rejected with an error. + +- Currently VeriFactu supportts sending more than one invoice at a time (up to 1000). However, this module only currently supports 1 invoice at a time. + +## Tags, Keys and Extensions + +In order to provide the supplier specific data required by Veri*Factu, invoices need to include a bit of extra data. We've managed to simplify these into specific cases. + + From d23249a519a39d9b32be0f8faabb15e7a3ef9ec3 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 12 Nov 2024 16:09:16 +0000 Subject: [PATCH 13/56] Party Refactor and Minor Features --- cmd/gobl.verifactu/convert.go | 2 +- cmd/gobl.verifactu/send.go | 15 +- doc/breakdown.go | 2 + doc/cancel.go | 24 ++- doc/doc.go | 27 +--- doc/document.go | 39 +++-- doc/fingerprint.go | 4 +- doc/invoice.go | 13 +- doc/party.go | 33 +++-- document.go | 4 +- go.mod | 2 +- go.sum | 4 + .../ejemploRegistro-signed-epes-xades4j.xml | 137 ++++++++++++++++++ test/schema/ejemploRegistro-sin_firmar.xml | 77 ++++++++++ 14 files changed, 313 insertions(+), 70 deletions(-) create mode 100644 test/schema/ejemploRegistro-signed-epes-xades4j.xml create mode 100644 test/schema/ejemploRegistro-sin_firmar.xml diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go index 391720b..273dfd6 100644 --- a/cmd/gobl.verifactu/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -63,7 +63,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { panic(err) } - data, err := doc.BytesIndent() + data, err := doc.Bytes() if err != nil { return fmt.Errorf("generating verifactu xml: %w", err) } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index 07ed89b..d22b523 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -8,6 +8,7 @@ import ( "github.com/invopop/gobl" verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" "github.com/spf13/cobra" ) @@ -28,9 +29,9 @@ func (c *sendOpts) cmd() *cobra.Command { } f := cmd.Flags() + c.prepareFlags(f) - f.StringVar(&c.previous, "prev", "p", "Previous document fingerprint to chain with") - f.BoolVarP(&c.production, "production", "prod", false, "Production environment") + f.StringVar(&c.previous, "prev", "", "Previous document fingerprint to chain with") return cmd } @@ -72,7 +73,15 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { return err } - err = tc.Fingerprint(td) + var prev *doc.Encadenamiento + if c.previous != "" { + prev = new(doc.Encadenamiento) + if err := json.Unmarshal([]byte(c.previous), prev); err != nil { + return err + } + } + + err = tc.Fingerprint(td, prev) if err != nil { return err } diff --git a/doc/breakdown.go b/doc/breakdown.go index 827e7b9..5e6c9c9 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -34,6 +34,8 @@ func buildDetalleDesglose(r *tax.RateTotal) (*DetalleDesglose, error) { if r.Key == tax.RateExempt { detalle.OperacionExenta = r.Ext[verifactu.ExtKeyExemption].String() + } else { + detalle.CalificacionOperacion = r.Ext[verifactu.ExtKeyTaxClassification].String() } if r.Percent != nil { diff --git a/doc/cancel.go b/doc/cancel.go index d9e8b3f..7673dd6 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -13,7 +13,7 @@ type RegistroAnulacion struct { SinRegistroPrevio string `xml:"SinRegistroPrevio"` RechazoPrevio string `xml:"RechazoPrevio,omitempty"` GeneradoPor string `xml:"GeneradoPor"` - Generador *Tercero `xml:"Generador"` + Generador *Party `xml:"Generador"` Encadenamiento *Encadenamiento `xml:"Encadenamiento"` SistemaInformatico *Software `xml:"SistemaInformatico"` FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` @@ -31,28 +31,24 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Soft NumSerieFactura: invoiceNumber(inv.Series, inv.Code), FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), }, - SinRegistroPrevio: "N", - GeneradoPor: "1", // Generated by issuer - Generador: &Tercero{ - NIF: inv.Supplier.TaxID.Code.String(), - NombreRazon: inv.Supplier.Name, - }, + // SinRegistroPrevio: "N", + GeneradoPor: string(r), + Generador: makeGenerador(inv, r), SistemaInformatico: newSoftware(s), FechaHoraHusoGenRegistro: formatDateTimeZone(ts), TipoHuella: "01", } - if err := reg.getEncadenamiento(); err != nil { - return nil, err - } - return reg, nil } -func (r *RegistroAnulacion) getEncadenamiento() error { - r.Encadenamiento = &Encadenamiento{ - PrimerRegistro: "1", +func makeGenerador(inv *bill.Invoice, r IssuerRole) *Party { + switch r { + case IssuerRoleSupplier, IssuerRoleThirdParty: + return newParty(inv.Supplier) + case IssuerRoleCustomer: + return newParty(inv.Customer) } return nil } diff --git a/doc/doc.go b/doc/doc.go index 1da7a4a..1f9040b 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -93,28 +93,26 @@ func (d *VeriFactu) QRCodes() *Codes { func (d *VeriFactu) ChainData() Encadenamiento { if d.RegistroFactura.RegistroAlta != nil { return Encadenamiento{ - PrimerRegistro: d.RegistroFactura.RegistroAlta.Huella, RegistroAnterior: RegistroAnterior{ IDEmisorFactura: d.Cabecera.Obligado.NIF, - NumSerieFactura: d.RegistroFactura.RegistroAlta.Encadenamiento.RegistroAnterior.NumSerieFactura, - FechaExpedicionFactura: d.RegistroFactura.RegistroAlta.Encadenamiento.RegistroAnterior.FechaExpedicionFactura, - Huella: d.RegistroFactura.RegistroAlta.Encadenamiento.RegistroAnterior.Huella, + NumSerieFactura: d.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura, + FechaExpedicionFactura: d.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura, + Huella: d.RegistroFactura.RegistroAlta.Huella, }, } } return Encadenamiento{ - PrimerRegistro: d.RegistroFactura.RegistroAnulacion.Huella, RegistroAnterior: RegistroAnterior{ IDEmisorFactura: d.Cabecera.Obligado.NIF, - NumSerieFactura: d.RegistroFactura.RegistroAnulacion.Encadenamiento.RegistroAnterior.NumSerieFactura, - FechaExpedicionFactura: d.RegistroFactura.RegistroAnulacion.Encadenamiento.RegistroAnterior.FechaExpedicionFactura, - Huella: d.RegistroFactura.RegistroAnulacion.Encadenamiento.RegistroAnterior.Huella, + NumSerieFactura: d.RegistroFactura.RegistroAnulacion.IDFactura.NumSerieFactura, + FechaExpedicionFactura: d.RegistroFactura.RegistroAnulacion.IDFactura.FechaExpedicionFactura, + Huella: d.RegistroFactura.RegistroAnulacion.Huella, }, } } -func (d *VeriFactu) Fingerprint() error { - return d.GenerateHash() +func (d *VeriFactu) Fingerprint(prev *Encadenamiento) error { + return d.GenerateHash(prev) } // Bytes returns the XML document bytes @@ -131,15 +129,6 @@ func toBytes(doc any) ([]byte, error) { return buf.Bytes(), nil } -func (d *VeriFactu) BytesIndent() ([]byte, error) { - buf, err := buffer(d, xml.Header, true) - if err != nil { - return nil, err - } - - return buf.Bytes(), nil -} - func buffer(doc any, base string, indent bool) (*bytes.Buffer, error) { buf := bytes.NewBufferString(base) diff --git a/doc/document.go b/doc/document.go index bc48b07..23065f8 100644 --- a/doc/document.go +++ b/doc/document.go @@ -2,11 +2,13 @@ package doc import "encoding/xml" +// SUM is the namespace for the main VeriFactu schema const ( SUM = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" SUM1 = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" ) +// VeriFactu represents the root element of a VeriFactu document type VeriFactu struct { XMLName xml.Name `xml:"sum:Verifactu"` Cabecera *Cabecera `xml:"sum:Cabecera"` @@ -15,11 +17,13 @@ type VeriFactu struct { SUM1Namespace string `xml:"xmlns:sum1,attr"` } +// RegistroFactura contains either an invoice registration or cancellation type RegistroFactura struct { RegistroAlta *RegistroAlta `xml:"sum1:RegistroAlta,omitempty"` RegistroAnulacion *RegistroAnulacion `xml:"sum1:RegistroAnulacion,omitempty"` } +// Cabecera contains the header information for a VeriFactu document type Cabecera struct { Obligado Obligado `xml:"sum1:Obligado"` Representante *Obligado `xml:"sum1:Representante,omitempty"` @@ -27,21 +31,25 @@ type Cabecera struct { RemisionRequerimiento *RemisionRequerimiento `xml:"sum1:RemisionRequerimiento,omitempty"` } +// Obligado represents an obligated party in the document type Obligado struct { NombreRazon string `xml:"sum1:NombreRazon"` NIF string `xml:"sum1:NIF"` } +// RemisionVoluntaria contains voluntary submission details type RemisionVoluntaria struct { FechaFinVerifactu string `xml:"sum1:FechaFinVerifactu"` Incidencia string `xml:"sum1:Incidencia"` } +// RemisionRequerimiento contains requirement submission details type RemisionRequerimiento struct { RefRequerimiento string `xml:"sum1:RefRequerimiento"` FinRequerimiento string `xml:"sum1:FinRequerimiento"` } +// RegistroAlta contains the details of an invoice registration type RegistroAlta struct { IDVersion string `xml:"sum1:IDVersion"` IDFactura *IDFactura `xml:"sum1:IDFactura"` @@ -60,7 +68,7 @@ type RegistroAlta struct { FacturaSinIdentifDestinatarioArt61d string `xml:"sum1:FacturaSinIdentifDestinatarioArt61d,omitempty"` Macrodato string `xml:"sum1:Macrodato,omitempty"` EmitidaPorTerceroODestinatario string `xml:"sum1:EmitidaPorTerceroODestinatario,omitempty"` - Tercero *Tercero `xml:"sum1:Tercero,omitempty"` + Tercero *Party `xml:"sum1:Tercero,omitempty"` Destinatarios []*Destinatario `xml:"sum1:Destinatarios>sum1:Destinatario,omitempty"` Cupon string `xml:"sum1:Cupon,omitempty"` Desglose *Desglose `xml:"sum1:Desglose"` @@ -76,52 +84,55 @@ type RegistroAlta struct { // Signature *xmldsig.Signature `xml:"sum1:Signature,omitempty"` } +// IDFactura contains the identifying information for an invoice type IDFactura struct { IDEmisorFactura string `xml:"sum1:IDEmisorFactura"` NumSerieFactura string `xml:"sum1:NumSerieFactura"` FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"` } +// FacturaRectificada represents a rectified invoice type FacturaRectificada struct { IDFactura IDFactura `xml:"sum1:IDFactura"` } +// FacturaSustituida represents a substituted invoice type FacturaSustituida struct { IDFactura IDFactura `xml:"sum1:IDFactura"` } +// ImporteRectificacion contains rectification amounts type ImporteRectificacion struct { BaseRectificada string `xml:"sum1:BaseRectificada"` CuotaRectificada string `xml:"sum1:CuotaRectificada"` CuotaRecargoRectificado string `xml:"sum1:CuotaRecargoRectificado"` } -type Tercero struct { - NIF string `xml:"sum1:Nif,omitempty"` - NombreRazon string `xml:"sum1:NombreRazon"` - IDOtro IDOtro `xml:"sum1:IDOtro,omitempty"` +// Party represents a in the document, covering fields Generador, Tercero and IDDestinatario +type Party struct { + NIF string `xml:"sum1:NIF,omitempty"` + NombreRazon string `xml:"sum1:NombreRazon"` + IDOtro *IDOtro `xml:"sum1:IDOtro,omitempty"` } +// Destinatario represents a recipient in the document type Destinatario struct { - IDDestinatario IDDestinatario `xml:"sum1:IDDestinatario"` -} - -type IDDestinatario struct { - NIF string `xml:"sum1:NIF,omitempty"` - NombreRazon string `xml:"sum1:NombreRazon"` - IDOtro IDOtro `xml:"sum1:IDOtro,omitempty"` + IDDestinatario *Party `xml:"sum1:IDDestinatario"` } +// IDOtro contains alternative identifying information type IDOtro struct { CodigoPais string `xml:"sum1:CodigoPais"` IDType string `xml:"sum1:IDType"` ID string `xml:"sum1:ID"` } +// Desglose contains the breakdown details type Desglose struct { DetalleDesglose []*DetalleDesglose `xml:"sum1:DetalleDesglose"` } +// DetalleDesglose contains detailed breakdown information type DetalleDesglose struct { Impuesto string `xml:"sum1:Impuesto"` ClaveRegimen string `xml:"sum1:ClaveRegimen"` @@ -135,11 +146,13 @@ type DetalleDesglose struct { CuotaRecargoEquivalencia string `xml:"sum1:CuotaRecargoEquivalencia,omitempty"` } +// Encadenamiento contains chaining information between documents type Encadenamiento struct { - PrimerRegistro string `xml:"sum1:PrimerRegistro"` + PrimerRegistro *string `xml:"sum1:PrimerRegistro,omitempty"` RegistroAnterior RegistroAnterior `xml:"sum1:RegistroAnterior,omitempty"` } +// RegistroAnterior contains information about the previous registration type RegistroAnterior struct { IDEmisorFactura string `xml:"sum1:IDEmisorFactura"` NumSerieFactura string `xml:"sum1:NumSerieFactura"` diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 0be1f8f..72bba6c 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -53,13 +53,15 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { } // GenerateHash generates the SHA-256 hash for the invoice data. -func (d *VeriFactu) GenerateHash() error { +func (d *VeriFactu) GenerateHash(prev *Encadenamiento) error { // Concatenate f according to Verifactu specifications if d.RegistroFactura.RegistroAlta != nil { + d.RegistroFactura.RegistroAlta.Encadenamiento = prev if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { return err } } else if d.RegistroFactura.RegistroAnulacion != nil { + d.RegistroFactura.RegistroAnulacion.Encadenamiento = prev if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { return err } diff --git a/doc/invoice.go b/doc/invoice.go index 0c87ffe..f609c01 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -45,9 +45,15 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) if r == IssuerRoleThirdParty { reg.EmitidaPorTerceroODestinatario = "T" - reg.Tercero = newTercero(inv.Supplier) + reg.Tercero = newParty(inv.Supplier) } + // Check + if inv.Type == bill.InvoiceTypeCorrective { + reg.Subsanacion = "S" + } + + // Check if inv.HasTags(tax.TagSimplified) { if inv.Type == bill.InvoiceTypeStandard { reg.FacturaSimplificadaArt7273 = "S" @@ -56,6 +62,11 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) } } + // Flag for operations with totals over 100,000,000€. Added with optimism. + if inv.Totals.TotalWithTax.Compare(num.MakeAmount(100000000, 0)) == 1 { + reg.Macrodato = "S" + } + return reg, nil } diff --git a/doc/party.go b/doc/party.go index cee9373..db0e394 100644 --- a/doc/party.go +++ b/doc/party.go @@ -1,13 +1,14 @@ package doc import ( + "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/org" ) func newDestinatario(party *org.Party) []*Destinatario { dest := &Destinatario{ - IDDestinatario: IDDestinatario{ + IDDestinatario: &Party{ NombreRazon: party.Name, }, } @@ -16,7 +17,7 @@ func newDestinatario(party *org.Party) []*Destinatario { if party.TaxID.Country == l10n.ES.Tax() { dest.IDDestinatario.NIF = party.TaxID.Code.String() } else { - dest.IDDestinatario.IDOtro = IDOtro{ + dest.IDDestinatario.IDOtro = &IDOtro{ CodigoPais: party.TaxID.Country.String(), IDType: "04", // Code for foreign tax IDs L7 ID: party.TaxID.Code.String(), @@ -26,21 +27,23 @@ func newDestinatario(party *org.Party) []*Destinatario { return []*Destinatario{dest} } -func newTercero(party *org.Party) *Tercero { - t := &Tercero{ - NombreRazon: party.Name, +func newParty(p *org.Party) *Party { + pty := &Party{ + NombreRazon: p.Name, } - - if party.TaxID != nil { - if party.TaxID.Country == l10n.ES.Tax() { - t.NIF = party.TaxID.Code.String() - } else { - t.IDOtro = IDOtro{ - CodigoPais: party.TaxID.Country.String(), - IDType: "04", // Code for foreign tax IDs L7 - ID: party.TaxID.Code.String(), + if p.TaxID != nil && p.TaxID.Code.String() != "" { + pty.NIF = p.TaxID.Code.String() + } else { + if len(p.Identities) > 0 { + for _, id := range p.Identities { + if id.Ext != nil && id.Ext[verifactu.ExtKeyIdentity] != "" { + pty.IDOtro = &IDOtro{ + IDType: string(id.Ext[verifactu.ExtKeyIdentity]), + ID: id.Code.String(), + } + } } } } - return t + return pty } diff --git a/document.go b/document.go index a843d00..15cd125 100644 --- a/document.go +++ b/document.go @@ -39,8 +39,8 @@ func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { // data provided from the previous chain data. If there was no previous // document in the chain, the parameter should be nil. The document is updated // in place. -func (c *Client) Fingerprint(d *doc.VeriFactu) error { - return d.Fingerprint() +func (c *Client) Fingerprint(d *doc.VeriFactu, prev *doc.Encadenamiento) error { + return d.Fingerprint(prev) } // AddQR adds the QR code stamp to the envelope. diff --git a/go.mod b/go.mod index 5faed7f..e35fb00 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.22.1 // replace github.com/invopop/gobl => ../gobl require ( - github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c + github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34 github.com/invopop/xmldsig v0.10.0 github.com/spf13/cobra v1.8.1 ) diff --git a/go.sum b/go.sum index 774fcd5..e201289 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,10 @@ github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807 h1:p2bmC9OhAt7Ixo github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c h1:oPGUQlyzz0MLcN/0MwOYN1RVrfZjDrWCeUkivfO5bCI= github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.204.2-0.20241112123001-0ec4e55345b3 h1:aC3H14lRHw9wq7Q2WcKOOrnddt4nJVCGt84jY1IMQIU= +github.com/invopop/gobl v0.204.2-0.20241112123001-0ec4e55345b3/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34 h1:1iXp0n3c9tt2CWDuZsBE0cd0dMhgPvKRgIgpvjNYbbg= +github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= diff --git a/test/schema/ejemploRegistro-signed-epes-xades4j.xml b/test/schema/ejemploRegistro-signed-epes-xades4j.xml new file mode 100644 index 0000000..a6a161a --- /dev/null +++ b/test/schema/ejemploRegistro-signed-epes-xades4j.xml @@ -0,0 +1,137 @@ + + 1.0 + + 89890001k + 12345678-G33 + 01-01-2024 + + certificado uno telematicas + N + N + R3 + I + + + 89890002e + AGRUPRECT_MULT_00 + 02-04-2023 + + + 01-01-2024 + DescripcPRIEMAR + + + + certificado dos telematicas + 89890002e + + + + + 01 + S1 + 4 + 10 + 0.4 + + + 01 + S1 + 21 + 100 + 21 + + + 05 + S1 + 10 + 100 + 10 + + + 41.4 + 241.4 + + + B80260144 + 44 + 15-03-2024 + huella + + + + CERTIFICADO DOS TELEMATICAS + + B80260144 + NombreSistemaInformatico + 77 + 1.0.03 + 383 + S + N + N + + 2024-02-07T12:30:00+02:00 + 01 + 5C56AC56F13DB2A25E0031BD2B7ED5C9AF4AF1EF5EBBA700350DE3EEF12C2D35 + + + + + + + + + +tvhJSkSx7f7wccHZ5t3wheEzLzLjJJgNIxg9e9CGy3w= + + + + + + +cwsM+dcIDdncxmTK2wR1NLmqmScuUJyjBJP/IXHwu1s= + + + +RvjfaPYU98C5NBgUO4RGeMhk1yzGvojwN9mR7lO8ttTlfu/S9eKrjXSKuhg1kg5wckQKdB8X5jwy +bZopbH65rfcb3LPE+v6d4lgcwiUlZqBIy1hYar2tKYx8Nq11LFHhsEshr3WxgjQFbCf4IAosUuRQ +20AjtvHAik6grvO/iWF5SqNX0R0a+6UJ5NOmy4vheCGZ/pVCPWKncw6dSju8yQG2kOaRu63+h6r3 +tzvtCsNwvjPGbpR8imSOYv0p3HbIKlw3LKsn5WA+dEUMKP47qglcxdqXrGkwdyLdRuVI59CcJXt1 +8Nx2qud3YolDHboR1x+ePbuJfrU859hcB25OeQ== + + + + +MIIDrTCCApWgAwIBAgIUZPpb63n9H8d9VgsG4YgpdwpdreYwDQYJKoZIhvcNAQELBQAwZjELMAkG +A1UEBhMCRVMxDDAKBgNVBAgMA01BRDEPMA0GA1UEBwwGTWFkcmlkMRUwEwYDVQQKDAxGaXJtYUZh +Y3R1cmExCzAJBgNVBAsMAklUMRQwEgYDVQQDDAtGYWN0dXJhY2lvbjAeFw0yNDAxMjYwODUwMTBa +Fw0yNTAxMjUwODUwMTBaMGYxCzAJBgNVBAYTAkVTMQwwCgYDVQQIDANNQUQxDzANBgNVBAcMBk1h +ZHJpZDEVMBMGA1UECgwMRmlybWFGYWN0dXJhMQswCQYDVQQLDAJJVDEUMBIGA1UEAwwLRmFjdHVy +YWNpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+aGQ7IDfQSPux7mZF1fn+q6Fh +pohOOjT3wWWZc33FpnIbHGop61mTQ7I5anqNQH3BnbBp8u+Y6r6DI5KO3nFB9LPDD9xnAfXMrC1E +gIyFfRI4gqbfisQaCAYFouk0zJstAIFu7zAq/ntGLMbyFkublxhDo1mqOxm3vlAvQjTwPdxrIeY7 +Dj2OpgyoBjvXn6pzsKgc+u3KvLX2xP4PlQzTMllJBfhp8n2gYEMHB0d5v2uF86JN7YSX0RsQyf6y +C0loXIGI/JwF4V2Dys45Zbe6GRliQzGVurjS9B53rnvT2RbZsZWkUziOLcMTBQc0OGqvZJniBXja +rDNt6K8i++2hAgMBAAGjUzBRMB0GA1UdDgQWBBQ3D07fv/INEyd5Iu2m7Ax3/Lr/IjAfBgNVHSME +GDAWgBQ3D07fv/INEyd5Iu2m7Ax3/Lr/IjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA +A4IBAQBJNeHjIh2fMn2omR8CxbpvUjvn/vM1vaLzN+UaF0f8xfF5bOyJrrlEVu6ECZ3hNR4sqxfE +TagzOiu/n2J/PKON4yqvt9R4bBvY8z/H8pC1wXw+PvBHFqqt3RXmw8xB+BcRBPoW9BfiQmvhcIdM +gv1y5RZwdmt+092SRKbzW3GB3MiPhh+SkwuXiGHjMGJD2z2pbtH3Fbe4XgDGFVHkAPCofzP5I7me +dg+i+OPq3YwNCbQ9qeMeipgnjxSTbkzVsNrF6j+0TNU3q2vMCnITnkjdiRckOKOyHe1Uoi+UuZna +I4+GkhVOUpHIldTO54dSaR67cg2tYhDoYWU63DXe89yy + + + + +vmhkOyA30Ej7se5mRdX5/quhYaaITjo098FlmXN9xaZyGxxqKetZk0OyOWp6jUB9wZ2wafLvmOq+ +gyOSjt5xQfSzww/cZwH1zKwtRICMhX0SOIKm34rEGggGBaLpNMybLQCBbu8wKv57RizG8hZLm5cY +Q6NZqjsZt75QL0I08D3cayHmOw49jqYMqAY715+qc7CoHPrtyry19sT+D5UM0zJZSQX4afJ9oGBD +BwdHeb9rhfOiTe2El9EbEMn+sgtJaFyBiPycBeFdg8rOOWW3uhkZYkMxlbq40vQed65709kW2bGV +pFM4ji3DEwUHNDhqr2SZ4gV42qwzbeivIvvtoQ== +AQAB + + + +2024-07-22T09:00:46.397+02:00YUhXomOMz7gh8rOuiBM+i3+LUoDDKvcOibuPlRaW4DY=cn=Facturacion,ou=IT,o=FirmaFactura,l=Madrid,st=MAD,c=ES576482270728543507966757905488730867767223102950urn:oid:2.16.724.1.3.1.1.2.1.9Dkx2R3nMv8kWo7iSAh+/1SQ70hfseOEaQbpJnURk+pg=https://sede.administracion.gob.es/politica_de_firma_anexo_1.pdf + \ No newline at end of file diff --git a/test/schema/ejemploRegistro-sin_firmar.xml b/test/schema/ejemploRegistro-sin_firmar.xml new file mode 100644 index 0000000..12c7b86 --- /dev/null +++ b/test/schema/ejemploRegistro-sin_firmar.xml @@ -0,0 +1,77 @@ + + 1.0 + + 89890001k + 12345678-G33 + 01-01-2024 + + certificado uno telematicas + N + N + R3 + I + + + 89890002e + AGRUPRECT_MULT_00 + 02-04-2023 + + + 01-01-2024 + DescripcPRIEMAR + + + + certificado dos telematicas + 89890002e + + + + + 01 + S1 + 4 + 10 + 0.4 + + + 01 + S1 + 21 + 100 + 21 + + + 05 + S1 + 10 + 100 + 10 + + + 41.4 + 241.4 + + + B80260144 + 44 + 15-03-2024 + huella + + + + CERTIFICADO DOS TELEMATICAS + + B80260144 + NombreSistemaInformatico + 77 + 1.0.03 + 383 + S + N + N + + 2024-02-07T12:30:00+02:00 + 01 + 5C56AC56F13DB2A25E0031BD2B7ED5C9AF4AF1EF5EBBA700350DE3EEF12C2D35 + From 96a93bd1c57ff4221a9d2396580512ada9a2f532 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 12 Nov 2024 16:50:56 +0000 Subject: [PATCH 14/56] Formatting and Send --- cmd/gobl.verifactu/root.go | 7 ++++--- cmd/gobl.verifactu/send.go | 17 +++++++++++++++++ doc/breakdown.go | 13 +++++-------- doc/cancel.go | 21 +++------------------ doc/doc.go | 5 +++++ doc/document.go | 29 +++++++++++++++++++++++------ doc/fingerprint.go | 3 +++ doc/invoice.go | 6 ++---- doc/qr_code.go | 6 +++--- document.go | 2 +- internal/gateways/errors.go | 14 +++++++------- internal/gateways/gateways.go | 4 +++- verifactu.go | 1 + 13 files changed, 77 insertions(+), 51 deletions(-) diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 104b370..588620a 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -14,7 +14,7 @@ type rootOpts struct { swNIF string swNombreRazon string swVersion string - swIdSistemaInformatico string + swIDSistemaInformatico string swNumeroInstalacion string production bool } @@ -31,6 +31,7 @@ func (o *rootOpts) cmd() *cobra.Command { } cmd.AddCommand(versionCmd()) + cmd.AddCommand(send(o).cmd()) cmd.AddCommand(convert(o).cmd()) return cmd @@ -40,7 +41,7 @@ func (o *rootOpts) prepareFlags(f *pflag.FlagSet) { f.StringVar(&o.swNIF, "sw-nif", os.Getenv("SOFTWARE_COMPANY_NIF"), "NIF of the software company") f.StringVar(&o.swNombreRazon, "sw-name", os.Getenv("SOFTWARE_COMPANY_NAME"), "Name of the software company") f.StringVar(&o.swVersion, "sw-version", os.Getenv("SOFTWARE_VERSION"), "Version of the software") - f.StringVar(&o.swIdSistemaInformatico, "sw-id", os.Getenv("SOFTWARE_ID_SISTEMA_INFORMATICO"), "ID of the software system") + f.StringVar(&o.swIDSistemaInformatico, "sw-id", os.Getenv("SOFTWARE_ID_SISTEMA_INFORMATICO"), "ID of the software system") f.StringVar(&o.swNumeroInstalacion, "sw-inst", os.Getenv("SOFTWARE_NUMERO_INSTALACION"), "Number of the software installation") f.BoolVarP(&o.production, "production", "p", false, "Production environment") } @@ -50,7 +51,7 @@ func (o *rootOpts) software() *doc.Software { NIF: o.swNIF, NombreRazon: o.swNombreRazon, Version: o.swVersion, - IdSistemaInformatico: o.swIdSistemaInformatico, + IdSistemaInformatico: o.swIDSistemaInformatico, NumeroInstalacion: o.swNumeroInstalacion, } } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index d22b523..c132d52 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -101,5 +101,22 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { } fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) + // TEMP + + out, err := c.openOutput(cmd, args) + if err != nil { + return err + } + defer out.Close() // nolint:errcheck + + convOut, err := td.Bytes() + if err != nil { + return fmt.Errorf("generating verifactu xml: %w", err) + } + + if _, err = out.Write(append(convOut, '\n')); err != nil { + return fmt.Errorf("writing verifactu xml: %w", err) + } + return nil } diff --git a/doc/breakdown.go b/doc/breakdown.go index 5e6c9c9..0eedae3 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -6,23 +6,20 @@ import ( "github.com/invopop/gobl/tax" ) -func newDesglose(inv *bill.Invoice) (*Desglose, error) { +func newDesglose(inv *bill.Invoice) *Desglose { desglose := &Desglose{} for _, c := range inv.Totals.Taxes.Categories { for _, r := range c.Rates { - detalleDesglose, err := buildDetalleDesglose(r) - if err != nil { - return nil, err - } + detalleDesglose := buildDetalleDesglose(r) desglose.DetalleDesglose = append(desglose.DetalleDesglose, detalleDesglose) } } - return desglose, nil + return desglose } -func buildDetalleDesglose(r *tax.RateTotal) (*DetalleDesglose, error) { +func buildDetalleDesglose(r *tax.RateTotal) *DetalleDesglose { detalle := &DetalleDesglose{ BaseImponibleOImporteNoSujeto: r.Base.String(), CuotaRepercutida: r.Amount.String(), @@ -41,5 +38,5 @@ func buildDetalleDesglose(r *tax.RateTotal) (*DetalleDesglose, error) { if r.Percent != nil { detalle.TipoImpositivo = r.Percent.String() } - return detalle, nil + return detalle } diff --git a/doc/cancel.go b/doc/cancel.go index 7673dd6..05e2086 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -6,22 +6,6 @@ import ( "github.com/invopop/gobl/bill" ) -type RegistroAnulacion struct { - IDVersion string `xml:"IDVersion"` - IDFactura *IDFactura `xml:"IDFactura"` - RefExterna string `xml:"RefExterna,omitempty"` - SinRegistroPrevio string `xml:"SinRegistroPrevio"` - RechazoPrevio string `xml:"RechazoPrevio,omitempty"` - GeneradoPor string `xml:"GeneradoPor"` - Generador *Party `xml:"Generador"` - Encadenamiento *Encadenamiento `xml:"Encadenamiento"` - SistemaInformatico *Software `xml:"SistemaInformatico"` - FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` - TipoHuella string `xml:"TipoHuella"` - Huella string `xml:"Huella"` - Signature string `xml:"Signature"` -} - // NewRegistroAnulacion provides support for credit notes func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ @@ -31,12 +15,13 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Soft NumSerieFactura: invoiceNumber(inv.Series, inv.Code), FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), }, - // SinRegistroPrevio: "N", + // SinRegistroPrevio: "N", // TODO: Think what to do with this field + // RechazoPrevio: "N", // TODO: Think what to do with this field GeneradoPor: string(r), Generador: makeGenerador(inv, r), SistemaInformatico: newSoftware(s), FechaHoraHusoGenRegistro: formatDateTimeZone(ts), - TipoHuella: "01", + TipoHuella: TipoHuella, } return reg, nil diff --git a/doc/doc.go b/doc/doc.go index 1f9040b..57727c5 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -1,3 +1,4 @@ +// Package doc provides the VeriFactu document mappings from GOBL package doc import ( @@ -23,6 +24,7 @@ const ( ) const ( + // CurrentVersion is the current version of the VeriFactu document CurrentVersion = "1.0" ) @@ -50,6 +52,7 @@ func init() { } } +// NewDocument creates a new VeriFactu document func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*VeriFactu, error) { doc := &VeriFactu{ @@ -81,6 +84,7 @@ func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*V return doc, nil } +// QRCodes generates the QR code for the document func (d *VeriFactu) QRCodes() *Codes { if d.RegistroFactura.RegistroAlta.Encadenamiento == nil { return nil @@ -111,6 +115,7 @@ func (d *VeriFactu) ChainData() Encadenamiento { } } +// Fingerprint generates the SHA-256 fingerprint for the document func (d *VeriFactu) Fingerprint(prev *Encadenamiento) error { return d.GenerateHash(prev) } diff --git a/doc/document.go b/doc/document.go index 23065f8..880819f 100644 --- a/doc/document.go +++ b/doc/document.go @@ -78,12 +78,29 @@ type RegistroAlta struct { SistemaInformatico *Software `xml:"sum1:SistemaInformatico"` FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"` NumRegistroAcuerdoFacturacion string `xml:"sum1:NumRegistroAcuerdoFacturacion,omitempty"` - IdAcuerdoSistemaInformatico string `xml:"sum1:IdAcuerdoSistemaInformatico,omitempty"` + IdAcuerdoSistemaInformatico string `xml:"sum1:IdAcuerdoSistemaInformatico,omitempty"` //nolint:revive TipoHuella string `xml:"sum1:TipoHuella"` Huella string `xml:"sum1:Huella"` // Signature *xmldsig.Signature `xml:"sum1:Signature,omitempty"` } +// RegistroAnulacion contains the details of an invoice cancellation +type RegistroAnulacion struct { + IDVersion string `xml:"IDVersion"` + IDFactura *IDFactura `xml:"IDFactura"` + RefExterna string `xml:"RefExterna,omitempty"` + SinRegistroPrevio string `xml:"SinRegistroPrevio"` + RechazoPrevio string `xml:"RechazoPrevio,omitempty"` + GeneradoPor string `xml:"GeneradoPor"` + Generador *Party `xml:"Generador"` + Encadenamiento *Encadenamiento `xml:"Encadenamiento"` + SistemaInformatico *Software `xml:"SistemaInformatico"` + FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` + TipoHuella string `xml:"TipoHuella"` + Huella string `xml:"Huella"` + Signature string `xml:"Signature"` +} + // IDFactura contains the identifying information for an invoice type IDFactura struct { IDEmisorFactura string `xml:"sum1:IDEmisorFactura"` @@ -164,9 +181,9 @@ type RegistroAnterior struct { // generate VeriFactu documents. These details are included in the final // document. type Software struct { - NombreRazon string - NIF string - IdSistemaInformatico string - Version string - NumeroInstalacion string + NombreRazon string `xml:"sum1:NombreRazon"` + NIF string `xml:"sum1:NIF"` + IdSistemaInformatico string `xml:"sum1:IdSistemaInformatico"` //nolint:revive + Version string `xml:"sum1:Version"` + NumeroInstalacion string `xml:"sum1:NumeroInstalacion"` } diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 72bba6c..7e369b8 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -7,6 +7,9 @@ import ( "strings" ) +// TipoHuella is the SHA-256 fingerprint type for Verifactu - L12 +const TipoHuella = "01" + // FormatField returns a formatted field as key=value or key= if the value is empty. func FormatField(key, value string) string { value = strings.TrimSpace(value) // Remove whitespace diff --git a/doc/invoice.go b/doc/invoice.go index f609c01..f76387f 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -17,10 +17,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) return nil, err } - desglose, err := newDesglose(inv) - if err != nil { - return nil, err - } + desglose := newDesglose(inv) reg := &RegistroAlta{ IDVersion: CurrentVersion, @@ -37,6 +34,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) SistemaInformatico: newSoftware(s), Desglose: desglose, FechaHoraHusoGenRegistro: formatDateTimeZone(ts), + TipoHuella: TipoHuella, } if inv.Customer != nil { diff --git a/doc/qr_code.go b/doc/qr_code.go index 6381d1d..f2dc7b4 100644 --- a/doc/qr_code.go +++ b/doc/qr_code.go @@ -17,8 +17,8 @@ type Codes struct { } const ( - TestURL = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?" - ProdURL = "https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?nif=89890001K&numserie=12345678-G33&fecha=01-09-2024&importe=241.4" + testURL = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?" + prodURL = "https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?nif=89890001K&numserie=12345678-G33&fecha=01-09-2024&importe=241.4" ) // var crcTable = crc8.MakeTable(crc8.CRC8) @@ -45,7 +45,7 @@ func (doc *VeriFactu) generateURLCodeAlta() string { // Build the URL urlCode := fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", - TestURL, nif, numSerie, fecha, importe) + testURL, nif, numSerie, fecha, importe) return urlCode } diff --git a/document.go b/document.go index 15cd125..a30b6f8 100644 --- a/document.go +++ b/document.go @@ -11,7 +11,7 @@ import ( "github.com/invopop/gobl/l10n" ) -// NewDocument creates a new document from the provided GOBL Envelope. +// Convert creates a new document from the provided GOBL Envelope. // The envelope must contain a valid Invoice. func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { // Extract the Invoice diff --git a/internal/gateways/errors.go b/internal/gateways/errors.go index e90b47f..d16295e 100644 --- a/internal/gateways/errors.go +++ b/internal/gateways/errors.go @@ -5,7 +5,7 @@ import ( "strings" ) -// Error codes and their descriptions from VeriFactu +// ErrorCodes and their descriptions from VeriFactu var ErrorCodes = map[string]string{ // Errors that cause rejection of the entire submission "4102": "El XML no cumple el esquema. Falta informar campo obligatorio.", @@ -120,12 +120,12 @@ func (e *Error) withMessage(msg string) *Error { return e } -func (e *Error) withCause(err error) *Error { - e = e.clone() - e.cause = err - e.message = err.Error() - return e -} +// func (e *Error) withCause(err error) *Error { +// e = e.clone() +// e.cause = err +// e.message = err.Error() +// return e +// } func (e *Error) clone() *Error { ne := new(Error) diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index bd3b79e..567bbc3 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -1,3 +1,4 @@ +// Package gateways provides the VeriFactu gateway package gateways import ( @@ -27,7 +28,7 @@ const ( correctStatus = "Correcto" ) -// VeriFactuResponse defines the response fields from the VeriFactu gateway. +// Response defines the response fields from the VeriFactu gateway. type Response struct { XMLName xml.Name `xml:"RespuestaSuministro"` CSV string `xml:"CSV"` @@ -58,6 +59,7 @@ func New(env Environment) (*Connection, error) { return c, nil } +// Post sends the VeriFactu document to the gateway func (c *Connection) Post(ctx context.Context, doc doc.VeriFactu) error { payload, err := doc.Bytes() if err != nil { diff --git a/verifactu.go b/verifactu.go index 67d6bef..d00e9db 100644 --- a/verifactu.go +++ b/verifactu.go @@ -1,3 +1,4 @@ +// Package verifactu provides the VeriFactu client package verifactu import ( From 5ef856fa5203390adc764b31d66c13b50bb9297e Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 12 Nov 2024 17:57:10 +0000 Subject: [PATCH 15/56] Convert Fix --- cmd/gobl.verifactu/convert.go | 5 ++--- cmd/gobl.verifactu/send.go | 2 +- doc/doc.go | 14 ++++++++++++++ doc/fingerprint.go | 3 +++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go index 273dfd6..2ef63d8 100644 --- a/cmd/gobl.verifactu/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -8,7 +8,6 @@ import ( "github.com/invopop/gobl" verifactu "github.com/invopop/gobl.verifactu" - "github.com/invopop/gobl.verifactu/doc" "github.com/spf13/cobra" ) @@ -53,7 +52,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("unmarshaling gobl envelope: %w", err) } - vf, err := verifactu.New(&doc.Software{}) + vf, err := verifactu.New(c.software()) if err != nil { return fmt.Errorf("creating verifactu client: %w", err) } @@ -63,7 +62,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { panic(err) } - data, err := doc.Bytes() + data, err := doc.BytesIndent() if err != nil { return fmt.Errorf("generating verifactu xml: %w", err) } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index c132d52..3200748 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -109,7 +109,7 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { } defer out.Close() // nolint:errcheck - convOut, err := td.Bytes() + convOut, err := td.BytesIndent() if err != nil { return fmt.Errorf("generating verifactu xml: %w", err) } diff --git a/doc/doc.go b/doc/doc.go index 57727c5..c378a6b 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -125,6 +125,11 @@ func (d *VeriFactu) Bytes() ([]byte, error) { return toBytes(d) } +// BytesIndent returns the indented XML document bytes +func (d *VeriFactu) BytesIndent() ([]byte, error) { + return toBytesIndent(d) +} + func toBytes(doc any) ([]byte, error) { buf, err := buffer(doc, xml.Header, false) if err != nil { @@ -134,6 +139,15 @@ func toBytes(doc any) ([]byte, error) { return buf.Bytes(), nil } +func toBytesIndent(doc any) ([]byte, error) { + buf, err := buffer(doc, xml.Header, true) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + func buffer(doc any, base string, indent bool) (*bytes.Buffer, error) { buf := bytes.NewBufferString(base) diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 7e369b8..107871b 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -57,6 +57,9 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { // GenerateHash generates the SHA-256 hash for the invoice data. func (d *VeriFactu) GenerateHash(prev *Encadenamiento) error { + if prev == nil { + return fmt.Errorf("previous document is required") + } // Concatenate f according to Verifactu specifications if d.RegistroFactura.RegistroAlta != nil { d.RegistroFactura.RegistroAlta.Encadenamiento = prev From 2f8aba63e4b0a85196ec501bba07f0f0f395ab1b Mon Sep 17 00:00:00 2001 From: apardods Date: Wed, 13 Nov 2024 08:33:51 +0000 Subject: [PATCH 16/56] Temp Testing Env --- cmd/gobl.verifactu/root.go | 1 + cmd/gobl.verifactu/send.go | 4 +- cmd/gobl.verifactu/sendtest.go | 121 +++++++++++++++++++++++++++++++++ doc/doc.go | 2 +- doc/fingerprint.go | 30 +++++++- document.go | 2 +- prev.json | 7 ++ 7 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 cmd/gobl.verifactu/sendtest.go create mode 100644 prev.json diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 588620a..2bc3dd4 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -32,6 +32,7 @@ func (o *rootOpts) cmd() *cobra.Command { cmd.AddCommand(versionCmd()) cmd.AddCommand(send(o).cmd()) + cmd.AddCommand(sendTest(o).cmd()) cmd.AddCommand(convert(o).cmd()) return cmd diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index 3200748..734fe06 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -73,9 +73,9 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { return err } - var prev *doc.Encadenamiento + var prev *doc.ChainData if c.previous != "" { - prev = new(doc.Encadenamiento) + prev = new(doc.ChainData) if err := json.Unmarshal([]byte(c.previous), prev); err != nil { return err } diff --git a/cmd/gobl.verifactu/sendtest.go b/cmd/gobl.verifactu/sendtest.go new file mode 100644 index 0000000..9558736 --- /dev/null +++ b/cmd/gobl.verifactu/sendtest.go @@ -0,0 +1,121 @@ +// Package main provides the command line interface to the VeriFactu package. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/invopop/gobl" + verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" + "github.com/spf13/cobra" +) + +type sendTestOpts struct { + *rootOpts + previous string +} + +func sendTest(o *rootOpts) *sendTestOpts { + return &sendTestOpts{rootOpts: o} +} + +func (c *sendTestOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sendTest [infile]", + Short: "Sends the GOBL invoiceFactu service", + RunE: c.runE, + } + + f := cmd.Flags() + c.prepareFlags(f) + + f.StringVar(&c.previous, "prev", "", "Previous document fingerprint to chain with") + + return cmd +} + +func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer input.Close() // nolint:errcheck + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(input); err != nil { + return fmt.Errorf("reading input: %w", err) + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(buf.Bytes(), env); err != nil { + return fmt.Errorf("unmarshaling gobl envelope: %w", err) + } + + opts := []verifactu.Option{ + verifactu.WithThirdPartyIssuer(), + verifactu.InTesting(), + } + + tc, err := verifactu.New(c.software(), opts...) + if err != nil { + return err + } + + td, err := tc.Convert(env) + if err != nil { + return err + } + + c.previous = `{ + "emisor": "B123456789", + "serie": "FACT-001", + "fecha": "2024-11-11", + "huella": "abc123def456" + }` + prev := new(doc.ChainData) + if err := json.Unmarshal([]byte(c.previous), prev); err != nil { + return err + } + + err = tc.Fingerprint(td, prev) + if err != nil { + return err + } + + if err := tc.AddQR(td, env); err != nil { + return err + } + + out, err := c.openOutput(cmd, args) + if err != nil { + return err + } + defer out.Close() // nolint:errcheck + + convOut, err := td.BytesIndent() + if err != nil { + return fmt.Errorf("generating verifactu xml: %w", err) + } + + if _, err = out.Write(append(convOut, '\n')); err != nil { + return fmt.Errorf("writing verifactu xml: %w", err) + } + + err = tc.Post(cmd.Context(), td) + if err != nil { + return err + } + fmt.Println("made it!") + + data, err := json.Marshal(td.ChainData()) + if err != nil { + return err + } + fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) + + // TEMP + + return nil +} diff --git a/doc/doc.go b/doc/doc.go index c378a6b..2bdcdd2 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -116,7 +116,7 @@ func (d *VeriFactu) ChainData() Encadenamiento { } // Fingerprint generates the SHA-256 fingerprint for the document -func (d *VeriFactu) Fingerprint(prev *Encadenamiento) error { +func (d *VeriFactu) Fingerprint(prev *ChainData) error { return d.GenerateHash(prev) } diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 107871b..9341b09 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -10,6 +10,16 @@ import ( // TipoHuella is the SHA-256 fingerprint type for Verifactu - L12 const TipoHuella = "01" +// ChainData contains the fields of this invoice that will be +// required for fingerprinting the next invoice. JSON tags are +// provided to help with serialization. +type ChainData struct { + IDEmisorFactura string `json:"emisor"` + NumSerieFactura string `json:"serie"` + FechaExpedicionFactura string `json:"fecha"` + Huella string `json:"huella"` +} + // FormatField returns a formatted field as key=value or key= if the value is empty. func FormatField(key, value string) string { value = strings.TrimSpace(value) // Remove whitespace @@ -56,18 +66,32 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { } // GenerateHash generates the SHA-256 hash for the invoice data. -func (d *VeriFactu) GenerateHash(prev *Encadenamiento) error { +func (d *VeriFactu) GenerateHash(prev *ChainData) error { if prev == nil { return fmt.Errorf("previous document is required") } // Concatenate f according to Verifactu specifications if d.RegistroFactura.RegistroAlta != nil { - d.RegistroFactura.RegistroAlta.Encadenamiento = prev + d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + RegistroAnterior: RegistroAnterior{ + IDEmisorFactura: prev.IDEmisorFactura, + NumSerieFactura: prev.NumSerieFactura, + FechaExpedicionFactura: prev.FechaExpedicionFactura, + Huella: prev.Huella, + }, + } if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { return err } } else if d.RegistroFactura.RegistroAnulacion != nil { - d.RegistroFactura.RegistroAnulacion.Encadenamiento = prev + d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ + RegistroAnterior: RegistroAnterior{ + IDEmisorFactura: prev.IDEmisorFactura, + NumSerieFactura: prev.NumSerieFactura, + FechaExpedicionFactura: prev.FechaExpedicionFactura, + Huella: prev.Huella, + }, + } if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { return err } diff --git a/document.go b/document.go index a30b6f8..6285af4 100644 --- a/document.go +++ b/document.go @@ -39,7 +39,7 @@ func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { // data provided from the previous chain data. If there was no previous // document in the chain, the parameter should be nil. The document is updated // in place. -func (c *Client) Fingerprint(d *doc.VeriFactu, prev *doc.Encadenamiento) error { +func (c *Client) Fingerprint(d *doc.VeriFactu, prev *doc.ChainData) error { return d.Fingerprint(prev) } diff --git a/prev.json b/prev.json new file mode 100644 index 0000000..df27550 --- /dev/null +++ b/prev.json @@ -0,0 +1,7 @@ +{ + "emisor": "B123456789", + "serie": "FACT-001", + "fecha": "2024-11-11", + "huella": "abc123def456" +} + From eed8122763a518d8c5e598fa11ed777855c923b9 Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 14 Nov 2024 10:48:39 +0000 Subject: [PATCH 17/56] Modified args --- .gitignore | 9 +++++++++ cmd/gobl.verifactu/root.go | 26 ++++++++++++++++++-------- cmd/gobl.verifactu/sendtest.go | 9 ++++++++- doc/document.go | 14 +++++++++----- internal/gateways/gateways.go | 9 ++++++++- prev.json | 2 +- verifactu.go | 12 +++++++++++- 7 files changed, 64 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 6f72f89..c5d4c46 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,12 @@ go.work.sum # env file .env +*.txt + +# certs +*.cer +*.p12 +*.key +*.pem + +./test/certs \ No newline at end of file diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 2bc3dd4..4a35a71 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -11,10 +11,13 @@ import ( ) type rootOpts struct { - swNIF string + cert string + password string swNombreRazon string - swVersion string + swNIF string + swName string swIDSistemaInformatico string + swVersion string swNumeroInstalacion string production bool } @@ -39,8 +42,11 @@ func (o *rootOpts) cmd() *cobra.Command { } func (o *rootOpts) prepareFlags(f *pflag.FlagSet) { + f.StringVar(&o.cert, "cert", os.Getenv("CERTIFICATE_PATH"), "Certificate for authentication") + f.StringVar(&o.password, "password", os.Getenv("CERTIFICATE_PASSWORD"), "Password of the certificate") f.StringVar(&o.swNIF, "sw-nif", os.Getenv("SOFTWARE_COMPANY_NIF"), "NIF of the software company") - f.StringVar(&o.swNombreRazon, "sw-name", os.Getenv("SOFTWARE_COMPANY_NAME"), "Name of the software company") + f.StringVar(&o.swName, "sw-name", os.Getenv("SOFTWARE_NAME"), "Name of the software") + f.StringVar(&o.swNombreRazon, "sw-company", os.Getenv("SOFTWARE_COMPANY_NAME"), "Name of the software company") f.StringVar(&o.swVersion, "sw-version", os.Getenv("SOFTWARE_VERSION"), "Version of the software") f.StringVar(&o.swIDSistemaInformatico, "sw-id", os.Getenv("SOFTWARE_ID_SISTEMA_INFORMATICO"), "ID of the software system") f.StringVar(&o.swNumeroInstalacion, "sw-inst", os.Getenv("SOFTWARE_NUMERO_INSTALACION"), "Number of the software installation") @@ -49,11 +55,15 @@ func (o *rootOpts) prepareFlags(f *pflag.FlagSet) { func (o *rootOpts) software() *doc.Software { return &doc.Software{ - NIF: o.swNIF, - NombreRazon: o.swNombreRazon, - Version: o.swVersion, - IdSistemaInformatico: o.swIDSistemaInformatico, - NumeroInstalacion: o.swNumeroInstalacion, + NIF: o.swNIF, + NombreRazon: o.swNombreRazon, + Version: o.swVersion, + IdSistemaInformatico: o.swIDSistemaInformatico, + NumeroInstalacion: o.swNumeroInstalacion, + NombreSistemaInformatico: o.swName, + TipoUsoPosibleSoloVerifactu: "S", + TipoUsoPosibleMultiOT: "S", + IndicadorMultiplesOT: "S", } } diff --git a/cmd/gobl.verifactu/sendtest.go b/cmd/gobl.verifactu/sendtest.go index 9558736..141d9ee 100644 --- a/cmd/gobl.verifactu/sendtest.go +++ b/cmd/gobl.verifactu/sendtest.go @@ -9,6 +9,7 @@ import ( "github.com/invopop/gobl" verifactu "github.com/invopop/gobl.verifactu" "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/xmldsig" "github.com/spf13/cobra" ) @@ -53,7 +54,13 @@ func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("unmarshaling gobl envelope: %w", err) } + cert, err := xmldsig.LoadCertificate(c.cert, c.password) + if err != nil { + return err + } + opts := []verifactu.Option{ + verifactu.WithCertificate(cert), verifactu.WithThirdPartyIssuer(), verifactu.InTesting(), } @@ -72,7 +79,7 @@ func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { "emisor": "B123456789", "serie": "FACT-001", "fecha": "2024-11-11", - "huella": "abc123def456" + "huella": "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" }` prev := new(doc.ChainData) if err := json.Unmarshal([]byte(c.previous), prev); err != nil { diff --git a/doc/document.go b/doc/document.go index 880819f..7da4ef3 100644 --- a/doc/document.go +++ b/doc/document.go @@ -181,9 +181,13 @@ type RegistroAnterior struct { // generate VeriFactu documents. These details are included in the final // document. type Software struct { - NombreRazon string `xml:"sum1:NombreRazon"` - NIF string `xml:"sum1:NIF"` - IdSistemaInformatico string `xml:"sum1:IdSistemaInformatico"` //nolint:revive - Version string `xml:"sum1:Version"` - NumeroInstalacion string `xml:"sum1:NumeroInstalacion"` + NombreRazon string `xml:"sum1:NombreRazon"` + NIF string `xml:"sum1:NIF"` + NombreSistemaInformatico string `xml:"sum1:NombreSistemaInformatico,omitempty"` + IdSistemaInformatico string `xml:"sum1:IdSistemaInformatico"` //nolint:revive + Version string `xml:"sum1:Version"` + NumeroInstalacion string `xml:"sum1:NumeroInstalacion"` + TipoUsoPosibleSoloVerifactu string `xml:"sum1:TipoUsoPosibleSoloVerifactu,omitempty"` + TipoUsoPosibleMultiOT string `xml:"sum1:TipoUsoPosibleMultiOT,omitempty"` + IndicadorMultiplesOT string `xml:"sum1:IndicadorMultiplesOT,omitempty"` } diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 567bbc3..b194fe9 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -11,6 +11,7 @@ import ( "github.com/go-resty/resty/v2" "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/xmldsig" ) // Environment defines the environment to use for connections @@ -45,7 +46,11 @@ type Connection struct { } // New instantiates a new connection using the provided config. -func New(env Environment) (*Connection, error) { +func New(env Environment, cert *xmldsig.Certificate) (*Connection, error) { + tlsConf, err := cert.TLSAuthConfig() + if err != nil { + return nil, fmt.Errorf("preparing TLS config: %w", err) + } c := new(Connection) c.client = resty.New() @@ -55,6 +60,8 @@ func New(env Environment) (*Connection, error) { default: c.client.SetBaseURL(TestingBaseURL) } + tlsConf.InsecureSkipVerify = true + c.client.SetTLSClientConfig(tlsConf) c.client.SetDebug(os.Getenv("DEBUG") == "true") return c, nil } diff --git a/prev.json b/prev.json index df27550..6e3d408 100644 --- a/prev.json +++ b/prev.json @@ -2,6 +2,6 @@ "emisor": "B123456789", "serie": "FACT-001", "fecha": "2024-11-11", - "huella": "abc123def456" + "huella": "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" } diff --git a/verifactu.go b/verifactu.go index d00e9db..a10c779 100644 --- a/verifactu.go +++ b/verifactu.go @@ -8,6 +8,7 @@ import ( "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/internal/gateways" + "github.com/invopop/xmldsig" ) // Standard error responses. @@ -38,12 +39,21 @@ type Client struct { env gateways.Environment issuerRole doc.IssuerRole curTime time.Time + cert *xmldsig.Certificate gw *gateways.Connection } // Option is used to configure the client. type Option func(*Client) +// WithCertificate defines the signing certificate to use when producing the +// VeriFactu document. +func WithCertificate(cert *xmldsig.Certificate) Option { + return func(c *Client) { + c.cert = cert + } +} + // WithCurrentTime defines the current time to use when generating the VeriFactu // document. Useful for testing. func WithCurrentTime(curTime time.Time) Option { @@ -67,7 +77,7 @@ func New(software *doc.Software, opts ...Option) (*Client, error) { } var err error - c.gw, err = gateways.New(c.env) + c.gw, err = gateways.New(c.env, c.cert) if err != nil { return nil, err } From 132cdbde6b91ed043f82c2febb19c6aaa399dd1b Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 19 Nov 2024 17:26:37 +0000 Subject: [PATCH 18/56] First Tests and Valid Structure --- .gitignore | 1 + cmd/gobl.verifactu/sendtest.go | 18 +++-- doc/breakdown.go | 81 ++++++++++++++++++--- doc/breakdown_test.go | 99 +++++++++++++++++++++++++ doc/cancel.go | 14 +++- doc/doc.go | 31 ++++++-- doc/document.go | 61 ++++++++++------ doc/fingerprint.go | 4 +- doc/invoice.go | 44 ++++++------ doc/party.go | 73 ++++++++++--------- doc/party_test.go | 66 +++++++++++++++++ doc/qr_code.go | 32 +-------- doc/qr_code_test.go | 105 +++++++++++++++++++++++++++ document.go | 4 +- go.mod | 10 ++- go.sum | 12 ++-- internal/gateways/gateways.go | 28 +++----- internal/gateways/response.go | 50 +++++++++++++ test/data/inv-base.json | 128 +++++++++++++++++++++++++++++++++ test/data/invoice-es-es.json | 4 +- test/test.go | 102 ++++++++++++++++++++++++++ 21 files changed, 797 insertions(+), 170 deletions(-) create mode 100644 doc/breakdown_test.go create mode 100644 doc/party_test.go create mode 100644 doc/qr_code_test.go create mode 100644 internal/gateways/response.go create mode 100644 test/data/inv-base.json create mode 100644 test/test.go diff --git a/.gitignore b/.gitignore index c5d4c46..25739d3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ go.work.sum # env file .env +*.password *.txt # certs diff --git a/cmd/gobl.verifactu/sendtest.go b/cmd/gobl.verifactu/sendtest.go index 141d9ee..8c35787 100644 --- a/cmd/gobl.verifactu/sendtest.go +++ b/cmd/gobl.verifactu/sendtest.go @@ -25,7 +25,7 @@ func sendTest(o *rootOpts) *sendTestOpts { func (c *sendTestOpts) cmd() *cobra.Command { cmd := &cobra.Command{ Use: "sendTest [infile]", - Short: "Sends the GOBL invoiceFactu service", + Short: "Sends the GOBL to the VeriFactu service", RunE: c.runE, } @@ -61,7 +61,7 @@ func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { opts := []verifactu.Option{ verifactu.WithCertificate(cert), - verifactu.WithThirdPartyIssuer(), + verifactu.WithSupplierIssuer(), verifactu.InTesting(), } @@ -76,11 +76,12 @@ func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { } c.previous = `{ - "emisor": "B123456789", - "serie": "FACT-001", - "fecha": "2024-11-11", - "huella": "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" - }` + "emisor": "B85905495", + "serie": "SAMPLE-001", + "fecha": "11-11-2024", + "huella": "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C" + }` + prev := new(doc.ChainData) if err := json.Unmarshal([]byte(c.previous), prev); err != nil { return err @@ -105,7 +106,6 @@ func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("generating verifactu xml: %w", err) } - if _, err = out.Write(append(convOut, '\n')); err != nil { return fmt.Errorf("writing verifactu xml: %w", err) } @@ -122,7 +122,5 @@ func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { } fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) - // TEMP - return nil } diff --git a/doc/breakdown.go b/doc/breakdown.go index 0eedae3..8dbc364 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -1,42 +1,101 @@ package doc import ( + "fmt" + "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" ) -func newDesglose(inv *bill.Invoice) *Desglose { +var taxCategoryCodeMap = map[cbc.Code]string{ + tax.CategoryVAT: "01", + es.TaxCategoryIGIC: "02", + es.TaxCategoryIPSI: "03", +} + +func newDesglose(inv *bill.Invoice) (*Desglose, error) { desglose := &Desglose{} for _, c := range inv.Totals.Taxes.Categories { for _, r := range c.Rates { - detalleDesglose := buildDetalleDesglose(r) + detalleDesglose, err := buildDetalleDesglose(c, r) + if err != nil { + return nil, err + } desglose.DetalleDesglose = append(desglose.DetalleDesglose, detalleDesglose) } } - return desglose + return desglose, nil } -func buildDetalleDesglose(r *tax.RateTotal) *DetalleDesglose { +// Rules applied to build the breakdown come from: +// https://www.agenciatributaria.es/static_files/AEAT_Desarrolladores/EEDD/IVA/VERI-FACTU/Validaciones_Errores_Veri-Factu.pdf +func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesglose, error) { detalle := &DetalleDesglose{ - BaseImponibleOImporteNoSujeto: r.Base.String(), - CuotaRepercutida: r.Amount.String(), + BaseImponibleOImporteNoSujeto: r.Base.Float64(), + CuotaRepercutida: r.Amount.Float64(), } + // L1: IVA, IGIC, IPSI or Other. Default is IVA. if r.Ext != nil && r.Ext[verifactu.ExtKeyTaxCategory] != "" { - detalle.Impuesto = r.Ext[verifactu.ExtKeyTaxCategory].String() + cat, ok := taxCategoryCodeMap[c.Code] + if !ok { + detalle.Impuesto = "05" + } else { + detalle.Impuesto = cat + } + if detalle.Impuesto == "01" { + // L8A: IVA + detalle.ClaveRegimen = r.Ext[verifactu.ExtKeyTaxRegime].String() + } + if detalle.Impuesto == "02" { + // L8B: IGIC + detalle.ClaveRegimen = r.Ext[verifactu.ExtKeyTaxRegime].String() + } + } else { + // L8A: IVA + detalle.ClaveRegimen = r.Ext[verifactu.ExtKeyTaxRegime].String() } - if r.Key == tax.RateExempt { - detalle.OperacionExenta = r.Ext[verifactu.ExtKeyExemption].String() + // Rate zero is what VeriFactu calls "Exempt operation", in difference to GOBL's exempt operation, which in + // VeriFactu is called "No sujeta". + if r.Key == tax.RateZero { + detalle.OperacionExenta = r.Ext[verifactu.ExtKeyTaxClassification].String() } else { detalle.CalificacionOperacion = r.Ext[verifactu.ExtKeyTaxClassification].String() } + if isSpecialRegime(r) { + detalle.BaseImponibleACoste = r.Base.Float64() + } + if r.Percent != nil { - detalle.TipoImpositivo = r.Percent.String() + detalle.TipoImpositivo = r.Percent.Amount().Float64() } - return detalle + + if detalle.OperacionExenta == "" && detalle.CalificacionOperacion == "" { + return nil, fmt.Errorf("missing operation classification for rate %s", r.Key) + } + + if hasEquivalenceSurcharge(r) { + if r.Surcharge == nil { + return nil, fmt.Errorf("missing surcharge for rate %s", r.Key) + } + detalle.TipoRecargoEquivalencia = r.Surcharge.Percent.Amount().Float64() + detalle.CuotaRecargoEquivalencia = r.Surcharge.Amount.Float64() + } + + return detalle, nil +} + +func isSpecialRegime(r *tax.RateTotal) bool { + return r.Ext != nil && (r.Ext[verifactu.ExtKeyTaxCategory] == "02" || r.Ext[verifactu.ExtKeyTaxCategory] == "05" || r.Ext[verifactu.ExtKeyTaxRegime] == "06") +} + +func hasEquivalenceSurcharge(r *tax.RateTotal) bool { + return r.Ext != nil && r.Ext[verifactu.ExtKeyTaxCategory] == "01" && r.Ext[verifactu.ExtKeyTaxRegime] == "18" } diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go new file mode 100644 index 0000000..195a571 --- /dev/null +++ b/doc/breakdown_test.go @@ -0,0 +1,99 @@ +package doc + +import ( + "testing" + "time" + + "github.com/invopop/gobl.verifactu/test" + "github.com/invopop/gobl/bill" + "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 TestBreakdownConversion(t *testing.T) { + t.Run("should handle basic invoice breakdown", func(t *testing.T) { + inv := test.LoadInvoice("./test/data/inv-base.json") + err := inv.Calculate() + require.NoError(t, err) + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, 200.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 42.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + }) + + t.Run("should handle exempt taxes", func(t *testing.T) { + inv := test.LoadInvoice("./test/data/inv-base.json") + inv.Lines = []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(100, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "zero", + }, + }, + }, + } + err := inv.Calculate() + require.NoError(t, err) + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, "", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + }) + + t.Run("should handle multiple tax rates", func(t *testing.T) { + inv := test.LoadInvoice("./test/data/inv-base.json") + inv.Lines = []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(100, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "standard", + }, + }, + }, + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(50, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "reduced", + }, + }, + }, + } + _ = inv.Calculate() + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + + assert.Equal(t, 50.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 10.50, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].CuotaRepercutida) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].ClaveRegimen) + + }) +} diff --git a/doc/cancel.go b/doc/cancel.go index 05e2086..efb8b35 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -19,7 +19,7 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Soft // RechazoPrevio: "N", // TODO: Think what to do with this field GeneradoPor: string(r), Generador: makeGenerador(inv, r), - SistemaInformatico: newSoftware(s), + SistemaInformatico: s, FechaHoraHusoGenRegistro: formatDateTimeZone(ts), TipoHuella: TipoHuella, } @@ -31,9 +31,17 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Soft func makeGenerador(inv *bill.Invoice, r IssuerRole) *Party { switch r { case IssuerRoleSupplier, IssuerRoleThirdParty: - return newParty(inv.Supplier) + p, err := newParty(inv.Supplier) + if err != nil { + return nil + } + return p case IssuerRoleCustomer: - return newParty(inv.Customer) + p, err := newParty(inv.Customer) + if err != nil { + return nil + } + return p } return nil } diff --git a/doc/doc.go b/doc/doc.go index 2bdcdd2..f5de033 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -56,8 +56,6 @@ func init() { func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*VeriFactu, error) { doc := &VeriFactu{ - SUMNamespace: SUM, - SUM1Namespace: SUM1, Cabecera: &Cabecera{ Obligado: Obligado{ NombreRazon: inv.Supplier.Name, @@ -85,11 +83,11 @@ func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*V } // QRCodes generates the QR code for the document -func (d *VeriFactu) QRCodes() *Codes { +func (d *VeriFactu) QRCodes() string { if d.RegistroFactura.RegistroAlta.Encadenamiento == nil { - return nil + return "" } - return d.generateCodes() + return d.generateURL() } // ChainData generates the data to be used to link to this one @@ -130,6 +128,29 @@ func (d *VeriFactu) BytesIndent() ([]byte, error) { return toBytesIndent(d) } +// Envelope wraps the VeriFactu document in a SOAP envelope and includes the expected namespaces +func (v *VeriFactu) Envelop() ([]byte, error) { + // Create and set the envelope with namespaces + env := Envelope{ + XMLNs: EnvNamespace, + SUM: SUM, + SUM1: SUM1, + } + env.Body.VeriFactu = v + + // Marshal the SOAP envelope into an XML byte slice + var result bytes.Buffer + enc := xml.NewEncoder(&result) + enc.Indent("", " ") + err := enc.Encode(env) + if err != nil { + return nil, err + } + + // Return the enveloped XML document + return result.Bytes(), nil +} + func toBytes(doc any) ([]byte, error) { buf, err := buffer(doc, xml.Header, false) if err != nil { diff --git a/doc/document.go b/doc/document.go index 7da4ef3..6aaae19 100644 --- a/doc/document.go +++ b/doc/document.go @@ -4,17 +4,32 @@ import "encoding/xml" // SUM is the namespace for the main VeriFactu schema const ( - SUM = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" - SUM1 = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" + SUM = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" + SUM1 = "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" + EnvNamespace = "http://schemas.xmlsoap.org/soap/envelope/" ) +// xmlns:sf="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" +// xmlns:sfLR="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" +// xmlns:sfR="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd" +// +// Envelope is the SOAP envelope wrapper +type Envelope struct { + XMLName xml.Name `xml:"soapenv:Envelope"` + XMLNs string `xml:"xmlns:soapenv,attr"` + SUM string `xml:"xmlns:sum,attr"` + SUM1 string `xml:"xmlns:sum1,attr"` + Body struct { + XMLName xml.Name `xml:"soapenv:Body"` + VeriFactu *VeriFactu `xml:"sum:RegFactuSistemaFacturacion"` + } +} + // VeriFactu represents the root element of a VeriFactu document type VeriFactu struct { - XMLName xml.Name `xml:"sum:Verifactu"` + XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"` Cabecera *Cabecera `xml:"sum:Cabecera"` RegistroFactura *RegistroFactura `xml:"sum:RegistroFactura"` - SUMNamespace string `xml:"xmlns:sum,attr"` - SUM1Namespace string `xml:"xmlns:sum1,attr"` } // RegistroFactura contains either an invoice registration or cancellation @@ -25,7 +40,7 @@ type RegistroFactura struct { // Cabecera contains the header information for a VeriFactu document type Cabecera struct { - Obligado Obligado `xml:"sum1:Obligado"` + Obligado Obligado `xml:"sum1:ObligadoEmision"` Representante *Obligado `xml:"sum1:Representante,omitempty"` RemisionVoluntaria *RemisionVoluntaria `xml:"sum1:RemisionVoluntaria,omitempty"` RemisionRequerimiento *RemisionRequerimiento `xml:"sum1:RemisionRequerimiento,omitempty"` @@ -59,8 +74,8 @@ type RegistroAlta struct { RechazoPrevio string `xml:"sum1:RechazoPrevio,omitempty"` TipoFactura string `xml:"sum1:TipoFactura"` TipoRectificativa string `xml:"sum1:TipoRectificativa,omitempty"` - FacturasRectificadas []*FacturaRectificada `xml:"sum1:FacturasRectificadas>sum1:FacturaRectificada,omitempty"` - FacturasSustituidas []*FacturaSustituida `xml:"sum1:FacturasSustituidas>sum1:FacturaSustituida,omitempty"` + FacturasRectificadas []*FacturaRectificada `xml:"sum1:FacturasRectificadas,omitempty"` + FacturasSustituidas []*FacturaSustituida `xml:"sum1:FacturasSustituidas,omitempty"` ImporteRectificacion *ImporteRectificacion `xml:"sum1:ImporteRectificacion,omitempty"` FechaOperacion string `xml:"sum1:FechaOperacion"` DescripcionOperacion string `xml:"sum1:DescripcionOperacion"` @@ -69,11 +84,11 @@ type RegistroAlta struct { Macrodato string `xml:"sum1:Macrodato,omitempty"` EmitidaPorTerceroODestinatario string `xml:"sum1:EmitidaPorTerceroODestinatario,omitempty"` Tercero *Party `xml:"sum1:Tercero,omitempty"` - Destinatarios []*Destinatario `xml:"sum1:Destinatarios>sum1:Destinatario,omitempty"` + Destinatarios []*Destinatario `xml:"sum1:Destinatarios,omitempty"` Cupon string `xml:"sum1:Cupon,omitempty"` Desglose *Desglose `xml:"sum1:Desglose"` - CuotaTotal string `xml:"sum1:CuotaTotal"` - ImporteTotal string `xml:"sum1:ImporteTotal"` + CuotaTotal float64 `xml:"sum1:CuotaTotal"` + ImporteTotal float64 `xml:"sum1:ImporteTotal"` Encadenamiento *Encadenamiento `xml:"sum1:Encadenamiento"` SistemaInformatico *Software `xml:"sum1:SistemaInformatico"` FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"` @@ -127,8 +142,8 @@ type ImporteRectificacion struct { // Party represents a in the document, covering fields Generador, Tercero and IDDestinatario type Party struct { - NIF string `xml:"sum1:NIF,omitempty"` NombreRazon string `xml:"sum1:NombreRazon"` + NIF string `xml:"sum1:NIF,omitempty"` IDOtro *IDOtro `xml:"sum1:IDOtro,omitempty"` } @@ -151,16 +166,16 @@ type Desglose struct { // DetalleDesglose contains detailed breakdown information type DetalleDesglose struct { - Impuesto string `xml:"sum1:Impuesto"` - ClaveRegimen string `xml:"sum1:ClaveRegimen"` - CalificacionOperacion string `xml:"sum1:CalificacionOperacion,omitempty"` - OperacionExenta string `xml:"sum1:OperacionExenta,omitempty"` - TipoImpositivo string `xml:"sum1:TipoImpositivo,omitempty"` - BaseImponibleOImporteNoSujeto string `xml:"sum1:BaseImponibleOImporteNoSujeto"` - BaseImponibleACoste string `xml:"sum1:BaseImponibleACoste,omitempty"` - CuotaRepercutida string `xml:"sum1:CuotaRepercutida,omitempty"` - TipoRecargoEquivalencia string `xml:"sum1:TipoRecargoEquivalencia,omitempty"` - CuotaRecargoEquivalencia string `xml:"sum1:CuotaRecargoEquivalencia,omitempty"` + Impuesto string `xml:"sum1:Impuesto,omitempty"` + ClaveRegimen string `xml:"sum1:ClaveRegimen,omitempty"` + CalificacionOperacion string `xml:"sum1:CalificacionOperacion,omitempty"` + OperacionExenta string `xml:"sum1:OperacionExenta,omitempty"` + TipoImpositivo float64 `xml:"sum1:TipoImpositivo,omitempty"` + BaseImponibleOImporteNoSujeto float64 `xml:"sum1:BaseImponibleOimporteNoSujeto"` + BaseImponibleACoste float64 `xml:"sum1:BaseImponibleACoste,omitempty"` + CuotaRepercutida float64 `xml:"sum1:CuotaRepercutida,omitempty"` + TipoRecargoEquivalencia float64 `xml:"sum1:TipoRecargoEquivalencia,omitempty"` + CuotaRecargoEquivalencia float64 `xml:"sum1:CuotaRecargoEquivalencia,omitempty"` } // Encadenamiento contains chaining information between documents @@ -183,7 +198,7 @@ type RegistroAnterior struct { type Software struct { NombreRazon string `xml:"sum1:NombreRazon"` NIF string `xml:"sum1:NIF"` - NombreSistemaInformatico string `xml:"sum1:NombreSistemaInformatico,omitempty"` + NombreSistemaInformatico string `xml:"sum1:NombreSistemaInformatico"` IdSistemaInformatico string `xml:"sum1:IdSistemaInformatico"` //nolint:revive Version string `xml:"sum1:Version"` NumeroInstalacion string `xml:"sum1:NumeroInstalacion"` diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 9341b09..1eff58b 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -36,8 +36,8 @@ func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), FormatField("TipoFactura", inv.TipoFactura), - FormatField("CuotaTotal", inv.CuotaTotal), - FormatField("ImporteTotal", inv.ImporteTotal), + FormatField("CuotaTotal", fmt.Sprintf("%f", inv.CuotaTotal)), + FormatField("ImporteTotal", fmt.Sprintf("%f", inv.ImporteTotal)), FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } diff --git a/doc/invoice.go b/doc/invoice.go index f76387f..9b1a058 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -17,7 +17,10 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) return nil, err } - desglose := newDesglose(inv) + desglose, err := newDesglose(inv) + if err != nil { + return nil, err + } reg := &RegistroAlta{ IDVersion: CurrentVersion, @@ -29,21 +32,32 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) NombreRazonEmisor: inv.Supplier.Name, TipoFactura: mapInvoiceType(inv), DescripcionOperacion: description, - ImporteTotal: newImporteTotal(inv), - CuotaTotal: newTotalTaxes(inv), - SistemaInformatico: newSoftware(s), Desglose: desglose, + CuotaTotal: newTotalTaxes(inv), + ImporteTotal: newImporteTotal(inv), + SistemaInformatico: s, FechaHoraHusoGenRegistro: formatDateTimeZone(ts), TipoHuella: TipoHuella, } if inv.Customer != nil { - reg.Destinatarios = newDestinatario(inv.Customer) + d, err := newParty(inv.Customer) + if err != nil { + return nil, err + } + ds := &Destinatario{ + IDDestinatario: d, + } + reg.Destinatarios = []*Destinatario{ds} } if r == IssuerRoleThirdParty { reg.EmitidaPorTerceroODestinatario = "T" - reg.Tercero = newParty(inv.Supplier) + t, err := newParty(inv.Supplier) + if err != nil { + return nil, err + } + reg.Tercero = t } // Check @@ -94,7 +108,7 @@ func newDescription(notes []*cbc.Note) (string, error) { return "", validationErr(`notes: missing note with key '%s'`, cbc.NoteKeyGeneral) } -func newImporteTotal(inv *bill.Invoice) string { +func newImporteTotal(inv *bill.Invoice) float64 { totalWithDiscounts := inv.Totals.Total totalTaxes := num.MakeAmount(0, 2) @@ -104,10 +118,10 @@ func newImporteTotal(inv *bill.Invoice) string { } } - return totalWithDiscounts.Add(totalTaxes).String() + return totalWithDiscounts.Add(totalTaxes).Float64() } -func newTotalTaxes(inv *bill.Invoice) string { +func newTotalTaxes(inv *bill.Invoice) float64 { totalTaxes := num.MakeAmount(0, 2) for _, category := range inv.Totals.Taxes.Categories { if !category.Retained { @@ -115,15 +129,5 @@ func newTotalTaxes(inv *bill.Invoice) string { } } - return totalTaxes.String() -} - -func newSoftware(s *Software) *Software { - return &Software{ - NombreRazon: s.NombreRazon, - NIF: s.NIF, - IdSistemaInformatico: s.IdSistemaInformatico, - Version: s.Version, - NumeroInstalacion: s.NumeroInstalacion, - } + return totalTaxes.Float64() } diff --git a/doc/party.go b/doc/party.go index db0e394..1ad84fa 100644 --- a/doc/party.go +++ b/doc/party.go @@ -1,49 +1,58 @@ package doc import ( - "github.com/invopop/gobl/addons/es/verifactu" - "github.com/invopop/gobl/l10n" + "fmt" + + "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" ) -func newDestinatario(party *org.Party) []*Destinatario { - dest := &Destinatario{ - IDDestinatario: &Party{ - NombreRazon: party.Name, - }, - } +const ( + idTypeCodeTaxID = "02" +) - if party.TaxID != nil { - if party.TaxID.Country == l10n.ES.Tax() { - dest.IDDestinatario.NIF = party.TaxID.Code.String() - } else { - dest.IDDestinatario.IDOtro = &IDOtro{ - CodigoPais: party.TaxID.Country.String(), - IDType: "04", // Code for foreign tax IDs L7 - ID: party.TaxID.Code.String(), - } - } - } - return []*Destinatario{dest} +var idTypeCodeMap = map[cbc.Key]string{ + org.IdentityKeyPassport: "03", + org.IdentityKeyForeign: "04", + org.IdentityKeyResident: "05", + org.IdentityKeyOther: "06", } -func newParty(p *org.Party) *Party { +func newParty(p *org.Party) (*Party, error) { pty := &Party{ NombreRazon: p.Name, } - if p.TaxID != nil && p.TaxID.Code.String() != "" { + if p.TaxID != nil && p.TaxID.Code.String() != "" && p.TaxID.Country.String() == "ES" { pty.NIF = p.TaxID.Code.String() } else { - if len(p.Identities) > 0 { - for _, id := range p.Identities { - if id.Ext != nil && id.Ext[verifactu.ExtKeyIdentity] != "" { - pty.IDOtro = &IDOtro{ - IDType: string(id.Ext[verifactu.ExtKeyIdentity]), - ID: id.Code.String(), - } - } - } + pty.IDOtro = otherIdentity(p) + } + if pty.NIF == "" && pty.IDOtro == nil { + return nil, fmt.Errorf("customer with tax ID or other identity is required") + } + return pty, nil +} + +func otherIdentity(p *org.Party) *IDOtro { + oid := new(IDOtro) + if p.TaxID != nil && p.TaxID.Code != "" { + oid.IDType = idTypeCodeTaxID + oid.ID = p.TaxID.Code.String() + if p.TaxID.Country != "" { + oid.CodigoPais = p.TaxID.Country.String() + } + return oid + } + + for _, id := range p.Identities { + it, ok := idTypeCodeMap[id.Key] + if !ok { + continue } + + oid.IDType = it + oid.ID = id.Code.String() + return oid } - return pty + return nil } diff --git a/doc/party_test.go b/doc/party_test.go new file mode 100644 index 0000000..922184a --- /dev/null +++ b/doc/party_test.go @@ -0,0 +1,66 @@ +package doc + +import ( + "testing" + + "github.com/invopop/gobl/addons/es/verifactu" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewParty(t *testing.T) { + t.Run("with tax ID", func(t *testing.T) { + party := &org.Party{ + Name: "Test Company", + TaxID: &tax.Identity{ + Code: "B12345678", + }, + } + + result, err := newParty(party) + require.NoError(t, err) + + assert.Equal(t, "Test Company", result.NombreRazon) + assert.Equal(t, "B12345678", result.NIF) + assert.Nil(t, result.IDOtro) + }) + + t.Run("with identity", func(t *testing.T) { + party := &org.Party{ + Name: "Foreign Company", + Identities: []*org.Identity{ + { + Code: "12345", + Ext: map[cbc.Key]tax.ExtValue{ + verifactu.ExtKeyIdentity: "02", + }, + }, + }, + } + + result, err := newParty(party) + require.NoError(t, err) + + assert.Equal(t, "Foreign Company", result.NombreRazon) + assert.Empty(t, result.NIF) + assert.NotNil(t, result.IDOtro) + assert.Equal(t, "02", result.IDOtro.IDType) + assert.Equal(t, "12345", result.IDOtro.ID) + }) + + t.Run("with no identifiers", func(t *testing.T) { + party := &org.Party{ + Name: "Simple Company", + } + + result, err := newParty(party) + require.NoError(t, err) + + assert.Equal(t, "Simple Company", result.NombreRazon) + assert.Empty(t, result.NIF) + assert.Nil(t, result.IDOtro) + }) +} diff --git a/doc/qr_code.go b/doc/qr_code.go index f2dc7b4..7519375 100644 --- a/doc/qr_code.go +++ b/doc/qr_code.go @@ -6,44 +6,18 @@ import ( // "github.com/sigurn/crc8" ) -// Codes contain info about the codes that should be generated and shown on a -// Ticketbai invoice. One is an alphanumeric code that identifies the invoice -// and the other one is a URL (which can be used by a customer to validate that -// the invoice has been sent to the tax agency) that should be encoded as a -// QR code in the printed invoice / ticket. -type Codes struct { - URLCode string - QRCode string -} - const ( testURL = "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?" prodURL = "https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?nif=89890001K&numserie=12345678-G33&fecha=01-09-2024&importe=241.4" ) -// var crcTable = crc8.MakeTable(crc8.CRC8) - -// generateCodes will generate the QR and URL codes for the invoice -func (doc *VeriFactu) generateCodes() *Codes { - urlCode := doc.generateURLCodeAlta() - // qrCode := doc.generateQRCode(urlCode) - - return &Codes{ - URLCode: urlCode, - // QRCode: qrCode, - } -} - -// generateURLCode generates the encoded URL code with parameters. -func (doc *VeriFactu) generateURLCodeAlta() string { - - // URL encode each parameter +// generateURL generates the encoded URL code with parameters. +func (doc *VeriFactu) generateURL() string { nif := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) numSerie := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) fecha := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) - importe := url.QueryEscape(doc.RegistroFactura.RegistroAlta.ImporteTotal) + importe := url.QueryEscape(fmt.Sprintf("%f", doc.RegistroFactura.RegistroAlta.ImporteTotal)) - // Build the URL urlCode := fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", testURL, nif, numSerie, fecha, importe) diff --git a/doc/qr_code_test.go b/doc/qr_code_test.go new file mode 100644 index 0000000..b917727 --- /dev/null +++ b/doc/qr_code_test.go @@ -0,0 +1,105 @@ +package doc + +import ( + "testing" +) + +func TestGenerateCodes(t *testing.T) { + tests := []struct { + name string + doc *VeriFactu + expected string + }{ + { + name: "valid codes generation", + doc: &VeriFactu{ + RegistroFactura: &RegistroFactura{ + RegistroAlta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "89890001K", + NumSerieFactura: "12345678-G33", + FechaExpedicionFactura: "01-09-2024", + }, + ImporteTotal: 241.4, + }, + }, + }, + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=89890001K&numserie=12345678-G33&fecha=01-09-2024&importe=241.4", + }, + { + name: "empty fields", + doc: &VeriFactu{ + RegistroFactura: &RegistroFactura{ + RegistroAlta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "", + NumSerieFactura: "", + FechaExpedicionFactura: "", + }, + ImporteTotal: 0, + }, + }, + }, + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=&numserie=&fecha=&importe=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.doc.generateURL() + if got != tt.expected { + t.Errorf("generateURL() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestGenerateURLCodeAlta(t *testing.T) { + tests := []struct { + name string + doc *VeriFactu + expected string + }{ + { + name: "valid URL generation", + doc: &VeriFactu{ + RegistroFactura: &RegistroFactura{ + RegistroAlta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "89890001K", + NumSerieFactura: "12345678-G33", + FechaExpedicionFactura: "01-09-2024", + }, + ImporteTotal: 241.4, + }, + }, + }, + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=89890001K&numserie=12345678-G33&fecha=01-09-2024&importe=241.4", + }, + { + name: "URL with special characters", + doc: &VeriFactu{ + RegistroFactura: &RegistroFactura{ + RegistroAlta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "A12 345&67", + NumSerieFactura: "SERIE/2023", + FechaExpedicionFactura: "01-09-2024", + }, + ImporteTotal: 1234.56, + }, + }, + }, + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=A12+345%2667&numserie=SERIE%2F2023&fecha=01-09-2024&importe=1%2C234.56", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.doc.generateURL() + if got != tt.expected { + t.Errorf("generateURLCodeAlta() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/document.go b/document.go index 6285af4..ef4488c 100644 --- a/document.go +++ b/document.go @@ -46,11 +46,11 @@ func (c *Client) Fingerprint(d *doc.VeriFactu, prev *doc.ChainData) error { // AddQR adds the QR code stamp to the envelope. func (c *Client) AddQR(d *doc.VeriFactu, env *gobl.Envelope) error { // now generate the QR codes and add them to the envelope - codes := d.QRCodes() + code := d.QRCodes() env.Head.AddStamp( &head.Stamp{ Provider: verifactu.StampQR, - Value: codes.QRCode, + Value: code, }, ) return nil diff --git a/go.mod b/go.mod index e35fb00..49d69dc 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,14 @@ toolchain go1.22.1 // replace github.com/invopop/gobl => ../gobl require ( + github.com/go-resty/resty/v2 v2.15.3 github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34 github.com/invopop/xmldsig v0.10.0 + github.com/joho/godotenv v1.5.1 + github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.8.4 ) require ( @@ -19,17 +24,16 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beevik/etree v1.4.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect - github.com/go-resty/resty/v2 v2.15.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect github.com/invopop/validation v0.7.0 // indirect github.com/invopop/yaml v0.3.1 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/crypto v0.26.0 // indirect diff --git a/go.sum b/go.sum index e201289..51684fb 100644 --- a/go.sum +++ b/go.sum @@ -24,14 +24,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.204.0 h1:xvpq2EtRgc3eQ/rbWjWAKtTXc9OX02NVumTSvEk3U7g= -github.com/invopop/gobl v0.204.0/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= -github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807 h1:p2bmC9OhAt7IxorD5PhxstP5smfI2FGnvXK9+KuB8iM= -github.com/invopop/gobl v0.204.2-0.20241108112448-0cb518bcd807/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= -github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c h1:oPGUQlyzz0MLcN/0MwOYN1RVrfZjDrWCeUkivfO5bCI= -github.com/invopop/gobl v0.204.2-0.20241111171701-580b5e4d959c/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= -github.com/invopop/gobl v0.204.2-0.20241112123001-0ec4e55345b3 h1:aC3H14lRHw9wq7Q2WcKOOrnddt4nJVCGt84jY1IMQIU= -github.com/invopop/gobl v0.204.2-0.20241112123001-0ec4e55345b3/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34 h1:1iXp0n3c9tt2CWDuZsBE0cd0dMhgPvKRgIgpvjNYbbg= github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= @@ -53,6 +45,8 @@ github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 h1:1okRi+ZpH92VkeIJPJWfoNVXm3F4225PoT1LSO+gFfw= +github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80/go.mod h1:990JnYmJZFrx1vI1TALoD6/fCqnWlTx2FrPbYy2wi5I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 h1:igWZJluD8KtEtAgRyF4x6lqcxDry1ULztksMJh2mnQE= @@ -80,6 +74,8 @@ golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index b194fe9..db1c652 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -3,7 +3,6 @@ package gateways import ( "context" - "encoding/xml" "fmt" "net/http" "os" @@ -29,17 +28,6 @@ const ( correctStatus = "Correcto" ) -// Response defines the response fields from the VeriFactu gateway. -type Response struct { - XMLName xml.Name `xml:"RespuestaSuministro"` - CSV string `xml:"CSV"` - EstadoEnvio string `xml:"EstadoEnvio"` - RespuestaLinea []struct { - EstadoRegistro string `xml:"EstadoRegistro"` - DescripcionErrorRegistro string `xml:"DescripcionErrorRegistro,omitempty"` - } `xml:"RespuestaLinea"` -} - // Connection defines what is expected from a connection to a gateway. type Connection struct { client *resty.Client @@ -68,15 +56,15 @@ func New(env Environment, cert *xmldsig.Certificate) (*Connection, error) { // Post sends the VeriFactu document to the gateway func (c *Connection) Post(ctx context.Context, doc doc.VeriFactu) error { - payload, err := doc.Bytes() + pyl, err := doc.Envelop() if err != nil { return fmt.Errorf("generating payload: %w", err) } - return c.post(ctx, TestingBaseURL, payload) + return c.post(ctx, TestingBaseURL, pyl) } func (c *Connection) post(ctx context.Context, path string, payload []byte) error { - out := new(Response) + out := new(Envelope) req := c.client.R(). SetContext(ctx). SetDebug(true). @@ -92,13 +80,13 @@ func (c *Connection) post(ctx context.Context, path string, payload []byte) erro if res.StatusCode() != http.StatusOK { return ErrInvalid.withCode(strconv.Itoa(res.StatusCode())) } - - if out.EstadoEnvio != correctStatus { + if out.Body.Respuesta.EstadoEnvio != correctStatus { err := ErrInvalid - if len(out.RespuestaLinea) > 0 { - e1 := out.RespuestaLinea[0] - err = err.withMessage(e1.DescripcionErrorRegistro).withCode(e1.EstadoRegistro) + if len(out.Body.Respuesta.RespuestaLinea) > 0 { + e1 := out.Body.Respuesta.RespuestaLinea[0] + err = err.withMessage(e1.DescripcionErrorRegistro).withCode(e1.CodigoErrorRegistro) } + fmt.Println(out.Body.Respuesta) return err } diff --git a/internal/gateways/response.go b/internal/gateways/response.go new file mode 100644 index 0000000..bb1ff9a --- /dev/null +++ b/internal/gateways/response.go @@ -0,0 +1,50 @@ +package gateways + +import ( + "github.com/nbio/xml" +) + +// Envelope defines the SOAP envelope structure. +type Envelope struct { + XMLName xml.Name `xml:"Envelope"` + XMLNs string `xml:"xmlns:env,attr"` + Body Body `xml:"Body"` +} + +// Body represents the body of the SOAP envelope. +type Body struct { + XMLName xml.Name `xml:"Body"` + ID string `xml:"Id,attr,omitempty"` + Respuesta Response `xml:"RespuestaRegFactuSistemaFacturacion"` +} + +// Response defines the response fields from the VeriFactu gateway. +type Response struct { + XMLName xml.Name `xml:"RespuestaRegFactuSistemaFacturacion"` + TikNamespace string `xml:"xmlns:tik,attr,omitempty"` + TikRNamespace string `xml:"xmlns:tikR,attr,omitempty"` + Cabecera Cabecera `xml:"Cabecera"` + TiempoEsperaEnvio int `xml:"TiempoEsperaEnvio"` + EstadoEnvio string `xml:"EstadoEnvio"` + RespuestaLinea []struct { + IDFactura struct { + IDEmisorFactura string `xml:"IDEmisorFactura"` + NumSerieFactura string `xml:"NumSerieFactura"` + FechaExpedicionFactura string `xml:"FechaExpedicionFactura"` + } `xml:"IDFactura"` + Operacion struct { + TipoOperacion string `xml:"TipoOperacion"` + } `xml:"Operacion"` + EstadoRegistro string `xml:"EstadoRegistro"` + CodigoErrorRegistro string `xml:"CodigoErrorRegistro,omitempty"` + DescripcionErrorRegistro string `xml:"DescripcionErrorRegistro,omitempty"` + } `xml:"RespuestaLinea"` +} + +// Cabecera represents the header section of the response. +type Cabecera struct { + ObligadoEmision struct { + NombreRazon string `xml:"NombreRazon"` + NIF string `xml:"NIF"` + } `xml:"ObligadoEmision"` +} diff --git a/test/data/inv-base.json b/test/data/inv-base.json new file mode 100644 index 0000000..938881d --- /dev/null +++ b/test/data/inv-base.json @@ -0,0 +1,128 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-verifactu-v1" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "code": "SAMPLE-002", + "issue_date": "2024-11-11", + "currency": "EUR", + "tax": { + "ext": { + "es-verifactu-doc-type": "F1" + } + }, + "supplier": { + "name": "Invopop S.L.", + "tax_id": { + "country": "ES", + "code": "B85905495" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "B63272603" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "1800.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%" + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1810.00", + "total": "1810.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1810.00", + "percent": "21.0%", + "amount": "380.10", + "ext": { + "es-verifactu-tax-classification": "S1", + "es-verifactu-tax-category": "01", + "es-verifactu-tax-regime": "01" + } + } + ], + "amount": "380.10" + } + ], + "sum": "380.10" + }, + "tax": "380.10", + "total_with_tax": "2190.10", + "payable": "2190.10" + }, + "notes": [ + { + "key": "general", + "text": "This is a sample invoice" + } + ] + } +} \ No newline at end of file diff --git a/test/data/invoice-es-es.json b/test/data/invoice-es-es.json index 1aca426..63cc913 100644 --- a/test/data/invoice-es-es.json +++ b/test/data/invoice-es-es.json @@ -25,10 +25,10 @@ } }, "supplier": { - "name": "Provide One S.L.", + "name": "Invopop S.L.", "tax_id": { "country": "ES", - "code": "B98602642" + "code": "B85905495" }, "addresses": [ { diff --git a/test/test.go b/test/test.go new file mode 100644 index 0000000..182f478 --- /dev/null +++ b/test/test.go @@ -0,0 +1,102 @@ +// Package test provides common functions for testing. +package test + +import ( + "bytes" + "os" + "path" + "path/filepath" + "strings" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/bill" +) + +// LoadEnvelope loads a test file from the test/data folder as a GOBL envelope +// and will rebuild it if necessary to ensure any changes are accounted for. +func LoadEnvelope(file string) *gobl.Envelope { + path := Path("test", "data", file) + f, err := os.Open(path) + if err != nil { + panic(err) + } + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(f); err != nil { + panic(err) + } + + out, err := gobl.Parse(buf.Bytes()) + if err != nil { + panic(err) + } + + var env *gobl.Envelope + switch doc := out.(type) { + case *gobl.Envelope: + env = doc + default: + env = gobl.NewEnvelope() + if err := env.Insert(doc); err != nil { + panic(err) + } + } + + if err := env.Calculate(); err != nil { + panic(err) + } + + if err := env.Validate(); err != nil { + panic(err) + } + + return env +} + +// LoadInvoice grabs the gobl envelope and attempts to extract the invoice payload +func LoadInvoice(name string) *bill.Invoice { + env := LoadEnvelope(name) + + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + panic("envelope does not contain an invoice") + } + + return inv +} + +// Path joins the provided elements to the project root +func Path(elements ...string) string { + np := []string{RootPath()} + np = append(np, elements...) + return path.Join(np...) +} + +// RootPath returns the project root, regardless of current working directory. +func RootPath() string { + cwd, _ := os.Getwd() + + for !isRootFolder(cwd) { + cwd = removeLastEntry(cwd) + } + + return cwd +} + +func isRootFolder(dir string) bool { + files, _ := os.ReadDir(dir) + + for _, file := range files { + if file.Name() == "go.mod" { + return true + } + } + + return false +} + +func removeLastEntry(dir string) string { + lastEntry := "/" + filepath.Base(dir) + i := strings.LastIndex(dir, lastEntry) + return dir[:i] +} From d90abe3a54726dfc5753b9e6cebd0c68f01d37e4 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 19 Nov 2024 17:31:35 +0000 Subject: [PATCH 19/56] Add Actions --- .github/workflows/lint.yaml | 27 +++++++++++++++++ .github/workflows/release.yaml | 54 ++++++++++++++++++++++++++++++++++ .github/workflows/test.yaml | 27 +++++++++++++++++ doc/breakdown.go | 19 ++++++++---- 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..63a17b6 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,27 @@ +name: Lint +on: + push: + tags: + - v* + branches: + - main + pull_request: +jobs: + lint: + name: golangci-lint + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + id: go + + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.58 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..3ab95d0 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,54 @@ +# +# Automatically tag a merge with master and release it +# + +name: Release + +on: + push: + branches: + - main + tags: + - "*" + +jobs: + tag-release: + name: Tag and Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: "0" # make sure we get all commits! + + - name: Get repo details + run: | + echo "COMMIT_TYPE=$(echo $GITHUB_REF | cut -d / -f 2)" >> $GITHUB_ENV + echo "REPO_NAME=$(echo $GITHUB_REPOSITORY | cut -d / -f 2-)" >> $GITHUB_ENV + echo "REPO_VERSION=$(echo $GITHUB_REF | cut -d / -f 3-)" >> $GITHUB_ENV + + - name: Bump version and push tag + id: bump + if: env.COMMIT_TYPE != 'tags' + uses: anothrNick/github-tag-action@1.52.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BRANCHES: main + WITH_V: true + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.21 + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + # either 'goreleaser' (default) or 'goreleaser-pro' + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution + # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..10a4e38 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,27 @@ +name: Test Go +on: [push] +jobs: + lint-test-build: + name: Test, Build + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + id: go + + - name: Install Dependencies + env: + GOPROXY: https://proxy.golang.org,direct + run: go mod download + + - name: Test + run: go test -tags unit -race ./... + + - name: Build + run: go build -v ./... diff --git a/doc/breakdown.go b/doc/breakdown.go index 8dbc364..e5644ad 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -2,6 +2,7 @@ package doc import ( "fmt" + "strings" "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" @@ -65,11 +66,17 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl // VeriFactu is called "No sujeta". if r.Key == tax.RateZero { detalle.OperacionExenta = r.Ext[verifactu.ExtKeyTaxClassification].String() + if detalle.OperacionExenta != "" && !strings.HasPrefix(detalle.OperacionExenta, "E") { + return nil, fmt.Errorf("invalid exemption code %s - must be E1-E6", detalle.OperacionExenta) + } } else { detalle.CalificacionOperacion = r.Ext[verifactu.ExtKeyTaxClassification].String() + if detalle.CalificacionOperacion == "" { + return nil, fmt.Errorf("missing operation classification for rate %s", r.Key) + } } - if isSpecialRegime(r) { + if isSpecialRegime(c, r) { detalle.BaseImponibleACoste = r.Base.Float64() } @@ -81,7 +88,7 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl return nil, fmt.Errorf("missing operation classification for rate %s", r.Key) } - if hasEquivalenceSurcharge(r) { + if hasEquivalenceSurcharge(c, r) { if r.Surcharge == nil { return nil, fmt.Errorf("missing surcharge for rate %s", r.Key) } @@ -92,10 +99,10 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl return detalle, nil } -func isSpecialRegime(r *tax.RateTotal) bool { - return r.Ext != nil && (r.Ext[verifactu.ExtKeyTaxCategory] == "02" || r.Ext[verifactu.ExtKeyTaxCategory] == "05" || r.Ext[verifactu.ExtKeyTaxRegime] == "06") +func isSpecialRegime(c *tax.CategoryTotal, r *tax.RateTotal) bool { + return r.Ext != nil && (c.Code == es.TaxCategoryIGIC || c.Code == es.TaxCategoryIPSI || r.Ext[verifactu.ExtKeyTaxRegime] == "18") } -func hasEquivalenceSurcharge(r *tax.RateTotal) bool { - return r.Ext != nil && r.Ext[verifactu.ExtKeyTaxCategory] == "01" && r.Ext[verifactu.ExtKeyTaxRegime] == "18" +func hasEquivalenceSurcharge(c *tax.CategoryTotal, r *tax.RateTotal) bool { + return r.Ext != nil && c.Code == tax.CategoryVAT && r.Ext[verifactu.ExtKeyTaxRegime] == "18" } From 225bc7c529d58a146188c2f5a6b5fac381b2a529 Mon Sep 17 00:00:00 2001 From: apardods Date: Wed, 20 Nov 2024 17:33:56 +0000 Subject: [PATCH 20/56] Breakdown tests --- cmd/gobl.verifactu/convert.go | 2 +- doc/breakdown.go | 80 +++++--- doc/breakdown_test.go | 183 +++++++++++++++++- doc/party.go | 6 +- doc/party_test.go | 40 ++-- doc/qr_code.go | 2 +- doc/qr_code_test.go | 4 +- internal/gateways/errors.go | 7 - test/data/credit-note-es-es-tbai.json | 158 --------------- test/data/inv-base.json | 79 +++----- test/data/inv-eqv-sur.json | 116 +++++++++++ .../ejemploRegistro-signed-epes-xades4j.xml | 137 ------------- test/schema/ejemploRegistro-sin_firmar.xml | 77 -------- test/schema/example-alta.xml | 77 ++++++++ test/schema/example-anulacion.xml | 50 +++++ test/schema/example-subsanacion.xml | 78 ++++++++ verifactu.go | 19 +- 17 files changed, 618 insertions(+), 497 deletions(-) delete mode 100644 test/data/credit-note-es-es-tbai.json create mode 100644 test/data/inv-eqv-sur.json delete mode 100644 test/schema/ejemploRegistro-signed-epes-xades4j.xml delete mode 100644 test/schema/ejemploRegistro-sin_firmar.xml create mode 100644 test/schema/example-alta.xml create mode 100644 test/schema/example-anulacion.xml create mode 100644 test/schema/example-subsanacion.xml diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go index 2ef63d8..c1f1ef8 100644 --- a/cmd/gobl.verifactu/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -59,7 +59,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { doc, err := vf.Convert(env) if err != nil { - panic(err) + return fmt.Errorf("converting to verifactu xml: %w", err) } data, err := doc.BytesIndent() diff --git a/doc/breakdown.go b/doc/breakdown.go index e5644ad..ffdca1b 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -7,6 +7,8 @@ import ( "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" ) @@ -22,7 +24,7 @@ func newDesglose(inv *bill.Invoice) (*Desglose, error) { for _, c := range inv.Totals.Taxes.Categories { for _, r := range c.Rates { - detalleDesglose, err := buildDetalleDesglose(c, r) + detalleDesglose, err := buildDetalleDesglose(inv, c, r) if err != nil { return nil, err } @@ -35,31 +37,21 @@ func newDesglose(inv *bill.Invoice) (*Desglose, error) { // Rules applied to build the breakdown come from: // https://www.agenciatributaria.es/static_files/AEAT_Desarrolladores/EEDD/IVA/VERI-FACTU/Validaciones_Errores_Veri-Factu.pdf -func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesglose, error) { +func buildDetalleDesglose(inv *bill.Invoice, c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesglose, error) { detalle := &DetalleDesglose{ BaseImponibleOImporteNoSujeto: r.Base.Float64(), CuotaRepercutida: r.Amount.Float64(), } - // L1: IVA, IGIC, IPSI or Other. Default is IVA. - if r.Ext != nil && r.Ext[verifactu.ExtKeyTaxCategory] != "" { - cat, ok := taxCategoryCodeMap[c.Code] - if !ok { - detalle.Impuesto = "05" - } else { - detalle.Impuesto = cat - } - if detalle.Impuesto == "01" { - // L8A: IVA - detalle.ClaveRegimen = r.Ext[verifactu.ExtKeyTaxRegime].String() - } - if detalle.Impuesto == "02" { - // L8B: IGIC - detalle.ClaveRegimen = r.Ext[verifactu.ExtKeyTaxRegime].String() - } + cat, ok := taxCategoryCodeMap[c.Code] + if !ok { + detalle.Impuesto = "05" } else { - // L8A: IVA - detalle.ClaveRegimen = r.Ext[verifactu.ExtKeyTaxRegime].String() + detalle.Impuesto = cat + } + + if c.Code == tax.CategoryVAT || c.Code == es.TaxCategoryIGIC { + detalle.ClaveRegimen = parseClave(inv, c, r) } // Rate zero is what VeriFactu calls "Exempt operation", in difference to GOBL's exempt operation, which in @@ -76,7 +68,7 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl } } - if isSpecialRegime(c, r) { + if detalle.Impuesto == "02" || detalle.Impuesto == "05" || detalle.ClaveRegimen == "06" { detalle.BaseImponibleACoste = r.Base.Float64() } @@ -88,10 +80,7 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl return nil, fmt.Errorf("missing operation classification for rate %s", r.Key) } - if hasEquivalenceSurcharge(c, r) { - if r.Surcharge == nil { - return nil, fmt.Errorf("missing surcharge for rate %s", r.Key) - } + if r.Key.Has(es.TaxRateEquivalence) { detalle.TipoRecargoEquivalencia = r.Surcharge.Percent.Amount().Float64() detalle.CuotaRecargoEquivalencia = r.Surcharge.Amount.Float64() } @@ -99,10 +88,43 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl return detalle, nil } -func isSpecialRegime(c *tax.CategoryTotal, r *tax.RateTotal) bool { - return r.Ext != nil && (c.Code == es.TaxCategoryIGIC || c.Code == es.TaxCategoryIPSI || r.Ext[verifactu.ExtKeyTaxRegime] == "18") +func parseClave(inv *bill.Invoice, c *tax.CategoryTotal, r *tax.RateTotal) string { + switch c.Code { + case tax.CategoryVAT: + if inv.Customer != nil && partyTaxCountry(inv.Customer) != "ES" { + return "02" + } + if inv.HasTags(es.TagSecondHandGoods) || inv.HasTags(es.TagAntiques) || inv.HasTags(es.TagArt) { + return "03" + } + if inv.HasTags(es.TagTravelAgency) { + return "05" + } + if r.Key == es.TaxRateEquivalence { + return "18" + } + if inv.HasTags(es.TagSimplifiedScheme) { + return "20" + } + return "01" + case es.TaxCategoryIGIC: + if inv.Customer != nil && partyTaxCountry(inv.Customer) != "ES" { + return "02" + } + if inv.HasTags(es.TagSecondHandGoods) || inv.HasTags(es.TagAntiques) || inv.HasTags(es.TagArt) { + return "03" + } + if inv.HasTags(es.TagTravelAgency) { + return "05" + } + return "01" + } + return "" } -func hasEquivalenceSurcharge(c *tax.CategoryTotal, r *tax.RateTotal) bool { - return r.Ext != nil && c.Code == tax.CategoryVAT && r.Ext[verifactu.ExtKeyTaxRegime] == "18" +func partyTaxCountry(party *org.Party) l10n.TaxCountryCode { + if party != nil && party.TaxID != nil { + return party.TaxID.Country + } + return "ES" } diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index 195a571..7c09484 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -5,31 +5,34 @@ import ( "time" "github.com/invopop/gobl.verifactu/test" + "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBreakdownConversion(t *testing.T) { - t.Run("should handle basic invoice breakdown", func(t *testing.T) { - inv := test.LoadInvoice("./test/data/inv-base.json") + t.Run("basic-invoice", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") err := inv.Calculate() require.NoError(t, err) doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) - assert.Equal(t, 200.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 42.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, 1800.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 378.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) - t.Run("should handle exempt taxes", func(t *testing.T) { - inv := test.LoadInvoice("./test/data/inv-base.json") + t.Run("exempt-taxes", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") inv.Lines = []*bill.Line{ { Quantity: num.MakeAmount(1, 0), @@ -40,6 +43,9 @@ func TestBreakdownConversion(t *testing.T) { &tax.Combo{ Category: "VAT", Rate: "zero", + Ext: tax.Extensions{ + verifactu.ExtKeyTaxClassification: "E1", + }, }, }, }, @@ -51,11 +57,12 @@ func TestBreakdownConversion(t *testing.T) { require.NoError(t, err) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, "", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "E1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].OperacionExenta) }) - t.Run("should handle multiple tax rates", func(t *testing.T) { - inv := test.LoadInvoice("./test/data/inv-base.json") + t.Run("multiple-tax-rates", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") inv.Lines = []*bill.Line{ { Quantity: num.MakeAmount(1, 0), @@ -66,6 +73,9 @@ func TestBreakdownConversion(t *testing.T) { &tax.Combo{ Category: "VAT", Rate: "standard", + Ext: tax.Extensions{ + verifactu.ExtKeyTaxClassification: "S1", + }, }, }, }, @@ -78,6 +88,9 @@ func TestBreakdownConversion(t *testing.T) { &tax.Combo{ Category: "VAT", Rate: "reduced", + Ext: tax.Extensions{ + verifactu.ExtKeyTaxClassification: "S1", + }, }, }, }, @@ -89,11 +102,161 @@ func TestBreakdownConversion(t *testing.T) { assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) assert.Equal(t, 50.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 10.50, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].CuotaRepercutida) + assert.Equal(t, 5.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].CuotaRepercutida) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].Impuesto) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].ClaveRegimen) + }) + + t.Run("not-subject-taxes", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.Lines = []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(100, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "exempt", + Ext: tax.Extensions{ + verifactu.ExtKeyTaxClassification: "N1", + }, + }, + }, + }, + } + _ = inv.Calculate() + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 0.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "N1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + }) + + t.Run("missing-tax-classification", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.Lines = []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(100, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "standard", + }, + }, + }, + } + _ = inv.Calculate() + + _, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.Error(t, err) + }) + t.Run("equivalence-surcharge", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.Lines = []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(100, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "standard+eqs", + Ext: tax.Extensions{ + verifactu.ExtKeyTaxClassification: "S1", + }, + }, + }, + }, + } + _ = inv.Calculate() + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, 5.20, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRecargoEquivalencia) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + }) + + t.Run("ipsi-tax", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + p := num.MakePercentage(10, 2) + inv.Lines = []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(100, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: es.TaxCategoryIPSI, + Percent: &p, + Ext: tax.Extensions{ + verifactu.ExtKeyTaxClassification: "S1", + }, + }, + }, + }, + } + _ = inv.Calculate() + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 10.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "03", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Empty(t, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + }) + + t.Run("antiques", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.Tags = tax.WithTags(es.TagAntiques) + inv.Lines = []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(1000, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "reduced", + Ext: tax.Extensions{ + verifactu.ExtKeyTaxClassification: "S1", + }, + }, + }, + }, + } + _ = inv.Calculate() + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, 1000.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "03", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) } diff --git a/doc/party.go b/doc/party.go index 1ad84fa..9fe9460 100644 --- a/doc/party.go +++ b/doc/party.go @@ -7,10 +7,6 @@ import ( "github.com/invopop/gobl/org" ) -const ( - idTypeCodeTaxID = "02" -) - var idTypeCodeMap = map[cbc.Key]string{ org.IdentityKeyPassport: "03", org.IdentityKeyForeign: "04", @@ -36,7 +32,7 @@ func newParty(p *org.Party) (*Party, error) { func otherIdentity(p *org.Party) *IDOtro { oid := new(IDOtro) if p.TaxID != nil && p.TaxID.Code != "" { - oid.IDType = idTypeCodeTaxID + oid.IDType = idTypeCodeMap[org.IdentityKeyForeign] oid.ID = p.TaxID.Code.String() if p.TaxID.Country != "" { oid.CodigoPais = p.TaxID.Country.String() diff --git a/doc/party_test.go b/doc/party_test.go index 922184a..ebaa5b7 100644 --- a/doc/party_test.go +++ b/doc/party_test.go @@ -3,8 +3,6 @@ package doc import ( "testing" - "github.com/invopop/gobl/addons/es/verifactu" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" @@ -16,7 +14,8 @@ func TestNewParty(t *testing.T) { party := &org.Party{ Name: "Test Company", TaxID: &tax.Identity{ - Code: "B12345678", + Country: "ES", + Code: "B12345678", }, } @@ -28,15 +27,13 @@ func TestNewParty(t *testing.T) { assert.Nil(t, result.IDOtro) }) - t.Run("with identity", func(t *testing.T) { + t.Run("with passport", func(t *testing.T) { party := &org.Party{ - Name: "Foreign Company", + Name: "Mr. Pass Port", Identities: []*org.Identity{ { + Key: org.IdentityKeyPassport, Code: "12345", - Ext: map[cbc.Key]tax.ExtValue{ - verifactu.ExtKeyIdentity: "02", - }, }, }, } @@ -44,23 +41,38 @@ func TestNewParty(t *testing.T) { result, err := newParty(party) require.NoError(t, err) - assert.Equal(t, "Foreign Company", result.NombreRazon) + assert.Equal(t, "Mr. Pass Port", result.NombreRazon) assert.Empty(t, result.NIF) assert.NotNil(t, result.IDOtro) - assert.Equal(t, "02", result.IDOtro.IDType) + assert.Equal(t, "03", result.IDOtro.IDType) assert.Equal(t, "12345", result.IDOtro.ID) }) - t.Run("with no identifiers", func(t *testing.T) { + t.Run("with foreign identity", func(t *testing.T) { party := &org.Party{ - Name: "Simple Company", + Name: "Foreign Company", + TaxID: &tax.Identity{ + Country: "DE", + Code: "111111125", + }, } result, err := newParty(party) require.NoError(t, err) - assert.Equal(t, "Simple Company", result.NombreRazon) + assert.Equal(t, "Foreign Company", result.NombreRazon) assert.Empty(t, result.NIF) - assert.Nil(t, result.IDOtro) + assert.NotNil(t, result.IDOtro) + assert.Equal(t, "04", result.IDOtro.IDType) + assert.Equal(t, "111111125", result.IDOtro.ID) + }) + + t.Run("with no identifiers", func(t *testing.T) { + party := &org.Party{ + Name: "Simple Company", + } + + _, err := newParty(party) + require.Error(t, err) }) } diff --git a/doc/qr_code.go b/doc/qr_code.go index 7519375..a12709e 100644 --- a/doc/qr_code.go +++ b/doc/qr_code.go @@ -16,7 +16,7 @@ func (doc *VeriFactu) generateURL() string { nif := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) numSerie := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) fecha := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) - importe := url.QueryEscape(fmt.Sprintf("%f", doc.RegistroFactura.RegistroAlta.ImporteTotal)) + importe := url.QueryEscape(fmt.Sprintf("%g", doc.RegistroFactura.RegistroAlta.ImporteTotal)) urlCode := fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", testURL, nif, numSerie, fecha, importe) diff --git a/doc/qr_code_test.go b/doc/qr_code_test.go index b917727..8e9bb63 100644 --- a/doc/qr_code_test.go +++ b/doc/qr_code_test.go @@ -40,7 +40,7 @@ func TestGenerateCodes(t *testing.T) { }, }, }, - expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=&numserie=&fecha=&importe=", + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=&numserie=&fecha=&importe=0", }, } @@ -90,7 +90,7 @@ func TestGenerateURLCodeAlta(t *testing.T) { }, }, }, - expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=A12+345%2667&numserie=SERIE%2F2023&fecha=01-09-2024&importe=1%2C234.56", + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=A12+345%2667&numserie=SERIE%2F2023&fecha=01-09-2024&importe=1234.56", }, } diff --git a/internal/gateways/errors.go b/internal/gateways/errors.go index d16295e..3ceb004 100644 --- a/internal/gateways/errors.go +++ b/internal/gateways/errors.go @@ -120,13 +120,6 @@ func (e *Error) withMessage(msg string) *Error { return e } -// func (e *Error) withCause(err error) *Error { -// e = e.clone() -// e.cause = err -// e.message = err.Error() -// return e -// } - func (e *Error) clone() *Error { ne := new(Error) *ne = *e diff --git a/test/data/credit-note-es-es-tbai.json b/test/data/credit-note-es-es-tbai.json deleted file mode 100644 index bd0e4db..0000000 --- a/test/data/credit-note-es-es-tbai.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "96ae76238c90546f3fa238637b07a0080e9d41670ca32029a274c705453cb225" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "$addons": [ - "es-tbai-v1" - ], - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "credit-note", - "series": "FR", - "code": "012", - "issue_date": "2022-02-01", - "currency": "EUR", - "preceding": [ - { - "type": "standard", - "issue_date": "2022-01-10", - "series": "SAMPLE", - "code": "085", - "ext": { - "es-tbai-correction": "R2" - } - } - ], - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "San Frantzisko", - "locality": "Bilbo", - "region": "Bizkaia", - "code": "48003", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Customer", - "tax_id": { - "country": "ES", - "code": "54387763P" - }, - "addresses": [ - { - "num": "13", - "street": "Calle del Barro", - "locality": "Alcañiz", - "region": "Teruel", - "code": "44600", - "country": "ES" - } - ], - "emails": [ - { - "addr": "customer@example.com" - } - ] - }, - "lines": [ - { - "i": 1, - "quantity": "20", - "item": { - "name": "Development services", - "price": "90.00", - "unit": "h" - }, - "sum": "1800.00", - "discounts": [ - { - "reason": "Special discount", - "percent": "10%", - "amount": "180.00" - } - ], - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "1620.00" - }, - { - "i": 2, - "quantity": "1", - "item": { - "name": "Financial service", - "price": "10.00" - }, - "sum": "10.00", - "taxes": [ - { - "cat": "VAT", - "rate": "zero", - "percent": "0.0%" - } - ], - "total": "10.00" - } - ], - "totals": { - "sum": "1630.00", - "total": "1630.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "1620.00", - "percent": "21.0%", - "amount": "340.20" - }, - { - "key": "zero", - "base": "10.00", - "percent": "0.0%", - "amount": "0.00" - } - ], - "amount": "340.20" - } - ], - "sum": "340.20" - }, - "tax": "340.20", - "total_with_tax": "1970.20", - "payable": "1970.20" - }, - "notes": [ - { - "key": "general", - "text": "Some random description" - } - ] - } -} \ No newline at end of file diff --git a/test/data/inv-base.json b/test/data/inv-base.json index 938881d..766b8af 100644 --- a/test/data/inv-base.json +++ b/test/data/inv-base.json @@ -15,7 +15,8 @@ ], "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", "type": "standard", - "code": "SAMPLE-002", + "series": "SAMPLE", + "number": "002", "issue_date": "2024-11-11", "currency": "EUR", "tax": { @@ -66,58 +67,42 @@ { "cat": "VAT", "rate": "standard", - "percent": "21.0%" + "percent": "21.0%", + "ext": { + "es-verifactu-tax-classification": "S1" + } } ], "total": "1800.00" - }, - { - "i": 2, - "quantity": "1", - "item": { - "name": "Financial service", - "price": "10.00" - }, - "sum": "10.00", - "taxes": [ + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1800.00", + "percent": "21.0%", + "amount": "378.00", + "ext": { + "es-verifactu-tax-classification": "S1" + } + } + ], + "amount": "378.00" } ], - "total": "10.00" - } - ], - "totals": { - "sum": "1810.00", - "total": "1810.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "1810.00", - "percent": "21.0%", - "amount": "380.10", - "ext": { - "es-verifactu-tax-classification": "S1", - "es-verifactu-tax-category": "01", - "es-verifactu-tax-regime": "01" - } - } - ], - "amount": "380.10" - } - ], - "sum": "380.10" - }, - "tax": "380.10", - "total_with_tax": "2190.10", - "payable": "2190.10" - }, + "sum": "378.00" + }, + "tax": "378.00", + "total_with_tax": "2178.00", + "payable": "2178.00" + }, "notes": [ { "key": "general", diff --git a/test/data/inv-eqv-sur.json b/test/data/inv-eqv-sur.json new file mode 100644 index 0000000..d95d9f9 --- /dev/null +++ b/test/data/inv-eqv-sur.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", + "dig": { + "alg": "sha256", + "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "$addons": [ + "es-verifactu-v1" + ], + "series": "SAMPLE", + "code": "432", + "issue_date": "2024-11-11", + "currency": "EUR", + "tax": { + "ext": { + "es-verifactu-doc-type": "F1" + } + }, + "supplier": { + "name": "Invopop S.L.", + "tax_id": { + "country": "ES", + "code": "B85905495" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "B63272603" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard+eqs", + "percent": "21.0%", + "surcharge": "5.2%" + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard+eqs", + "base": "1800.00", + "percent": "21.0%", + "surcharge": { + "percent": "5.2%", + "amount": "93.60" + }, + "amount": "378.00", + "ext": { + "es-verifactu-tax-classification": "S1" + } + } + ], + "amount": "378.00", + "surcharge": "93.60" + } + ], + "sum": "471.60" + }, + "tax": "471.60", + "total_with_tax": "2271.60", + "payable": "2271.60" + }, + "notes": [ + { + "key": "general", + "text": "This is a sample invoice" + } + ] + } +} \ No newline at end of file diff --git a/test/schema/ejemploRegistro-signed-epes-xades4j.xml b/test/schema/ejemploRegistro-signed-epes-xades4j.xml deleted file mode 100644 index a6a161a..0000000 --- a/test/schema/ejemploRegistro-signed-epes-xades4j.xml +++ /dev/null @@ -1,137 +0,0 @@ - - 1.0 - - 89890001k - 12345678-G33 - 01-01-2024 - - certificado uno telematicas - N - N - R3 - I - - - 89890002e - AGRUPRECT_MULT_00 - 02-04-2023 - - - 01-01-2024 - DescripcPRIEMAR - - - - certificado dos telematicas - 89890002e - - - - - 01 - S1 - 4 - 10 - 0.4 - - - 01 - S1 - 21 - 100 - 21 - - - 05 - S1 - 10 - 100 - 10 - - - 41.4 - 241.4 - - - B80260144 - 44 - 15-03-2024 - huella - - - - CERTIFICADO DOS TELEMATICAS - - B80260144 - NombreSistemaInformatico - 77 - 1.0.03 - 383 - S - N - N - - 2024-02-07T12:30:00+02:00 - 01 - 5C56AC56F13DB2A25E0031BD2B7ED5C9AF4AF1EF5EBBA700350DE3EEF12C2D35 - - - - - - - - - -tvhJSkSx7f7wccHZ5t3wheEzLzLjJJgNIxg9e9CGy3w= - - - - - - -cwsM+dcIDdncxmTK2wR1NLmqmScuUJyjBJP/IXHwu1s= - - - -RvjfaPYU98C5NBgUO4RGeMhk1yzGvojwN9mR7lO8ttTlfu/S9eKrjXSKuhg1kg5wckQKdB8X5jwy -bZopbH65rfcb3LPE+v6d4lgcwiUlZqBIy1hYar2tKYx8Nq11LFHhsEshr3WxgjQFbCf4IAosUuRQ -20AjtvHAik6grvO/iWF5SqNX0R0a+6UJ5NOmy4vheCGZ/pVCPWKncw6dSju8yQG2kOaRu63+h6r3 -tzvtCsNwvjPGbpR8imSOYv0p3HbIKlw3LKsn5WA+dEUMKP47qglcxdqXrGkwdyLdRuVI59CcJXt1 -8Nx2qud3YolDHboR1x+ePbuJfrU859hcB25OeQ== - - - - -MIIDrTCCApWgAwIBAgIUZPpb63n9H8d9VgsG4YgpdwpdreYwDQYJKoZIhvcNAQELBQAwZjELMAkG -A1UEBhMCRVMxDDAKBgNVBAgMA01BRDEPMA0GA1UEBwwGTWFkcmlkMRUwEwYDVQQKDAxGaXJtYUZh -Y3R1cmExCzAJBgNVBAsMAklUMRQwEgYDVQQDDAtGYWN0dXJhY2lvbjAeFw0yNDAxMjYwODUwMTBa -Fw0yNTAxMjUwODUwMTBaMGYxCzAJBgNVBAYTAkVTMQwwCgYDVQQIDANNQUQxDzANBgNVBAcMBk1h -ZHJpZDEVMBMGA1UECgwMRmlybWFGYWN0dXJhMQswCQYDVQQLDAJJVDEUMBIGA1UEAwwLRmFjdHVy -YWNpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+aGQ7IDfQSPux7mZF1fn+q6Fh -pohOOjT3wWWZc33FpnIbHGop61mTQ7I5anqNQH3BnbBp8u+Y6r6DI5KO3nFB9LPDD9xnAfXMrC1E -gIyFfRI4gqbfisQaCAYFouk0zJstAIFu7zAq/ntGLMbyFkublxhDo1mqOxm3vlAvQjTwPdxrIeY7 -Dj2OpgyoBjvXn6pzsKgc+u3KvLX2xP4PlQzTMllJBfhp8n2gYEMHB0d5v2uF86JN7YSX0RsQyf6y -C0loXIGI/JwF4V2Dys45Zbe6GRliQzGVurjS9B53rnvT2RbZsZWkUziOLcMTBQc0OGqvZJniBXja -rDNt6K8i++2hAgMBAAGjUzBRMB0GA1UdDgQWBBQ3D07fv/INEyd5Iu2m7Ax3/Lr/IjAfBgNVHSME -GDAWgBQ3D07fv/INEyd5Iu2m7Ax3/Lr/IjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA -A4IBAQBJNeHjIh2fMn2omR8CxbpvUjvn/vM1vaLzN+UaF0f8xfF5bOyJrrlEVu6ECZ3hNR4sqxfE -TagzOiu/n2J/PKON4yqvt9R4bBvY8z/H8pC1wXw+PvBHFqqt3RXmw8xB+BcRBPoW9BfiQmvhcIdM -gv1y5RZwdmt+092SRKbzW3GB3MiPhh+SkwuXiGHjMGJD2z2pbtH3Fbe4XgDGFVHkAPCofzP5I7me -dg+i+OPq3YwNCbQ9qeMeipgnjxSTbkzVsNrF6j+0TNU3q2vMCnITnkjdiRckOKOyHe1Uoi+UuZna -I4+GkhVOUpHIldTO54dSaR67cg2tYhDoYWU63DXe89yy - - - - -vmhkOyA30Ej7se5mRdX5/quhYaaITjo098FlmXN9xaZyGxxqKetZk0OyOWp6jUB9wZ2wafLvmOq+ -gyOSjt5xQfSzww/cZwH1zKwtRICMhX0SOIKm34rEGggGBaLpNMybLQCBbu8wKv57RizG8hZLm5cY -Q6NZqjsZt75QL0I08D3cayHmOw49jqYMqAY715+qc7CoHPrtyry19sT+D5UM0zJZSQX4afJ9oGBD -BwdHeb9rhfOiTe2El9EbEMn+sgtJaFyBiPycBeFdg8rOOWW3uhkZYkMxlbq40vQed65709kW2bGV -pFM4ji3DEwUHNDhqr2SZ4gV42qwzbeivIvvtoQ== -AQAB - - - -2024-07-22T09:00:46.397+02:00YUhXomOMz7gh8rOuiBM+i3+LUoDDKvcOibuPlRaW4DY=cn=Facturacion,ou=IT,o=FirmaFactura,l=Madrid,st=MAD,c=ES576482270728543507966757905488730867767223102950urn:oid:2.16.724.1.3.1.1.2.1.9Dkx2R3nMv8kWo7iSAh+/1SQ70hfseOEaQbpJnURk+pg=https://sede.administracion.gob.es/politica_de_firma_anexo_1.pdf - \ No newline at end of file diff --git a/test/schema/ejemploRegistro-sin_firmar.xml b/test/schema/ejemploRegistro-sin_firmar.xml deleted file mode 100644 index 12c7b86..0000000 --- a/test/schema/ejemploRegistro-sin_firmar.xml +++ /dev/null @@ -1,77 +0,0 @@ - - 1.0 - - 89890001k - 12345678-G33 - 01-01-2024 - - certificado uno telematicas - N - N - R3 - I - - - 89890002e - AGRUPRECT_MULT_00 - 02-04-2023 - - - 01-01-2024 - DescripcPRIEMAR - - - - certificado dos telematicas - 89890002e - - - - - 01 - S1 - 4 - 10 - 0.4 - - - 01 - S1 - 21 - 100 - 21 - - - 05 - S1 - 10 - 100 - 10 - - - 41.4 - 241.4 - - - B80260144 - 44 - 15-03-2024 - huella - - - - CERTIFICADO DOS TELEMATICAS - - B80260144 - NombreSistemaInformatico - 77 - 1.0.03 - 383 - S - N - N - - 2024-02-07T12:30:00+02:00 - 01 - 5C56AC56F13DB2A25E0031BD2B7ED5C9AF4AF1EF5EBBA700350DE3EEF12C2D35 - diff --git a/test/schema/example-alta.xml b/test/schema/example-alta.xml new file mode 100644 index 0000000..3fefd0c --- /dev/null +++ b/test/schema/example-alta.xml @@ -0,0 +1,77 @@ + + + + + + + XXXXX + AAAA + + + + + 1.0 + + AAAA + 12345 + 13-09-2024 + + XXXXX + F1 + Descripc + + + YYYY + BBBB + + + + + 01 + S1 + 4 + 10 + 0.4 + + + 01 + S1 + 21 + 100 + 21 + + + 21.4 + 131.4 + + + AAAA + 44 + 13-09-2024 + HuellaRegistroAnterior + + + + SSSS + NNNN + NombreSistemaInformatico + 77 + 1.0.03 + 383 + N + S + S + + 2024-09-13T19:20:30+01:00 + 01 + Huella + + + + + \ No newline at end of file diff --git a/test/schema/example-anulacion.xml b/test/schema/example-anulacion.xml new file mode 100644 index 0000000..d050980 --- /dev/null +++ b/test/schema/example-anulacion.xml @@ -0,0 +1,50 @@ + + + + + + + XXXXX + AAAA + + + + + 1.0 + + AAAA + 12345 + 13-09-2024 + + + + AAAA + 44 + 13-09-2024 + HuellaRegistroAnterior + + + + SSSS + NNNN + NombreSistemaInformatico + 77 + 1.0.03 + 383 + N + S + S + + 2024-09-13T19:20:30+01:00 + 01 + Huella + + + + + \ No newline at end of file diff --git a/test/schema/example-subsanacion.xml b/test/schema/example-subsanacion.xml new file mode 100644 index 0000000..2ef1de9 --- /dev/null +++ b/test/schema/example-subsanacion.xml @@ -0,0 +1,78 @@ + + + + + + + XXXXX + AAAA + + + + + 1.0 + + AAAA + 12345 + 13-09-2024 + + XXXXX + S + F1 + Descripc + + + YYYY + BBBB + + + + + 01 + S1 + 4 + 10 + 0.4 + + + 01 + S1 + 21 + 100 + 21 + + + 21.4 + 131.4 + + + AAAA + 44 + 13-09-2024 + HuellaRegistroAnterior + + + + SSSS + NNNN + NombreSistemaInformatico + 77 + 1.0.03 + 383 + N + S + S + + 2024-09-13T19:20:30+01:00 + 01 + Huella + + + + + \ No newline at end of file diff --git a/verifactu.go b/verifactu.go index a10c779..9560607 100644 --- a/verifactu.go +++ b/verifactu.go @@ -76,10 +76,16 @@ func New(software *doc.Software, opts ...Option) (*Client, error) { opt(c) } - var err error - c.gw, err = gateways.New(c.env, c.cert) - if err != nil { - return nil, err + if c.cert == nil { + return c, nil + } + + if c.gw == nil { + var err error + c.gw, err = gateways.New(c.env, c.cert) + if err != nil { + return nil, err + } } return c, nil @@ -128,11 +134,6 @@ func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { return nil } -// Cancel will send the cancel document in the VeriFactu gateway. -// func (c *Client) Cancel(ctx context.Context, d *doc.AnulaTicketBAI) error { -// return c.gw.Cancel(ctx, d) -// } - // CurrentTime returns the current time to use when generating // the VeriFactu document. func (c *Client) CurrentTime() time.Time { From 35045d93b760ce32a5d3ecbd678f70b45c2774ee Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 21 Nov 2024 17:31:16 +0000 Subject: [PATCH 21/56] Added Tests and Updated Fingerprint --- .github/workflows/release.yaml | 17 +-- cmd/gobl.verifactu/convert.go | 2 +- doc/breakdown_test.go | 20 +--- doc/cancel_test.go | 71 +++++++++++++ doc/doc.go | 6 +- doc/doc_test.go | 30 ++++++ doc/document.go | 4 - doc/fingerprint.go | 4 +- doc/fingerprint_test.go | 172 +++++++++++++++++++++++++++++++ doc/invoice.go | 19 ++-- doc/qr_code.go | 1 - go.mod | 12 +-- go.sum | 24 ++--- test/data/cred-note-base.json | 166 +++++++++++++++++++++++++++++ test/data/inv-base.json | 4 +- test/data/inv-zero-tax.json | 112 ++++++++++++++++++++ test/data/out/cred-note-base.xml | 43 ++++++++ test/data/out/inv-base.xml | 58 +++++++++++ test/data/out/inv-eqv-sur.xml | 60 +++++++++++ test/data/out/invoice-es-es.xml | 74 ------------- 20 files changed, 751 insertions(+), 148 deletions(-) create mode 100644 doc/cancel_test.go create mode 100644 doc/doc_test.go create mode 100644 doc/fingerprint_test.go create mode 100644 test/data/cred-note-base.json create mode 100644 test/data/inv-zero-tax.json create mode 100755 test/data/out/cred-note-base.xml create mode 100755 test/data/out/inv-base.xml create mode 100755 test/data/out/inv-eqv-sur.xml delete mode 100755 test/data/out/invoice-es-es.xml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3ab95d0..290dd30 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,7 +1,3 @@ -# -# Automatically tag a merge with master and release it -# - name: Release on: @@ -17,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: "0" # make sure we get all commits! @@ -37,18 +33,15 @@ jobs: WITH_V: true - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: - go-version: 1.21 - + go-version-file: "go.mod" + - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v6 with: - # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution - # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go index c1f1ef8..e45b03a 100644 --- a/cmd/gobl.verifactu/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -62,7 +62,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("converting to verifactu xml: %w", err) } - data, err := doc.BytesIndent() + data, err := doc.Envelop() if err != nil { return fmt.Errorf("generating verifactu xml: %w", err) } diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index 7c09484..9a46027 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -18,9 +18,7 @@ import ( func TestBreakdownConversion(t *testing.T) { t.Run("basic-invoice", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - err := inv.Calculate() - require.NoError(t, err) - + _ = inv.Calculate() doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) @@ -50,12 +48,9 @@ func TestBreakdownConversion(t *testing.T) { }, }, } - err := inv.Calculate() - require.NoError(t, err) - + _ = inv.Calculate() doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) assert.Equal(t, "E1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].OperacionExenta) @@ -96,10 +91,8 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) @@ -131,10 +124,8 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 0.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) @@ -159,7 +150,6 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - _, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.Error(t, err) }) @@ -184,10 +174,8 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, 5.20, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRecargoEquivalencia) @@ -217,10 +205,8 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 10.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "03", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) @@ -249,10 +235,8 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) require.NoError(t, err) - assert.Equal(t, 1000.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) diff --git a/doc/cancel_test.go b/doc/cancel_test.go new file mode 100644 index 0000000..613cb44 --- /dev/null +++ b/doc/cancel_test.go @@ -0,0 +1,71 @@ +package doc + +import ( + "testing" + "time" + + "github.com/invopop/gobl.verifactu/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRegistroAnulacion(t *testing.T) { + + t.Run("basic", func(t *testing.T) { + inv := test.LoadInvoice("cred-note-base.json") + + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + reg, err := NewRegistroAnulacion(inv, time.Now(), IssuerRoleSupplier, nil) + require.NoError(t, err) + + assert.Equal(t, "E", reg.GeneradoPor) + assert.NotNil(t, reg.Generador) + assert.Equal(t, "Provide One S.L.", reg.Generador.NombreRazon) + assert.Equal(t, "B98602642", reg.Generador.NIF) + + data, err := doc.BytesIndent() + require.NoError(t, err) + assert.NotEmpty(t, data) + }) + + t.Run("customer issuer", func(t *testing.T) { + inv := test.LoadInvoice("cred-note-base.json") + + doc, err := NewDocument(inv, time.Now(), IssuerRoleCustomer, nil) + require.NoError(t, err) + + reg, err := NewRegistroAnulacion(inv, time.Now(), IssuerRoleCustomer, nil) + require.NoError(t, err) + + assert.Equal(t, "D", reg.GeneradoPor) + assert.NotNil(t, reg.Generador) + assert.Equal(t, "Sample Customer", reg.Generador.NombreRazon) + assert.Equal(t, "54387763P", reg.Generador.NIF) + + data, err := doc.BytesIndent() + require.NoError(t, err) + assert.NotEmpty(t, data) + }) + + t.Run("third party issuer", func(t *testing.T) { + inv := test.LoadInvoice("cred-note-base.json") + + doc, err := NewDocument(inv, time.Now(), IssuerRoleThirdParty, nil) + require.NoError(t, err) + + reg, err := NewRegistroAnulacion(inv, time.Now(), IssuerRoleThirdParty, nil) + require.NoError(t, err) + + assert.Equal(t, "T", reg.GeneradoPor) + assert.NotNil(t, reg.Generador) + assert.Equal(t, "Provide One S.L.", reg.Generador.NombreRazon) + assert.Equal(t, "B98602642", reg.Generador.NIF) + + data, err := doc.BytesIndent() + require.NoError(t, err) + assert.NotEmpty(t, data) + }) + +} diff --git a/doc/doc.go b/doc/doc.go index f5de033..28067fb 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -128,15 +128,15 @@ func (d *VeriFactu) BytesIndent() ([]byte, error) { return toBytesIndent(d) } -// Envelope wraps the VeriFactu document in a SOAP envelope and includes the expected namespaces -func (v *VeriFactu) Envelop() ([]byte, error) { +// Envelop wraps the VeriFactu document in a SOAP envelope and includes the expected namespaces +func (d *VeriFactu) Envelop() ([]byte, error) { // Create and set the envelope with namespaces env := Envelope{ XMLNs: EnvNamespace, SUM: SUM, SUM1: SUM1, } - env.Body.VeriFactu = v + env.Body.VeriFactu = d // Marshal the SOAP envelope into an XML byte slice var result bytes.Buffer diff --git a/doc/doc_test.go b/doc/doc_test.go new file mode 100644 index 0000000..42a11e5 --- /dev/null +++ b/doc/doc_test.go @@ -0,0 +1,30 @@ +package doc + +import ( + "testing" + "time" + + "github.com/invopop/gobl.verifactu/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvoiceConversion(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2022-02-01T04:00:00Z") + require.NoError(t, err) + role := IssuerRoleSupplier + sw := &Software{} + + t.Run("should contain basic document info", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + doc, err := NewDocument(inv, ts, role, sw) + + require.NoError(t, err) + assert.Equal(t, "Invopop S.L.", doc.Cabecera.Obligado.NombreRazon) + assert.Equal(t, "B85905495", doc.Cabecera.Obligado.NIF) + assert.Equal(t, "1.0", doc.RegistroFactura.RegistroAlta.IDVersion) + assert.Equal(t, "B85905495", doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) + assert.Equal(t, "SAMPLE-003", doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) + assert.Equal(t, "13-11-2024", doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) + }) +} diff --git a/doc/document.go b/doc/document.go index 6aaae19..5892a0e 100644 --- a/doc/document.go +++ b/doc/document.go @@ -9,10 +9,6 @@ const ( EnvNamespace = "http://schemas.xmlsoap.org/soap/envelope/" ) -// xmlns:sf="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd" -// xmlns:sfLR="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" -// xmlns:sfR="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaSuministro.xsd" -// // Envelope is the SOAP envelope wrapper type Envelope struct { XMLName xml.Name `xml:"soapenv:Envelope"` diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 1eff58b..a5e68d0 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -36,8 +36,8 @@ func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), FormatField("TipoFactura", inv.TipoFactura), - FormatField("CuotaTotal", fmt.Sprintf("%f", inv.CuotaTotal)), - FormatField("ImporteTotal", fmt.Sprintf("%f", inv.ImporteTotal)), + FormatField("CuotaTotal", fmt.Sprintf("%g", inv.CuotaTotal)), + FormatField("ImporteTotal", fmt.Sprintf("%g", inv.ImporteTotal)), FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go new file mode 100644 index 0000000..85e7f41 --- /dev/null +++ b/doc/fingerprint_test.go @@ -0,0 +1,172 @@ +package doc + +import ( + "testing" +) + +var s = "S" + +func TestFingerprintAlta(t *testing.T) { + t.Run("Alta", func(t *testing.T) { + tests := []struct { + name string + alta *RegistroAlta + expected string + }{ + { + name: "Basic 1", + alta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", + }, + TipoFactura: "F1", + CuotaTotal: 378.0, + ImporteTotal: 2178.0, + Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + FechaHoraHusoGenRegistro: "2024-11-20T19:00:55+01:00", + }, + expected: "9F848AF7AECAA4C841654B37FD7119F4530B19141A2C3FF9968B5A229DEE21C2", + }, + { + name: "Basic 2", + alta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-002", + FechaExpedicionFactura: "12-11-2024", + }, + TipoFactura: "R3", + CuotaTotal: 500.50, + ImporteTotal: 2502.55, + Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", + }, + expected: "14543C022CBD197F247F77A88F41E636A3B2569CE5787A8D6C8A781BF1B9D25E", + }, + { + name: "No Previous", + alta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "B08194359", + NumSerieFactura: "SAMPLE-003", + FechaExpedicionFactura: "12-11-2024", + }, + TipoFactura: "F1", + CuotaTotal: 500.0, + ImporteTotal: 2500.0, + Encadenamiento: &Encadenamiento{PrimerRegistro: &s}, + FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", + }, + expected: "95619096010E699BB4B88AD2B42DC30BBD809A4B1ED2AE2904DFF86D064FCF29", + }, + { + name: "No Taxes", + alta: &RegistroAlta{ + IDFactura: &IDFactura{ + IDEmisorFactura: "B85905495", + NumSerieFactura: "SAMPLE-003", + FechaExpedicionFactura: "15-11-2024", + }, + TipoFactura: "F1", + CuotaTotal: 0.0, + ImporteTotal: 1800.0, + Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C"}}, + FechaHoraHusoGenRegistro: "2024-11-21T17:59:41+01:00", + }, + expected: "9F44F498EA51C0C50FEB026CCE86BDCCF852C898EE33336EFFE1BD6F132B506E", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &VeriFactu{ + RegistroFactura: &RegistroFactura{ + RegistroAlta: tt.alta, + }, + } + + err := d.fingerprintAlta(tt.alta) + if err != nil { + t.Errorf("fingerprintAlta() error = %v", err) + return + } + + if got := tt.alta.Huella; got != tt.expected { + t.Errorf("fingerprint = %v, want %v", got, tt.expected) + } + }) + } + }) +} + +func TestFingerprintAnulacion(t *testing.T) { + t.Run("Anulacion", func(t *testing.T) { + tests := []struct { + name string + anulacion *RegistroAnulacion + expected string + }{ + { + name: "Basic 1", + anulacion: &RegistroAnulacion{ + IDFactura: &IDFactura{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", + }, + FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", + Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + }, + expected: "BAB9B4AE157321642F6AFD8030288B7E595129B29A00A69CEB308CEAA53BFBD7", + }, + { + name: "Basic 2", + anulacion: &RegistroAnulacion{ + IDFactura: &IDFactura{ + IDEmisorFactura: "B08194359", + NumSerieFactura: "SAMPLE-002", + FechaExpedicionFactura: "12-11-2024", + }, + FechaHoraHusoGenRegistro: "2024-11-21T12:00:55+01:00", + Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A"}}, + }, + expected: "548707E0984AA867CC173B24389E648DECDEE48A2674DA8CE8A3682EF8F119DD", + }, + { + name: "No Previous", + anulacion: &RegistroAnulacion{ + IDFactura: &IDFactura{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", + }, + FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", + Encadenamiento: &Encadenamiento{PrimerRegistro: &s}, + }, + expected: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &VeriFactu{ + RegistroFactura: &RegistroFactura{ + RegistroAnulacion: tt.anulacion, + }, + } + + err := d.fingerprintAnulacion(tt.anulacion) + if err != nil { + t.Errorf("fingerprintAnulacion() error = %v", err) + return + } + + if got := tt.anulacion.Huella; got != tt.expected { + t.Errorf("fingerprint = %v, want %v", got, tt.expected) + } + }) + } + }) +} diff --git a/doc/invoice.go b/doc/invoice.go index 9b1a058..2c87053 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -2,14 +2,16 @@ package doc import ( "fmt" + "slices" "time" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" - "github.com/invopop/gobl/tax" ) +var simplifiedTypes = []string{"F2", "R5"} + // NewRegistroAlta creates a new VeriFactu registration for an invoice. func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { description, err := newDescription(inv.Notes) @@ -60,18 +62,9 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) reg.Tercero = t } - // Check - if inv.Type == bill.InvoiceTypeCorrective { - reg.Subsanacion = "S" - } - - // Check - if inv.HasTags(tax.TagSimplified) { - if inv.Type == bill.InvoiceTypeStandard { - reg.FacturaSimplificadaArt7273 = "S" - } else { - reg.FacturaSinIdentifDestinatarioArt61d = "S" - } + // Flag for simplified invoices. + if slices.Contains(simplifiedTypes, reg.TipoFactura) { + reg.FacturaSinIdentifDestinatarioArt61d = "S" } // Flag for operations with totals over 100,000,000€. Added with optimism. diff --git a/doc/qr_code.go b/doc/qr_code.go index a12709e..0631651 100644 --- a/doc/qr_code.go +++ b/doc/qr_code.go @@ -3,7 +3,6 @@ package doc import ( "fmt" "net/url" - // "github.com/sigurn/crc8" ) const ( diff --git a/go.mod b/go.mod index 49d69dc..54c7724 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ toolchain go1.22.1 require ( github.com/go-resty/resty/v2 v2.15.3 - github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34 + github.com/invopop/gobl v0.205.2-0.20241119180855-1b04b703647d github.com/invopop/xmldsig v0.10.0 github.com/joho/godotenv v1.5.1 github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 @@ -18,8 +18,8 @@ require ( ) require ( - cloud.google.com/go v0.110.2 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect + cloud.google.com/go v0.116.0 // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beevik/etree v1.4.0 // indirect @@ -28,7 +28,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect - github.com/invopop/validation v0.7.0 // indirect + github.com/invopop/validation v0.8.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/magefile/mage v1.15.0 // indirect @@ -36,8 +36,8 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/net v0.28.0 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/net v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect ) diff --git a/go.sum b/go.sum index 51684fb..a169ece 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= -cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -24,12 +24,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34 h1:1iXp0n3c9tt2CWDuZsBE0cd0dMhgPvKRgIgpvjNYbbg= -github.com/invopop/gobl v0.204.2-0.20241112151514-033c0ce0cd34/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.205.2-0.20241119180855-1b04b703647d h1:4xME4bzxrR0YrmjvvHAfRplMV+duEIvXwqZ/tsGib+U= +github.com/invopop/gobl v0.205.2-0.20241119180855-1b04b703647d/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= -github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= -github.com/invopop/validation v0.7.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg= +github.com/invopop/validation v0.8.0 h1:e5hXHGnONHImgJdonIpNbctg1hlWy1ncaHoVIQ0JWuw= +github.com/invopop/validation v0.8.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg= github.com/invopop/xmldsig v0.10.0 h1:pPKX4dLWi4GEQQVs1dXbH3EW/Thm/5Bf9db1nYqfoUQ= github.com/invopop/xmldsig v0.10.0/go.mod h1:YBUFOoEo8mJ0B6ssQzReE2EICznYdxwyY6Y/LAn2/Z0= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= @@ -66,11 +66,11 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/test/data/cred-note-base.json b/test/data/cred-note-base.json new file mode 100644 index 0000000..79a8d61 --- /dev/null +++ b/test/data/cred-note-base.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "bd55d1603bc9a657f994684631ad5890c1adfe88c00dbb1f9edb40b7c6d215c5" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-verifactu-v1" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "credit-note", + "series": "FR", + "code": "012", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": { + "ext": { + "es-verifactu-doc-type": "R1" + } + }, + "preceding": [ + { + "type": "standard", + "issue_date": "2022-01-10", + "series": "SAMPLE", + "code": "085" + } + ], + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "San Frantzisko", + "locality": "Bilbo", + "region": "Bizkaia", + "code": "48003", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Customer", + "tax_id": { + "country": "ES", + "code": "54387763P" + }, + "addresses": [ + { + "num": "13", + "street": "Calle del Barro", + "locality": "Alcañiz", + "region": "Teruel", + "code": "44600", + "country": "ES" + } + ], + "emails": [ + { + "addr": "customer@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%", + "ext": { + "es-verifactu-tax-classification": "S1" + } + } + ], + "total": "1620.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "zero", + "percent": "0.0%", + "ext": { + "es-verifactu-tax-classification": "E1" + } + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + }, + { + "key": "zero", + "base": "10.00", + "percent": "0.0%", + "amount": "0.00" + } + ], + "amount": "340.20" + } + ], + "sum": "340.20" + }, + "tax": "340.20", + "total_with_tax": "1970.20", + "payable": "1970.20" + }, + "notes": [ + { + "key": "general", + "text": "Some random description" + } + ] + } +} \ No newline at end of file diff --git a/test/data/inv-base.json b/test/data/inv-base.json index 766b8af..80a9efd 100644 --- a/test/data/inv-base.json +++ b/test/data/inv-base.json @@ -16,8 +16,8 @@ "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", "type": "standard", "series": "SAMPLE", - "number": "002", - "issue_date": "2024-11-11", + "code": "003", + "issue_date": "2024-11-13", "currency": "EUR", "tax": { "ext": { diff --git a/test/data/inv-zero-tax.json b/test/data/inv-zero-tax.json new file mode 100644 index 0000000..16f329b --- /dev/null +++ b/test/data/inv-zero-tax.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-verifactu-v1" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "series": "SAMPLE", + "code": "003", + "issue_date": "2024-11-15", + "currency": "EUR", + "tax": { + "ext": { + "es-verifactu-doc-type": "F1" + } + }, + "supplier": { + "name": "Invopop S.L.", + "tax_id": { + "country": "ES", + "code": "B85905495" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "B63272603" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "rate": "zero", + "ext": { + "es-verifactu-tax-classification": "E1" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "zero", + "base": "1800.00", + "percent": "0.0%", + "amount": "0.00", + "ext": { + "es-verifactu-tax-classification": "E1" + } + } + ], + "amount": "0.00" + } + ], + "sum": "0.00" + }, + "tax": "0.00", + "total_with_tax": "1800.00", + "payable": "1800.00" + }, + "notes": [ + { + "key": "general", + "text": "This is a sample invoice" + } + ] + } +} \ No newline at end of file diff --git a/test/data/out/cred-note-base.xml b/test/data/out/cred-note-base.xml new file mode 100755 index 0000000..6227e8d --- /dev/null +++ b/test/data/out/cred-note-base.xml @@ -0,0 +1,43 @@ + + + + + + Provide One S.L. + B98602642 + + + + + 1.0 + + B98602642 + FR-012 + 01-02-2022 + + + E + + Provide One S.L. + B98602642 + + + Invopop S.L. + B85905495 + gobl.verifactu + A1 + 1.0 + 00001 + S + S + S + + 2024-11-21T18:26:22+01:00 + 01 + + + + + + + diff --git a/test/data/out/inv-base.xml b/test/data/out/inv-base.xml new file mode 100755 index 0000000..1881ea3 --- /dev/null +++ b/test/data/out/inv-base.xml @@ -0,0 +1,58 @@ + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-003 + 13-11-2024 + + Invopop S.L. + F1 + + This is a sample invoice + + + Sample Consumer + B63272603 + + + + + 01 + 01 + S1 + 21 + 1800 + 378 + + + 378 + 2178 + + Invopop S.L. + B85905495 + gobl.verifactu + A1 + 1.0 + 00001 + S + S + S + + 2024-11-21T18:27:13+01:00 + 01 + + + + + + diff --git a/test/data/out/inv-eqv-sur.xml b/test/data/out/inv-eqv-sur.xml new file mode 100755 index 0000000..2c6d493 --- /dev/null +++ b/test/data/out/inv-eqv-sur.xml @@ -0,0 +1,60 @@ + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-432 + 11-11-2024 + + Invopop S.L. + F1 + + This is a sample invoice + + + Sample Consumer + B63272603 + + + + + 01 + 01 + S1 + 21 + 1800 + 378 + 5.2 + 93.6 + + + 378 + 2178 + + Invopop S.L. + B85905495 + gobl.verifactu + A1 + 1.0 + 00001 + S + S + S + + 2024-11-21T18:26:59+01:00 + 01 + + + + + + diff --git a/test/data/out/invoice-es-es.xml b/test/data/out/invoice-es-es.xml deleted file mode 100755 index 3fdbc1d..0000000 --- a/test/data/out/invoice-es-es.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - Provide One S.L. - B98602642 - - - - - 1.0 - - B98602642 - SAMPLE-001 - 01-02-2022 - - Provide One S.L. - F1 - - - - This is a sample invoice - - - - 54387763P - Sample Consumer - - - - - - - - - - - VAT - - 1 - 21.0% - 1620.00 - 340.20 - - - VAT - - 1 - 0.0% - 10.00 - 0.00 - - - 340.20 - 1970.20 - - xxxxxxxx - 0123456789 - - - 1 - - - - 1.0 - - 2024-11-05T18:13:09+01:00 - - - - - From 57f9991b537c0ecc0e17491faaab989ca90d80a2 Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 21 Nov 2024 19:16:59 +0000 Subject: [PATCH 22/56] Added Invoice Tests --- .goreleaser.yml | 37 ++++++ README.md | 150 ++++++++++++++--------- doc/fingerprint.go | 13 +- doc/invoice.go | 10 +- doc/invoice_test.go | 59 +++++++++ go.mod | 2 +- internal/gateways/gateways.go | 2 +- mage.go | 103 ++++++++++++++++ test/schema/errores.properties | 210 --------------------------------- verifactu.go | 4 +- 10 files changed, 310 insertions(+), 280 deletions(-) create mode 100644 .goreleaser.yml create mode 100644 doc/invoice_test.go create mode 100644 mage.go delete mode 100644 test/schema/errores.properties diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..ded1729 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,37 @@ +before: + hooks: + - go mod download +builds: + - id: gobl.verifactu + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + main: ./cmd/gobl.verifactu + binary: gobl.verifactu + +archives: + - id: gobl.verifactu + builds: + - gobl.verifactu + format: tar.gz + name_template: "gobl.verifactu_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + wrap_in_directory: true + +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ .Tag }}" +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" +release: + github: + owner: invopop + name: gobl.verifactu + prerelease: auto diff --git a/README.md b/README.md index 4d2e137..b2e4ad2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ import ( "github.com/invopop/gobl" verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/xmldsig" ) func main() { @@ -44,60 +46,84 @@ func main() { // Prepare software configuration: soft := &verifactu.Software{ - License: "XYZ", // provided by tax agency - NIF: "B123456789", // Software company's tax code - Name: "Invopop", // Name of application - Version: "v0.1.0", // Software version + NombreRazon: "Company LTD", // Company Name + NIF: "B123456789", // Software company's tax code + NombreSistemaInformatico: "Software Name", // Name of application + IdSistemaInformatico: "A1", // Software ID + Version: "1.0", // Software version + NumeroInstalacion: "00001", // Software installation number } + // Load the certificate + cert, err := xmldsig.LoadCertificate(c.cert, c.password) + if err != nil { + return err + } + + // Create the client with the software and certificate + opts := []verifactu.Option{ + verifactu.WithCertificate(cert), + verifactu.WithSupplierIssuer(), // The issuer can be either the supplier, the + verifactu.InTesting(), + } - // Instantiate the TicketBAI client with sofrward config - // and specific zone. - c, err := verifactu.New(soft, - verifactu.WithSupplierIssuer(), // The issuer is the invoice's supplier - verifactu.InTesting(), // Use the tax agency testing environment - ) + + tc, err := verifactu.New(c.software(), opts...) if err != nil { - panic(err) + return err } - // Create a new Veri*Factu document: - doc, err := c.Convert(env) + td, err := tc.Convert(env) if err != nil { - panic(err) + return err + } + + c.previous = `{ + "emisor": "B85905495", + "serie": "SAMPLE-001", + "fecha": "11-11-2024", + "huella": "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C" + }` + + prev := new(doc.ChainData) + if err := json.Unmarshal([]byte(c.previous), prev); err != nil { + return err } // Create the document fingerprint - // Assume here that we don't have a previous chain data object. - if err = c.Fingerprint(doc, nil); err != nil { - panic(err) + err = tc.Fingerprint(td, prev) + if err != nil { + return err } - // Sign the document: - if err := c.AddQR(doc, env); err != nil { - panic(err) + if err := tc.AddQR(td, env); err != nil { + return err } - // Create the XML output - bytes, err := doc.BytesIndent() + out, err := c.openOutput(cmd, args) if err != nil { - panic(err) + return err } + defer out.Close() // nolint:errcheck - // Do something with the output, you probably want to store - // it somewhere. - fmt.Println("Document created:\n", string(bytes)) + convOut, err := td.BytesIndent() + if err != nil { + return fmt.Errorf("generating verifactu xml: %w", err) + } - // Grab and persist the Chain Data somewhere so you can use this - // for the next call to the Fingerprint method. - cd := doc.ChainData() - // Send to Veri*Factu, if rejected, you'll want to fix any - // issues and send in a new XML document. The original - // version should not be modified. - if err := c.Post(ctx, doc); err != nil { - panic(err) + err = tc.Post(cmd.Context(), td) + if err != nil { + return err + } + + data, err := json.Marshal(td.ChainData()) + if err != nil { + return err } + fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) + + return nil } ``` @@ -114,11 +140,15 @@ We recommend using a `.env` file to prepare configuration settings, although all ``` SOFTWARE_COMPANY_NIF=B85905495 -SOFTWARE_COMPANY_NAME="Invopop S.L." -SOFTWARE_NAME="Invopop" -SOFTWARE_ID_SISTEMA_INFORMATICO="IP" -SOFTWARE_NUMERO_INSTALACION="12345678" -SOFTWARE_VERSION="1.0" +SOFTWARE_COMPANY_NAME=Invopop S.L. +SOFTWARE_NAME=gobl.verifactu +SOFTWARE_VERSION=1.0 +SOFTWARE_ID_SISTEMA_INFORMATICO=A1 +SOFTWARE_NUMERO_INSTALACION=00001 + +CERTIFICATE_PATH=./xxxxxxxxx.p12 +CERTIFICATE_PASSWORD=xxxxxxxx + ``` To convert a document to XML, run: @@ -145,35 +175,41 @@ gobl.verifactu send ./test/data/sample-invoice.json In order to provide the supplier specific data required by Veri*Factu, invoices need to include a bit of extra data. We've managed to simplify these into specific cases. - +All generate XML documents will be validated against the Veri*Factu XSD documents. diff --git a/doc/fingerprint.go b/doc/fingerprint.go index a5e68d0..df448a3 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -68,7 +68,18 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { // GenerateHash generates the SHA-256 hash for the invoice data. func (d *VeriFactu) GenerateHash(prev *ChainData) error { if prev == nil { - return fmt.Errorf("previous document is required") + if d.RegistroFactura.RegistroAlta != nil { + s := "S" + d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + PrimerRegistro: &s, + } + } else if d.RegistroFactura.RegistroAnulacion != nil { + s := "S" + d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ + PrimerRegistro: &s, + } + } + return nil } // Concatenate f according to Verifactu specifications if d.RegistroFactura.RegistroAlta != nil { diff --git a/doc/invoice.go b/doc/invoice.go index 2c87053..7e19524 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -2,7 +2,6 @@ package doc import ( "fmt" - "slices" "time" "github.com/invopop/gobl/bill" @@ -10,8 +9,6 @@ import ( "github.com/invopop/gobl/num" ) -var simplifiedTypes = []string{"F2", "R5"} - // NewRegistroAlta creates a new VeriFactu registration for an invoice. func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { description, err := newDescription(inv.Notes) @@ -51,6 +48,8 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) IDDestinatario: d, } reg.Destinatarios = []*Destinatario{ds} + } else { + reg.FacturaSinIdentifDestinatarioArt61d = "S" } if r == IssuerRoleThirdParty { @@ -62,11 +61,6 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) reg.Tercero = t } - // Flag for simplified invoices. - if slices.Contains(simplifiedTypes, reg.TipoFactura) { - reg.FacturaSinIdentifDestinatarioArt61d = "S" - } - // Flag for operations with totals over 100,000,000€. Added with optimism. if inv.Totals.TotalWithTax.Compare(num.MakeAmount(100000000, 0)) == 1 { reg.Macrodato = "S" diff --git a/doc/invoice_test.go b/doc/invoice_test.go new file mode 100644 index 0000000..19184d9 --- /dev/null +++ b/doc/invoice_test.go @@ -0,0 +1,59 @@ +package doc + +import ( + "testing" + "time" + + "github.com/invopop/gobl.verifactu/test" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRegistroAlta(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2022-02-01T04:00:00Z") + require.NoError(t, err) + role := IssuerRoleSupplier + sw := &Software{} + + t.Run("should contain basic document info", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + doc, err := NewDocument(inv, ts, role, sw) + require.NoError(t, err) + + reg := doc.RegistroFactura.RegistroAlta + assert.Equal(t, "1.0", reg.IDVersion) + assert.Equal(t, "B85905495", reg.IDFactura.IDEmisorFactura) + assert.Equal(t, "SAMPLE-003", reg.IDFactura.NumSerieFactura) + assert.Equal(t, "13-11-2024", reg.IDFactura.FechaExpedicionFactura) + assert.Equal(t, "Invopop S.L.", reg.NombreRazonEmisor) + assert.Equal(t, "F1", reg.TipoFactura) + assert.Equal(t, "This is a sample invoice", reg.DescripcionOperacion) + assert.Equal(t, float64(378), reg.CuotaTotal) + assert.Equal(t, float64(2178), reg.ImporteTotal) + + require.Len(t, reg.Destinatarios, 1) + dest := reg.Destinatarios[0].IDDestinatario + assert.Equal(t, "Sample Consumer", dest.NombreRazon) + assert.Equal(t, "B63272603", dest.NIF) + + require.Len(t, reg.Desglose.DetalleDesglose, 1) + desg := reg.Desglose.DetalleDesglose[0] + assert.Equal(t, "01", desg.Impuesto) + assert.Equal(t, "01", desg.ClaveRegimen) + assert.Equal(t, "S1", desg.CalificacionOperacion) + assert.Equal(t, float64(21), desg.TipoImpositivo) + assert.Equal(t, float64(1800), desg.BaseImponibleOImporteNoSujeto) + assert.Equal(t, float64(378), desg.CuotaRepercutida) + }) + t.Run("should handle simplified invoices", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.SetTags(tax.TagSimplified) + inv.Customer = nil + + doc, err := NewDocument(inv, ts, role, sw) + require.NoError(t, err) + + assert.Equal(t, "S", doc.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) + }) +} diff --git a/go.mod b/go.mod index 54c7724..3f0d443 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/invopop/gobl v0.205.2-0.20241119180855-1b04b703647d github.com/invopop/xmldsig v0.10.0 github.com/joho/godotenv v1.5.1 + github.com/magefile/mage v1.15.0 github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 @@ -31,7 +32,6 @@ require ( github.com/invopop/validation v0.8.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect - github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index db1c652..ad2bc92 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -19,7 +19,7 @@ type Environment string // Environment to use for connections const ( EnvironmentProduction Environment = "production" - EnvironmentTesting Environment = "testing" + EnvironmentSandbox Environment = "sandbox" // Production environment not published yet ProductionBaseURL = "xxxxxxxx" diff --git a/mage.go b/mage.go new file mode 100644 index 0000000..cc2a39f --- /dev/null +++ b/mage.go @@ -0,0 +1,103 @@ +//go:build mage +// +build mage + +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/magefile/mage/sh" + "github.com/magefile/mage/target" +) + +const ( + name = "gobl.verifactu" + mainBranch = "main" +) + +// Build the binary +func Build() error { + changed, err := target.Dir("./"+name, ".") + if os.IsNotExist(err) || (err == nil && changed) { + return build("build") + } + return nil +} + +// Install the binary into your go bin path +func Install() error { + return build("install") +} + +func build(action string) error { + args := []string{action} + flags, err := buildFlags() + if err != nil { + return err + } + args = append(args, flags...) + args = append(args, "./cmd/"+name) + return sh.RunV("go", args...) +} + +func buildFlags() ([]string, error) { + ldflags := []string{ + fmt.Sprintf("-X 'main.date=%s'", date()), + } + if v, err := version(); err != nil { + return nil, err + } else if v != "" { + ldflags = append(ldflags, fmt.Sprintf("-X 'main.version=%s'", v)) + } + + out := []string{} + if len(ldflags) > 0 { + out = append(out, "-ldflags="+strings.Join(ldflags, " ")) + } + return out, nil +} + +func version() (string, error) { + vt, err := versionTag() + if err != nil { + return "", err + } + v := []string{vt} + if b, err := branch(); err != nil { + return "", err + } else if b != mainBranch { + v = append(v, b) + } + if uncommittedChanges() { + v = append(v, "uncommitted") + } + return strings.Join(v, "-"), nil +} + +func versionTag() (string, error) { + return trimOutput("git", "describe", "--tags") // no "--exact-match" +} + +func branch() (string, error) { + return trimOutput("git", "rev-parse", "--abbrev-ref", "HEAD") +} + +func uncommittedChanges() bool { + err := sh.Run("git", "diff-index", "--quiet", "HEAD", "--") + return err != nil +} + +func date() string { + return time.Now().UTC().Format(time.RFC3339) +} + +func trimOutput(cmd string, args ...string) (string, error) { + txt, err := sh.Output(cmd, args...) + if err != nil { + return "", err + } + return strings.TrimSpace(txt), nil +} diff --git a/test/schema/errores.properties b/test/schema/errores.properties deleted file mode 100644 index 05abc54..0000000 --- a/test/schema/errores.properties +++ /dev/null @@ -1,210 +0,0 @@ -********* Listado de cdigos de error que provocan el rechazo del envo completo ********* -4102 = El XML no cumple el esquema. Falta informar campo obligatorio. -4103 = Se ha producido un error inesperado al parsear el XML. -4104 = Error en la cabecera: el valor del campo NIF del bloque ObligadoEmision no est identificado. -4105 = Error en la cabecera: el valor del campo NIF del bloque Representante no est identificado. -4106 = El formato de fecha es incorrecto. -4107 = El NIF no est identificado en el censo de la AEAT. -4108 = Error tcnico al obtener el certificado. -4109 = El formato del NIF es incorrecto. -4110 = Error tcnico al comprobar los apoderamientos. -4111 = Error tcnico al crear el trmite. -4112 = El titular del certificado debe ser Obligado Emisin, Colaborador Social, Apoderado o Sucesor. -4113 = El XML no cumple con el esquema: se ha superado el lmite permitido de registros para el bloque. -4114 = El XML no cumple con el esquema: se ha superado el lmite mximo permitido de facturas a registrar. -4115 = El valor del campo NIF del bloque ObligadoEmision es incorrecto. -4116 = Error en la cabecera: el campo NIF del bloque ObligadoEmision tiene un formato incorrecto. -4117 = Error en la cabecera: el campo NIF del bloque Representante tiene un formato incorrecto. -4118 = Error tcnico: la direccin no se corresponde con el fichero de entrada. -4119 = Error al informar caracteres cuya codificacin no es UTF-8. -4120 = Error en la cabecera: el valor del campo FechaFinVeriFactu es incorrecto, debe ser 31-12-20XX, donde XX corresponde con el ao actual o el anterior. -4121 = Error en la cabecera: el valor del campo Incidencia es incorrecto. -4122 = Error en la cabecera: el valor del campo RefRequerimiento es incorrecto. -4123 = Error en la cabecera: el valor del campo NIF del bloque Representante no est identificado en el censo de la AEAT. -4124 = Error en la cabecera: el valor del campo Nombre del bloque Representante no est identificado en el censo de la AEAT. -4125 = Error en la cabecera: el campo RefRequerimiento es obligatorio. -4126 = Error en la cabecera: el campo RefRequerimiento solo debe informarse en sistemas No VERIFACTU. -4127 = Error en la cabecera: la remisin voluntaria solo debe informarse en sistemas VERIFACTU. -4128 = Error tcnico en la recuperacin del valor del Gestor de Tablas. -4129 = Error en la cabecera: el campo FinRequerimiento es obligatorio. -4130 = Error en la cabecera: el campo FinRequerimiento solo debe informarse en sistemas No VERIFACTU. -4131 = Error en la cabecera: el valor del campo FinRequerimiento es incorrecto. -4132 = El titular del certificado debe ser el destinatario que realiza la consulta, un Apoderado o Sucesor -3500 = Error tcnico de base de datos: error en la integridad de la informacin. -3501 = Error tcnico de base de datos. -3502 = La factura consultada para el suministro de pagos/cobros/inmuebles no existe. -3503 = La factura especificada no pertenece al titular registrado en el sistema. - -********* Listado de cdigos de error que provocan el rechazo de la factura (o de la peticin completa si el error se produce en la cabecera) ********* -1100 = Valor o tipo incorrecto del campo. -1101 = El valor del campo CodigoPais es incorrecto. -1102 = El valor del campo IDType es incorrecto. -1103 = El valor del campo ID es incorrecto. -1104 = El valor del campo NumSerieFactura es incorrecto. -1105 = El valor del campo FechaExpedicionFactura es incorrecto. -1106 = El valor del campo TipoFactura no est incluido en la lista de valores permitidos. -1107 = El valor del campo TipoRectificativa es incorrecto. -1108 = El NIF del IDEmisorFactura debe ser el mismo que el NIF del ObligadoEmision. -1109 = El NIF no est identificado en el censo de la AEAT. -1110 = El NIF no est identificado en el censo de la AEAT. -1111 = El campo CodigoPais es obligatorio cuando IDType es distinto de 02. -1112 = El campo FechaExpedicionFactura es superior a la fecha actual. -1114 = Si la factura es de tipo rectificativa, el campo TipoRectificativa debe tener valor. -1115 = Si la factura no es de tipo rectificativa, el campo TipoRectificativa no debe tener valor. -1116 = Debe informarse el campo FacturasSustituidas slo si la factura es de tipo F3. -1117 = Si la factura no es de tipo rectificativa, el bloque FacturasRectificadas no podr venir informado. -1118 = Si la factura es de tipo rectificativa por sustitucin el bloque ImporteRectificacion es obligatorio. -1119 = Si la factura no es de tipo rectificativa por sustitucin el bloque ImporteRectificacion no debe tener valor. -1120 = Valor de campo IDEmisorFactura del bloque IDFactura con tipo incorrecto. -1121 = El campo ID no est identificado en el censo de la AEAT. -1122 = El campo CodigoPais indicado no coincide con los dos primeros dgitos del identificador. -1123 = El formato del NIF es incorrecto. -1124 = El valor del campo TipoImpositivo no est incluido en la lista de valores permitidos. -1125 = El valor del campo FechaOperacion tiene una fecha superior a la permitida. -1126 = El valor del CodigoPais solo puede ser ES cuando el IDType sea 07 o 03. Si IDType es 07 el CodigoPais debe ser ES. -1127 = El valor del campo TipoRecargoEquivalencia no est incluido en la lista de valores permitidos. -1128 = No existe acuerdo de facturacin. -1129 = Error tcnico al obtener el acuerdo de facturacin. -1130 = El campo NumSerieFactura contiene caracteres no permitidos. -1131 = El valor del campo ID ha de ser el NIF de una persona fsica cuando el campo IDType tiene valor 07. -1132 = El valor del campo TipoImpositivo es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura inferior o igual al año 2012. -1133 = El valor del campo FechaExpedicionFactura no debe ser inferior a la fecha actual menos veinte años. -1134 = El valor del campo FechaOperacion no debe ser inferior a la fecha actual menos veinte años. -1135 = El valor del campo TipoRecargoEquivalencia es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura inferior o igual al año 2012. -1136 = El campo FacturaSimplificadaArticulos7273 solo acepta valores N o S. -1137 = El campo Macrodato solo acepta valores N o S. -1138 = El campo Macrodato solo debe ser informado con valor S si el valor de ImporteTotal es igual o superior a +-100.000.000 -1139 = Si el campo ImporteTotal est informado y es igual o superior a +-100.000.000 el campo Macrodato debe estar informado con valor S. -1140 = Los campos CuotaRepercutida y BaseImponibleACoste deben tener el mismo signo. -1142 = El campo CuotaRepercutida tiene un valor incorrecto para el valor de los campos BaseImponibleOimporteNoSujeto y TipoImpositivo suministrados. -1143 = Los campos CuotaRepercutida y BaseImponibleOimporteNoSujeto deben tener el mismo signo. -1144 = El campo CuotaRepercutida tiene un valor incorrecto para el valor de los campos BaseImponibleACoste y TipoImpositivo suministrados. -1145 = Formato de fecha incorrecto. -1146 = Slo se permite que la fecha de expedicion de la factura sea anterior a la fecha operacin si los detalles del desglose son ClaveRegimen 14 o 15 e Impuesto 01, 03 o vaco. -1147 = Si ClaveRegimen es 14, FechaOperacion es obligatoria y debe ser posterior a la FechaExpedicionFactura. -1148 = Si la ClaveRegimen es 14, el campo TipoFactura debe ser F1, R1, R2, R3 o R4. -1149 = Si ClaveRegimen es 14, el NIF de Destinatarios debe estar identificado en el censo de la AEAT y comenzar por P, Q, S o V. -1150 = Cuando TipoFactura sea F2 y no este informado NumRegistroAcuerdoFacturacion o FacturaSinIdentifDestinatarioArt61d no sea S el sumatorio de BaseImponibleOimporteNoSujeto y CuotaRepercutida de todas las lneas de detalle no podr ser superior a 3.000. -1151 = El campo EmitidaPorTerceroODestinatario solo acepta valores T o D. -1152 = La fecha de Expedicion de la factura no puede ser inferior al indicado en la Orden Ministerial. -1153 = Valor del campo RechazoPrevio no vlido, solo podr incluirse el campo RechazoPrevio con valor X si se ha informado el campo Subsanacion y tiene el valor S. -1154 = El NIF del emisor de la factura rectificada/sustitutiva no se ha podido identificar en el censo de la AEAT. -1155 = Se est informando el bloque Tercero sin estar informado el campo EmitidaPorTerceroODestinatario. -1156 = Para el bloque IDOtro y IDType 02, el valor de TipoFactura es incorrecto. -1157 = El valor de cupn solo puede ser S o N si est informado. El valor de cupn slo puede ser S si el tipo de factura es R1 o R5. -1158 = Se est informando EmitidaPorTerceroODestinatario, pero no se informa el bloque correspondiente. -1159 = Se est informando del bloque Tercero cuando se indica que se va a informar de Destinatario. -1160 = Si el TipoImpositivo es 5%, slo se admite TipoRecargoEquivalencia 0,5 o 0,62. -1161 = El valor del campo RechazoPrevio no es vlido, no podr incluirse el campo RechazoPrevio con valor S si no se ha informado del campo Subsanacion o tiene el valor N. -1162 = Si el TipoImpositivo es 21%, slo se admite TipoRecargoEquivalencia 5,2 1,75. -1163 = Si el TipoImpositivo es 10%, slo se admite TipoRecargoEquivalencia 1,4. -1164 = Si el TipoImpositivo es 4%, slo se admite TipoRecargoEquivalencia 0,5. -1165 = Si el TipoImpositivo es 0% entre el 1 de enero de 2023 y el 30 de septiembre de 2024, slo se admite TipoRecargoEquivalencia 0. -1166 = Si el TipoImpositivo es 2% entre el 1 de octubre de 2024 y el 31 de diciembre de 2024, slo se admite TipoRecargoEquivalencia 0,26. -1167 = Si el TipoImpositivo es 5% slo se admite TipoRecargoEquivalencia 0,5 si Fecha Operacion (Fecha Expedicion Factura si no se informa FechaOperacion) es mayor o igual que el 1 de julio de 2022 y el 31 de diciembre de 2022. -1168 = Si el TipoImpositivo es 5% slo se admite TipoRecargoEquivalencia 0,62 si Fecha Operacion (Fecha Expedicion Factura si no se informa FechaOperacion) es mayor o igual que el 1 de enero de 2023 y el 30 de septiembre de 2024. -1169 = Si el TipoImpositivo es 7,5% entre el 1 de octubre de 2024 y el 31 de diciembre de 2024, slo se admite TipoRecargoEquivalencia 1. -1170 = Si el TipoImpositivo es 0%, desde el 1 de octubre del 2024, slo se admite TipoRecargoEquivalencia 0,26. -1171 = El valor del campo Subsanacion o RechazoPrevio no se encuentra en los valores permitidos. -1172 = El valor del campo NIF u ObligadoEmision son nulos. -1173 = Slo se permite que la fecha de operacin sea superior a la fecha actual si los detalles del desglose son ClaveRegimen 14 o 15 e Impuesto 01, 03 o vaco. -1174 = El valor del campo FechaExpedicionFactura del bloque RegistroAnteriores incorrecto. -1175 = El valor del campo NumSerieFactura del bloque RegistroAnterior es incorrecto. -1176 = El valor de campo NIF del bloque SistemaInformatico es incorrecto. -1177 = El valor de campo IdSistemaInformatico del bloque SistemaInformatico es incorrecto. -1178 = Error en el bloque de Tercero. -1179 = Error en el bloque de SistemaInformatico. -1180 = Error en el bloque de Encadenamiento. -1181 = El valor del campo CalificacionOperacion es incorrecto. -1182 = El valor del campo OperacionExenta es incorrecto. -1183 = El campo FacturaSimplificadaArticulos7273 solo se podr rellenar con S si TipoFactura es de tipo F1 o F3 o R1 o R2 o R3 o R4. -1184 = El campo FacturaSinIdentifDestinatarioArt61d solo acepta valores S o N. -1185 = El campo FacturaSinIdentifDestinatarioArt61d solo se podr rellenar con S si TipoFactura es de tipo F2 o R5. -1186 = Si EmitidaPorTercerosODestinatario es igual a T el bloque Tercero ser de cumplimentacin obligatoria. -1187 = Slo se podr cumplimentarse el bloque Tercero si el valor de EmitidaPorTercerosODestinatario es T. -1188 = El NIF del bloque Tercero debe ser diferente al NIF del ObligadoEmision. -1189 = Si TipoFactura es F1 o F3 o R1 o R2 o R3 o R4 el bloque Destinatarios tiene que estar cumplimentado. -1190 = Si TipoFactura es F2 o R5 el bloque Destinatarios no puede estar cumplimentado. -1191 = Si TipoFactura es R3 slo se admitir NIF o IDType = 07. -1192 = Si TipoFactura es R2 slo se admitir NIF o IDType = 07 o 02. -1193 = En el bloque Destinatarios si se identifica mediante NIF, el NIF debe estar identificado y ser distinto del NIF ObligadoEmision. -1194 = El valor del campo TipoImpositivo es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura posterior o igual a 1 de julio de 2022 e inferior o igual a 30 de septiembre de 2024. -1195 = Al menos uno de los dos campos OperacionExenta o CalificacionOperacion deben estar informados. -1196 = OperacionExenta o CalificacionOperacion no pueden ser ambos informados ya que son excluyentes entre s. -1197 = Si CalificacionOperacion es S2 TipoFactura solo puede ser F1, F3, R1, R2, R3 y R4. -1198 = Si CalificacionOperacion es S2 TipoImpositivo y CuotaRepercutida deberan tener valor 0. -1199 = Si Impuesto es '01' (IVA), '03' (IGIC) o no se cumplimenta y ClaveRegimen es 01 no pueden marcarse las OperacionExenta E2, E3. -1200 = Si ClaveRegimen es 03 CalificacionOperacion slo puede ser S1. -1201 = Si ClaveRegimen es 04 CalificacionOperacion slo puede ser S2 o bien OperacionExenta. -1202 = Si ClaveRegimen es 06 TipoFactura no puede ser F2, F3, R5 y BaseImponibleACoste debe estar cumplimentado. -1203 = Si ClaveRegimen es 07 OperacionExenta no puede ser E2, E3, E4 y E5 o CalificacionOperacion no puede ser S2, N1, N2. -1205 = Si ClaveRegimen es 10 CalificacionOperacion tiene que ser N1, TipoFactura F1 y Destinatarios estar identificada mediante NIF. -1206 = Si ClaveRegimen es 11 TipoImpositivo ha de ser 21%. -1207 = La CuotaRepercutida solo podr ser distinta de 0 si CalificacionOperacion es S1. -1208 = Si CalificacionOperacion es S1 y BaseImponibleACoste no est cumplimentada, TipoImpositivo y CuotaRepercutida son obligatorios. -1209 = Si CalificacionOperacion es S1 y ClaveRegimen es 06, TipoImpositivo y CuotaRepercutida son obligatorios. -1210 = El campo ImporteTotal tiene un valor incorrecto para el valor de los campos BaseImponibleOimporteNoSujeto, CuotaRepercutida y CuotaRecargoEquivalencia suministrados. -1211 = El bloque Tercero no puede estar identificado con IDType=07 (no censado). -1212 = El campo TipoUsoPosibleSoloVerifactu solo acepta valores N o S. -1213 = El campo TipoUsoPosibleMultiOT solo acepta valores N o S. -1214 = El campo NumeroOTAlta debe ser númerico positivo de 4 posiciones. -1215 = Error en el bloque de ObligadoEmision. -1216 = El campo CuotaTotal tiene un valor incorrecto para el valor de los campos CuotaRepercutida y CuotaRecargoEquivalencia suministrados. -1217 = Error identificando el IDEmisorFactura. -1218 = El valor del campo Impuesto es incorrecto. -1219 = El valor del campo IDEmisorFactura es incorrecto. -1220 = El valor del campo NombreSistemaInformatico es incorrecto. -1221 = El valor del campo IDType del sistema informtico es incorrecto. -1222 = El valor del campo ID del bloque IDOtro es incorrecto. -1223 = En el bloque SistemaInformatico si se cumplimenta NIF, no deber existir la agrupacin IDOtro y viceversa, pero es obligatorio que se cumplimente uno de los dos. -1224 = Si se informa el campo GeneradoPor deber existir la agrupacin Generador y viceversa. -1225 = El valor del campo GeneradoPor es incorrecto. -1226 = El campo IndicadorMultiplesOT solo acepta valores N o S. -1227 = Si el campo GeneradoPor es igual a E debe estar relleno el campo NIF del bloque Generador. -1228 = En el bloque Generador si se cumplimenta NIF, no deber existir la agrupacin IDOtro y viceversa, pero es obligatorio que se cumplimente uno de los dos. -1229 = Si el valor de GeneradoPor es igual a T el valor del campo IDType del bloque Generador no debe ser 07 (No censado). -1230 = Si el valor de GeneradoPor es igual a D y el CodigoPais tiene valor ES, el valor del campo IDType del bloque Generador debe ser 03 o 07. -1231 = El valor del campo IDType del bloque Generador es incorrecto. -1232 = Si se identifica a travs de la agrupacin IDOtro y CodigoPais tiene valor ES, el campo IDType debe valer 03. -1233 = Si se identifica a travs de la agrupacin IDOtro y CodigoPais tiene valor ES, el campo IDType debe valer 07. -1234 = Si se identifica a travs de la agrupacin IDOtro y CodigoPais tiene valor ES, el campo IDType debe valer 03 o 07. -1235 = El valor del campo TipoImpositivo es incorrecto, el valor informado slo es permitido para FechaOperacion o FechaExpedicionFactura posterior o igual a 1 de octubre de 2024 e inferior o igual a 31 de diciembre de 2024. -1236 = El valor del campo TipoImpositivo es incorrecto, el valor informado solo es permitido para FechaOperacion o FechaExpedicionFactura posterior o igual a 1 de octubre de 2024 e inferior o igual a 31 de diciembre de 2024. -1237 = El valor del campo CalificacionOperacion est informado como N1 o N2 y el impuesto es IVA. No se puede informar de los campos TipoImpositivo (excepto con ClaveRegimen 17), CuotaRepercutida (excepto con ClaveRegimen 17), TipoRecargoEquivalencia y CuotaRecargoEquivalencia. -1238 = Si la operacion es exenta no se puede informar ninguno de los campos TipoImpositivo, CuotaRepercutida, TipoRecargoEquivalencia y CuotaRecargoEquivalencia. -1239 = Error en el bloque Destinatario. -1240 = Error en el bloque de IdEmisorFactura. -1241 = Error tcnico al obtener el SistemaInformatico. -1242 = No existe el sistema informtico. -1243 = Error tcnico al obtener el clculo de la fecha del huso horario. -1244 = El campo FechaHoraHusoGenRegistro tiene un formato incorrecto. -1245 = Si el campo Impuesto est vaco o tiene valor 01 o 03 el campo ClaveRegimen debe de estar cumplimentado. -1246 = El valor del campo ClaveRegimen es incorrecto. -1247 = El valor del campo TipoHuella es incorrecto. -1248 = El valor del campo Periodo es incorrecto. -1249 = El valor del campo IndicadorRepresentante tiene un valor incorrecto. -1250 = El valor de fecha desde debe ser menor que el valor de fecha hasta en RangoFechaExpedicion. -1251 = El valor del campo IdVersion tiene un valor incorrecto -1252 = Si ClaveRegimen es 08 el campo CalificacionOperacion tiene que ser N2 e ir siempre informado. -1253 = El valor del campo RefExterna tiene un valor incorrecto. -1254 = Si FechaOperacion (FechaExpedicionFactura si no se informa FechaOperacion) es anterior a 01/01/2021 no se permite el valor 'XI' para Identificaciones NIF-IVA -1255 = Si FechaOperacion (FechaExpedicionFactura si no se informa FechaOperacion) es mayor o igual que 01/02/2021 no se permite el valor 'GB' para Identificaciones NIF-IVA -1256 = Error tcnico al obtener el lmite de la fecha de expedicin. -1257 = El campo BaseImponibleACoste solo puede estar cumplimentado si la ClaveRegimen es = '06' o Impuesto = '02' (IPSI) o Impuesto = '05' (Otros). -1258 = El valor de campo NIF del bloque Generador es incorrecto. -1259 = En el bloque Generador si se identifica mediante NIF, el NIF debe estar identificado y ser distinto del NIF ObligadoEmision. -1260 = el campo ClaveRegimen solo debe de estar cumplimentado si el campo Impuesto est vaco o tiene valor 01 o 03 - -3000 = Registro de facturacin duplicado. -3001 = El registro de facturacin ya ha sido dado de baja. -3002 = No existe el registro de facturacin. -3003 = El presentador no tiene los permisos necesarios para actualizar este registro de facturacin. - -********* Listado de cdigos de error que producen la aceptacin del registro de facturacin en el sistema (posteriormente deben ser subsanados) ********* -2000 = El clculo de la huella suministrada es incorrecta. -2001 = El NIF del bloque Destinatarios no est identificado en el censo de la AEAT. -2002 = La longitud de huella del registro anterior no cumple con las especificaciones. -2003 = El contenido de la huella del registro anterior no cumple con las especificaciones. -2004 = El valor del campo FechaHoraHusoGenRegistro debe ser la fecha actual del sistema de la AEAT, admitindose un margen de error de: -2005 = El campo ImporteTotal tiene un valor incorrecto para el valor de los campos BaseImponibleOimporteNoSujeto, CuotaRepercutida y CuotaRecargoEquivalencia suministrados. -2006 = El campo CuotaTotal tiene un valor incorrecto para el valor de los campos CuotaRepercutida y CuotaRecargoEquivalencia suministrados. \ No newline at end of file diff --git a/verifactu.go b/verifactu.go index 9560607..1d3ca57 100644 --- a/verifactu.go +++ b/verifactu.go @@ -69,7 +69,7 @@ func New(software *doc.Software, opts ...Option) (*Client, error) { c.software = software // Set default values that can be overwritten by the options - c.env = gateways.EnvironmentTesting + c.env = gateways.EnvironmentSandbox c.issuerRole = doc.IssuerRoleSupplier for _, opt := range opts { @@ -122,7 +122,7 @@ func InProduction() Option { // InTesting defines the connection to use the testing environment. func InTesting() Option { return func(c *Client) { - c.env = gateways.EnvironmentTesting + c.env = gateways.EnvironmentSandbox } } From b1da494da05734d7f03773e83466148c0f92170a Mon Sep 17 00:00:00 2001 From: apardods Date: Fri, 22 Nov 2024 16:41:47 +0000 Subject: [PATCH 23/56] Refactor Cancel --- README.md | 6 +- cancel.go | 62 +++++++++++++++++ cmd/gobl.verifactu/cancel.go | 121 +++++++++++++++++++++++++++++++++ cmd/gobl.verifactu/root.go | 1 + doc/breakdown_test.go | 16 ++--- doc/cancel.go | 2 +- doc/cancel_test.go | 15 ++-- doc/doc.go | 15 ++-- doc/doc_test.go | 2 +- doc/document.go | 37 ++++++---- doc/fingerprint.go | 68 +++++++++--------- doc/fingerprint_test.go | 16 ++--- doc/invoice_test.go | 4 +- document.go | 2 +- test/data/inv-eqv-sur-b2c.json | 109 +++++++++++++++++++++++++++++ test/data/inv-rev-charge.json | 113 ++++++++++++++++++++++++++++++ 16 files changed, 503 insertions(+), 86 deletions(-) create mode 100644 cancel.go create mode 100644 cmd/gobl.verifactu/cancel.go create mode 100644 test/data/inv-eqv-sur-b2c.json create mode 100644 test/data/inv-rev-charge.json diff --git a/README.md b/README.md index b2e4ad2..2c3ad51 100644 --- a/README.md +++ b/README.md @@ -73,29 +73,31 @@ func main() { return err } + // Convert the GOBL envelope to a Veri*Factu document td, err := tc.Convert(env) if err != nil { return err } + // Prepare the previous document chain data c.previous = `{ "emisor": "B85905495", "serie": "SAMPLE-001", "fecha": "11-11-2024", "huella": "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C" }` - prev := new(doc.ChainData) if err := json.Unmarshal([]byte(c.previous), prev); err != nil { return err } - // Create the document fingerprint + // Create the document fingerprint based on the previous document chain err = tc.Fingerprint(td, prev) if err != nil { return err } + // Add the QR code to the document if err := tc.AddQR(td, env); err != nil { return err } diff --git a/cancel.go b/cancel.go new file mode 100644 index 0000000..b19d732 --- /dev/null +++ b/cancel.go @@ -0,0 +1,62 @@ +package verifactu + +import ( + "errors" + "time" + + "github.com/invopop/gobl" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/l10n" +) + +// GenerateCancel creates a new AnulaTicketBAI document from the provided +// GOBL Envelope. +func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { + // Extract the Invoice + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + return nil, errors.New("only invoices are supported") + } + if inv.Supplier.TaxID.Country != l10n.ES.Tax() { + return nil, errors.New("only spanish invoices are supported") + } + // Extract the time when the invoice was posted to TicketBAI gateway + // ts, err := extractPostTime(env) + ts, err := time.Parse(time.DateOnly, inv.IssueDate.String()) // REVISAR + if err != nil { + return nil, err + } + + // Create the document + cd, err := doc.NewDocument(inv, ts, c.issuerRole, c.software, true) + if err != nil { + return nil, err + } + + return cd, nil +} + +// Fingerprint generates a fingerprint for the document using the +// data provided from the previous chain data. If there was no previous +// document in the chain, the parameter should be nil. The document is updated +// in place. +func (c *Client) FingerprintCancel(d *doc.VeriFactu, prev *doc.ChainData) error { + return d.FingerprintCancel(prev) +} + +// func extractPostTime(env *gobl.Envelope) (time.Time, error) { +// for _, stamp := range env.Head.Stamps { +// if stamp.Provider == verifactu.StampCode { +// parts := strings.Split(stamp.Value, "-") +// ts, err := time.Parse("020106", parts[2]) +// if err != nil { +// return time.Time{}, fmt.Errorf("parsing previous invoice date: %w", err) +// } + +// return ts, nil +// } +// } + +// return time.Time{}, fmt.Errorf("missing previous %s stamp in envelope", verifactu.StampCode) +// } diff --git a/cmd/gobl.verifactu/cancel.go b/cmd/gobl.verifactu/cancel.go new file mode 100644 index 0000000..bd260fc --- /dev/null +++ b/cmd/gobl.verifactu/cancel.go @@ -0,0 +1,121 @@ +// Package main provides the command line interface to the VeriFactu package. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/invopop/gobl" + verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/xmldsig" + "github.com/spf13/cobra" +) + +type cancelOpts struct { + *rootOpts + previous string +} + +func cancel(o *rootOpts) *cancelOpts { + return &cancelOpts{rootOpts: o} +} + +func (c *cancelOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cancel [infile]", + Short: "Cancels the GOBL invoice to the VeriFactu service", + RunE: c.runE, + } + + f := cmd.Flags() + c.prepareFlags(f) + + f.StringVar(&c.previous, "prev", "", "Previous document fingerprint to chain with") + + return cmd +} + +func (c *cancelOpts) runE(cmd *cobra.Command, args []string) error { + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer input.Close() // nolint:errcheck + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(input); err != nil { + return fmt.Errorf("reading input: %w", err) + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(buf.Bytes(), env); err != nil { + return fmt.Errorf("unmarshaling gobl envelope: %w", err) + } + + cert, err := xmldsig.LoadCertificate(c.cert, c.password) + if err != nil { + panic(err) + } + + opts := []verifactu.Option{ + verifactu.WithCertificate(cert), + verifactu.WithThirdPartyIssuer(), + } + + if c.production { + opts = append(opts, verifactu.InProduction()) + } else { + opts = append(opts, verifactu.InTesting()) + } + + tc, err := verifactu.New(c.software(), opts...) + if err != nil { + return err + } + + td, err := tc.GenerateCancel(env) + if err != nil { + return err + } + + var prev *doc.ChainData + if c.previous != "" { + prev = new(doc.ChainData) + if err := json.Unmarshal([]byte(c.previous), prev); err != nil { + return err + } + } + + err = tc.FingerprintCancel(td, prev) + if err != nil { + return err + } + + out, err := c.openOutput(cmd, args) + if err != nil { + return err + } + defer out.Close() // nolint:errcheck + convOut, err := td.BytesIndent() + if err != nil { + return fmt.Errorf("generating verifactu xml: %w", err) + } + if _, err = out.Write(append(convOut, '\n')); err != nil { + return fmt.Errorf("writing verifactu xml: %w", err) + } + + err = tc.Post(cmd.Context(), td) + if err != nil { + return err + } + + data, err := json.Marshal(td.ChainData()) + if err != nil { + return err + } + fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) + + return nil +} diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 4a35a71..3664089 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -37,6 +37,7 @@ func (o *rootOpts) cmd() *cobra.Command { cmd.AddCommand(send(o).cmd()) cmd.AddCommand(sendTest(o).cmd()) cmd.AddCommand(convert(o).cmd()) + cmd.AddCommand(cancel(o).cmd()) return cmd } diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index 9a46027..adf0ca0 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -19,7 +19,7 @@ func TestBreakdownConversion(t *testing.T) { t.Run("basic-invoice", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 1800.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) @@ -49,7 +49,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) @@ -91,7 +91,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -124,7 +124,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 0.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -150,7 +150,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - _, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + _, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.Error(t, err) }) @@ -174,7 +174,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -205,7 +205,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 10.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -235,7 +235,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 1000.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) diff --git a/doc/cancel.go b/doc/cancel.go index efb8b35..b814677 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -10,7 +10,7 @@ import ( func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ IDVersion: CurrentVersion, - IDFactura: &IDFactura{ + IDFactura: &IDFacturaAnulada{ IDEmisorFactura: inv.Supplier.TaxID.Code.String(), NumSerieFactura: invoiceNumber(inv.Series, inv.Code), FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), diff --git a/doc/cancel_test.go b/doc/cancel_test.go index 613cb44..4fa056d 100644 --- a/doc/cancel_test.go +++ b/doc/cancel_test.go @@ -14,11 +14,10 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("basic", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, true) require.NoError(t, err) - reg, err := NewRegistroAnulacion(inv, time.Now(), IssuerRoleSupplier, nil) - require.NoError(t, err) + reg := doc.RegistroFactura.RegistroAnulacion assert.Equal(t, "E", reg.GeneradoPor) assert.NotNil(t, reg.Generador) @@ -33,11 +32,10 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("customer issuer", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - doc, err := NewDocument(inv, time.Now(), IssuerRoleCustomer, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleCustomer, nil, true) require.NoError(t, err) - reg, err := NewRegistroAnulacion(inv, time.Now(), IssuerRoleCustomer, nil) - require.NoError(t, err) + reg := doc.RegistroFactura.RegistroAnulacion assert.Equal(t, "D", reg.GeneradoPor) assert.NotNil(t, reg.Generador) @@ -52,11 +50,10 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("third party issuer", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - doc, err := NewDocument(inv, time.Now(), IssuerRoleThirdParty, nil) + doc, err := NewDocument(inv, time.Now(), IssuerRoleThirdParty, nil, true) require.NoError(t, err) - reg, err := NewRegistroAnulacion(inv, time.Now(), IssuerRoleThirdParty, nil) - require.NoError(t, err) + reg := doc.RegistroFactura.RegistroAnulacion assert.Equal(t, "T", reg.GeneradoPor) assert.NotNil(t, reg.Generador) diff --git a/doc/doc.go b/doc/doc.go index 28067fb..0d1d756 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -53,7 +53,7 @@ func init() { } // NewDocument creates a new VeriFactu document -func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*VeriFactu, error) { +func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c bool) (*VeriFactu, error) { doc := &VeriFactu{ Cabecera: &Cabecera{ @@ -65,7 +65,7 @@ func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*V RegistroFactura: &RegistroFactura{}, } - if inv.Type == bill.InvoiceTypeCreditNote { + if c { reg, err := NewRegistroAnulacion(inv, ts, r, s) if err != nil { return nil, err @@ -95,7 +95,7 @@ func (d *VeriFactu) QRCodes() string { func (d *VeriFactu) ChainData() Encadenamiento { if d.RegistroFactura.RegistroAlta != nil { return Encadenamiento{ - RegistroAnterior: RegistroAnterior{ + RegistroAnterior: &RegistroAnterior{ IDEmisorFactura: d.Cabecera.Obligado.NIF, NumSerieFactura: d.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura, FechaExpedicionFactura: d.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura, @@ -104,7 +104,7 @@ func (d *VeriFactu) ChainData() Encadenamiento { } } return Encadenamiento{ - RegistroAnterior: RegistroAnterior{ + RegistroAnterior: &RegistroAnterior{ IDEmisorFactura: d.Cabecera.Obligado.NIF, NumSerieFactura: d.RegistroFactura.RegistroAnulacion.IDFactura.NumSerieFactura, FechaExpedicionFactura: d.RegistroFactura.RegistroAnulacion.IDFactura.FechaExpedicionFactura, @@ -115,7 +115,12 @@ func (d *VeriFactu) ChainData() Encadenamiento { // Fingerprint generates the SHA-256 fingerprint for the document func (d *VeriFactu) Fingerprint(prev *ChainData) error { - return d.GenerateHash(prev) + return d.GenerateHashAlta(prev) +} + +// Fingerprint generates the SHA-256 fingerprint for the document +func (d *VeriFactu) FingerprintCancel(prev *ChainData) error { + return d.GenerateHashAnulacion(prev) } // Bytes returns the XML document bytes diff --git a/doc/doc_test.go b/doc/doc_test.go index 42a11e5..3cc65a5 100644 --- a/doc/doc_test.go +++ b/doc/doc_test.go @@ -17,7 +17,7 @@ func TestInvoiceConversion(t *testing.T) { t.Run("should contain basic document info", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - doc, err := NewDocument(inv, ts, role, sw) + doc, err := NewDocument(inv, ts, role, sw, false) require.NoError(t, err) assert.Equal(t, "Invopop S.L.", doc.Cabecera.Obligado.NombreRazon) diff --git a/doc/document.go b/doc/document.go index 5892a0e..81373bf 100644 --- a/doc/document.go +++ b/doc/document.go @@ -97,19 +97,19 @@ type RegistroAlta struct { // RegistroAnulacion contains the details of an invoice cancellation type RegistroAnulacion struct { - IDVersion string `xml:"IDVersion"` - IDFactura *IDFactura `xml:"IDFactura"` - RefExterna string `xml:"RefExterna,omitempty"` - SinRegistroPrevio string `xml:"SinRegistroPrevio"` - RechazoPrevio string `xml:"RechazoPrevio,omitempty"` - GeneradoPor string `xml:"GeneradoPor"` - Generador *Party `xml:"Generador"` - Encadenamiento *Encadenamiento `xml:"Encadenamiento"` - SistemaInformatico *Software `xml:"SistemaInformatico"` - FechaHoraHusoGenRegistro string `xml:"FechaHoraHusoGenRegistro"` - TipoHuella string `xml:"TipoHuella"` - Huella string `xml:"Huella"` - Signature string `xml:"Signature"` + IDVersion string `xml:"sum1:IDVersion"` + IDFactura *IDFacturaAnulada `xml:"sum1:IDFactura"` + RefExterna string `xml:"sum1:RefExterna,omitempty"` + SinRegistroPrevio string `xml:"sum1:SinRegistroPrevio"` + RechazoPrevio string `xml:"sum1:RechazoPrevio,omitempty"` + GeneradoPor string `xml:"sum1:GeneradoPor"` + Generador *Party `xml:"sum1:Generador"` + Encadenamiento *Encadenamiento `xml:"sum1:Encadenamiento"` + SistemaInformatico *Software `xml:"sum1:SistemaInformatico"` + FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"` + TipoHuella string `xml:"sum1:TipoHuella"` + Huella string `xml:"sum1:Huella"` + Signature string `xml:"sum1:Signature"` } // IDFactura contains the identifying information for an invoice @@ -119,6 +119,13 @@ type IDFactura struct { FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"` } +// IDFactura contains the identifying information for an invoice +type IDFacturaAnulada struct { + IDEmisorFactura string `xml:"sum1:IDEmisorFacturaAnulada"` + NumSerieFactura string `xml:"sum1:NumSerieFacturaAnulada"` + FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFacturaAnulada"` +} + // FacturaRectificada represents a rectified invoice type FacturaRectificada struct { IDFactura IDFactura `xml:"sum1:IDFactura"` @@ -176,8 +183,8 @@ type DetalleDesglose struct { // Encadenamiento contains chaining information between documents type Encadenamiento struct { - PrimerRegistro *string `xml:"sum1:PrimerRegistro,omitempty"` - RegistroAnterior RegistroAnterior `xml:"sum1:RegistroAnterior,omitempty"` + PrimerRegistro *string `xml:"sum1:PrimerRegistro,omitempty"` + RegistroAnterior *RegistroAnterior `xml:"sum1:RegistroAnterior,omitempty"` } // RegistroAnterior contains information about the previous registration diff --git a/doc/fingerprint.go b/doc/fingerprint.go index df448a3..f8a2b66 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -66,47 +66,47 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { } // GenerateHash generates the SHA-256 hash for the invoice data. -func (d *VeriFactu) GenerateHash(prev *ChainData) error { +func (d *VeriFactu) GenerateHashAlta(prev *ChainData) error { if prev == nil { - if d.RegistroFactura.RegistroAlta != nil { - s := "S" - d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ - PrimerRegistro: &s, - } - } else if d.RegistroFactura.RegistroAnulacion != nil { - s := "S" - d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ - PrimerRegistro: &s, - } + s := "S" + d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + PrimerRegistro: &s, } return nil } // Concatenate f according to Verifactu specifications - if d.RegistroFactura.RegistroAlta != nil { - d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ - RegistroAnterior: RegistroAnterior{ - IDEmisorFactura: prev.IDEmisorFactura, - NumSerieFactura: prev.NumSerieFactura, - FechaExpedicionFactura: prev.FechaExpedicionFactura, - Huella: prev.Huella, - }, - } - if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { - return err - } - } else if d.RegistroFactura.RegistroAnulacion != nil { + d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + RegistroAnterior: &RegistroAnterior{ + IDEmisorFactura: prev.IDEmisorFactura, + NumSerieFactura: prev.NumSerieFactura, + FechaExpedicionFactura: prev.FechaExpedicionFactura, + Huella: prev.Huella, + }, + } + if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { + return err + } + return nil +} + +func (d *VeriFactu) GenerateHashAnulacion(prev *ChainData) error { + if prev == nil { + s := "S" d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ - RegistroAnterior: RegistroAnterior{ - IDEmisorFactura: prev.IDEmisorFactura, - NumSerieFactura: prev.NumSerieFactura, - FechaExpedicionFactura: prev.FechaExpedicionFactura, - Huella: prev.Huella, - }, - } - if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { - return err + PrimerRegistro: &s, } + return nil + } + d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ + RegistroAnterior: &RegistroAnterior{ + IDEmisorFactura: prev.IDEmisorFactura, + NumSerieFactura: prev.NumSerieFactura, + FechaExpedicionFactura: prev.FechaExpedicionFactura, + Huella: prev.Huella, + }, + } + if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { + return err } - return nil } diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go index 85e7f41..0a883b7 100644 --- a/doc/fingerprint_test.go +++ b/doc/fingerprint_test.go @@ -24,7 +24,7 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "F1", CuotaTotal: 378.0, ImporteTotal: 2178.0, - Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, FechaHoraHusoGenRegistro: "2024-11-20T19:00:55+01:00", }, expected: "9F848AF7AECAA4C841654B37FD7119F4530B19141A2C3FF9968B5A229DEE21C2", @@ -40,7 +40,7 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "R3", CuotaTotal: 500.50, ImporteTotal: 2502.55, - Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", }, expected: "14543C022CBD197F247F77A88F41E636A3B2569CE5787A8D6C8A781BF1B9D25E", @@ -72,7 +72,7 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "F1", CuotaTotal: 0.0, ImporteTotal: 1800.0, - Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C"}}, + Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C"}}, FechaHoraHusoGenRegistro: "2024-11-21T17:59:41+01:00", }, expected: "9F44F498EA51C0C50FEB026CCE86BDCCF852C898EE33336EFFE1BD6F132B506E", @@ -111,33 +111,33 @@ func TestFingerprintAnulacion(t *testing.T) { { name: "Basic 1", anulacion: &RegistroAnulacion{ - IDFactura: &IDFactura{ + IDFactura: &IDFacturaAnulada{ IDEmisorFactura: "A28083806", NumSerieFactura: "SAMPLE-001", FechaExpedicionFactura: "11-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", - Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, }, expected: "BAB9B4AE157321642F6AFD8030288B7E595129B29A00A69CEB308CEAA53BFBD7", }, { name: "Basic 2", anulacion: &RegistroAnulacion{ - IDFactura: &IDFactura{ + IDFactura: &IDFacturaAnulada{ IDEmisorFactura: "B08194359", NumSerieFactura: "SAMPLE-002", FechaExpedicionFactura: "12-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T12:00:55+01:00", - Encadenamiento: &Encadenamiento{RegistroAnterior: RegistroAnterior{Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A"}}, + Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A"}}, }, expected: "548707E0984AA867CC173B24389E648DECDEE48A2674DA8CE8A3682EF8F119DD", }, { name: "No Previous", anulacion: &RegistroAnulacion{ - IDFactura: &IDFactura{ + IDFactura: &IDFacturaAnulada{ IDEmisorFactura: "A28083806", NumSerieFactura: "SAMPLE-001", FechaExpedicionFactura: "11-11-2024", diff --git a/doc/invoice_test.go b/doc/invoice_test.go index 19184d9..1f06aa9 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -18,7 +18,7 @@ func TestNewRegistroAlta(t *testing.T) { t.Run("should contain basic document info", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - doc, err := NewDocument(inv, ts, role, sw) + doc, err := NewDocument(inv, ts, role, sw, false) require.NoError(t, err) reg := doc.RegistroFactura.RegistroAlta @@ -51,7 +51,7 @@ func TestNewRegistroAlta(t *testing.T) { inv.SetTags(tax.TagSimplified) inv.Customer = nil - doc, err := NewDocument(inv, ts, role, sw) + doc, err := NewDocument(inv, ts, role, sw, false) require.NoError(t, err) assert.Equal(t, "S", doc.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) diff --git a/document.go b/document.go index ef4488c..a2f62a0 100644 --- a/document.go +++ b/document.go @@ -27,7 +27,7 @@ func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { return nil, errors.New("only spanish invoices are supported") } - out, err := doc.NewDocument(inv, c.CurrentTime(), c.issuerRole, c.software) + out, err := doc.NewDocument(inv, c.CurrentTime(), c.issuerRole, c.software, false) if err != nil { return nil, err } diff --git a/test/data/inv-eqv-sur-b2c.json b/test/data/inv-eqv-sur-b2c.json new file mode 100644 index 0000000..aafa717 --- /dev/null +++ b/test/data/inv-eqv-sur-b2c.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", + "dig": { + "alg": "sha256", + "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "$addons": [ + "es-verifactu-v1" + ], + "series": "SAMPLE", + "code": "432", + "issue_date": "2024-11-11", + "currency": "EUR", + "tax": { + "ext": { + "es-verifactu-doc-type": "F2" + } + }, + "supplier": { + "name": "Invopop S.L.", + "tax_id": { + "country": "ES", + "code": "B85905495" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard+eqs", + "percent": "21.0%", + "surcharge": "5.2%" + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard+eqs", + "base": "1800.00", + "percent": "21.0%", + "surcharge": { + "percent": "5.2%", + "amount": "93.60" + }, + "amount": "378.00", + "ext": { + "es-verifactu-tax-classification": "S1" + } + } + ], + "amount": "378.00", + "surcharge": "93.60" + } + ], + "sum": "471.60" + }, + "tax": "471.60", + "total_with_tax": "2271.60", + "payable": "2271.60" + }, + "notes": [ + { + "key": "general", + "text": "This is a sample invoice" + } + ] + } +} \ No newline at end of file diff --git a/test/data/inv-rev-charge.json b/test/data/inv-rev-charge.json new file mode 100644 index 0000000..80a9efd --- /dev/null +++ b/test/data/inv-rev-charge.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-verifactu-v1" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "series": "SAMPLE", + "code": "003", + "issue_date": "2024-11-13", + "currency": "EUR", + "tax": { + "ext": { + "es-verifactu-doc-type": "F1" + } + }, + "supplier": { + "name": "Invopop S.L.", + "tax_id": { + "country": "ES", + "code": "B85905495" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "B63272603" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%", + "ext": { + "es-verifactu-tax-classification": "S1" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1800.00", + "percent": "21.0%", + "amount": "378.00", + "ext": { + "es-verifactu-tax-classification": "S1" + } + } + ], + "amount": "378.00" + } + ], + "sum": "378.00" + }, + "tax": "378.00", + "total_with_tax": "2178.00", + "payable": "2178.00" + }, + "notes": [ + { + "key": "general", + "text": "This is a sample invoice" + } + ] + } +} \ No newline at end of file From fb2a49863e0dbb69db3e6bc3dc3e8d24fa4f9121 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 26 Nov 2024 19:39:53 +0000 Subject: [PATCH 24/56] Test refactor --- README.md | 8 +- cancel.go | 18 +- cmd/gobl.verifactu/cancel.go | 2 +- doc/breakdown_test.go | 93 +++++----- doc/cancel_test.go | 21 +-- doc/doc.go | 27 ++- doc/doc_test.go | 9 +- doc/fingerprint.go | 4 +- doc/fingerprint_test.go | 81 +++++---- doc/invoice.go | 52 ++++-- doc/invoice_test.go | 58 ++++++- doc/party_test.go | 65 +++---- doc/qr_code_test.go | 47 +++--- examples_test.go | 141 ++++++++++++++++ go.mod | 4 +- go.sum | 10 +- test/data/invoice-es-es-freelance.json | 138 --------------- test/data/invoice-es-es-vateqs-provider.json | 169 ------------------- test/data/invoice-es-es-vateqs-retailer.json | 88 ---------- test/data/invoice-es-es.env.json | 91 ---------- test/data/invoice-es-es.json | 137 --------------- test/data/invoice-es-nl-b2b.json | 117 ------------- test/data/invoice-es-nl-digital-b2c.json | 96 ----------- test/data/invoice-es-pt-digital.json | 122 ------------- test/data/invoice-es-simplified.json | 112 ------------ test/data/invoice-es-usd.json | 166 ------------------ test/test.go | 16 ++ 27 files changed, 455 insertions(+), 1437 deletions(-) create mode 100644 examples_test.go delete mode 100644 test/data/invoice-es-es-freelance.json delete mode 100644 test/data/invoice-es-es-vateqs-provider.json delete mode 100644 test/data/invoice-es-es-vateqs-retailer.json delete mode 100644 test/data/invoice-es-es.env.json delete mode 100644 test/data/invoice-es-es.json delete mode 100644 test/data/invoice-es-nl-b2b.json delete mode 100644 test/data/invoice-es-nl-digital-b2c.json delete mode 100644 test/data/invoice-es-pt-digital.json delete mode 100644 test/data/invoice-es-simplified.json delete mode 100644 test/data/invoice-es-usd.json diff --git a/README.md b/README.md index 2c3ad51..d651c0f 100644 --- a/README.md +++ b/README.md @@ -215,14 +215,14 @@ The following extension can be applied to each line tax: ### Use-Cases -Under what situations should the TicketBAI system be expected to function: +Under what situations should the Veri*Factu system be expected to function: - B2B & B2C: regular national invoice with VAT. Operation with minimal data. - B2B Provider to Retailer: Include equalisation surcharge VAT rates - B2B Retailer: Same as regular invoice, except with invoice lines that include `ext[es-tbai-product] = resale` when the goods being provided are being sold without modification (recargo de equivalencia), very much related to the next point. - B2B Retailer Simplified: Include the simplified scheme key. (This implies that the `OperacionEnRecargoDeEquivalenciaORegimenSimplificado` tag will be set to `S`). - EU B2B: Reverse charge EU export, scheme: reverse-charge taxes calculated, but not applied to totals. By default all line items assumed to be services. Individual lines can use the `ext[es-tbai-product] = goods` value to identify when the line is a physical good. Operations like this are normally assigned the TipoNoExenta value of S2. If however the service or goods are exempt of tax, each line's tax `ext[exempt]` field can be used to identify a reason. -- EU B2C Digital Goods: use tax tag `customer-rates`, that applies VAT according to customer location. In TicketBAI, these cases are "not subject" to tax, and thus should have the cause RL (por reglas de localización). +- EU B2C Digital Goods: use tax tag `customer-rates`, that applies VAT according to customer location. In TicketBAI, these cases are "not subject" to tax, and thus should have the cause N2 (por reglas de localización). ## Test Data @@ -230,6 +230,4 @@ Some sample test data is available in the `./test` directory. To update the JSON ```bash go test ./examples_test.go --update -``` - -All generate XML documents will be validated against the Veri*Factu XSD documents. +``` \ No newline at end of file diff --git a/cancel.go b/cancel.go index b19d732..0b89574 100644 --- a/cancel.go +++ b/cancel.go @@ -23,7 +23,7 @@ func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { } // Extract the time when the invoice was posted to TicketBAI gateway // ts, err := extractPostTime(env) - ts, err := time.Parse(time.DateOnly, inv.IssueDate.String()) // REVISAR + ts, err := time.Parse("2006-01-02", inv.IssueDate.String()) // REVISAR if err != nil { return nil, err } @@ -44,19 +44,3 @@ func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { func (c *Client) FingerprintCancel(d *doc.VeriFactu, prev *doc.ChainData) error { return d.FingerprintCancel(prev) } - -// func extractPostTime(env *gobl.Envelope) (time.Time, error) { -// for _, stamp := range env.Head.Stamps { -// if stamp.Provider == verifactu.StampCode { -// parts := strings.Split(stamp.Value, "-") -// ts, err := time.Parse("020106", parts[2]) -// if err != nil { -// return time.Time{}, fmt.Errorf("parsing previous invoice date: %w", err) -// } - -// return ts, nil -// } -// } - -// return time.Time{}, fmt.Errorf("missing previous %s stamp in envelope", verifactu.StampCode) -// } diff --git a/cmd/gobl.verifactu/cancel.go b/cmd/gobl.verifactu/cancel.go index bd260fc..250ef49 100644 --- a/cmd/gobl.verifactu/cancel.go +++ b/cmd/gobl.verifactu/cancel.go @@ -111,7 +111,7 @@ func (c *cancelOpts) runE(cmd *cobra.Command, args []string) error { return err } - data, err := json.Marshal(td.ChainData()) + data, err := json.Marshal(td.ChainDataCancel()) if err != nil { return err } diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index adf0ca0..f69ab47 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -1,9 +1,10 @@ -package doc +package doc_test import ( "testing" "time" + "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" @@ -19,14 +20,14 @@ func TestBreakdownConversion(t *testing.T) { t.Run("basic-invoice", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 1800.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 378.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 1800.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 378.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("exempt-taxes", func(t *testing.T) { @@ -49,11 +50,11 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "E1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].OperacionExenta) + assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "E1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].OperacionExenta) }) t.Run("multiple-tax-rates", func(t *testing.T) { @@ -91,17 +92,17 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 21.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, 50.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 5.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].CuotaRepercutida) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].Impuesto) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].ClaveRegimen) + assert.Equal(t, 50.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 5.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].CuotaRepercutida) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].Impuesto) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].ClaveRegimen) }) t.Run("not-subject-taxes", func(t *testing.T) { @@ -124,13 +125,13 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 0.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "N1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 0.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "N1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("missing-tax-classification", func(t *testing.T) { @@ -150,7 +151,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - _, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + _, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.Error(t, err) }) @@ -174,14 +175,14 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 21.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, 5.20, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRecargoEquivalencia) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 21.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, 5.20, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRecargoEquivalencia) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("ipsi-tax", func(t *testing.T) { @@ -205,13 +206,13 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 10.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "03", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Empty(t, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 10.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "03", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Empty(t, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("antiques", func(t *testing.T) { @@ -235,12 +236,12 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, false) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 1000.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 100.00, doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "03", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", doc.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 1000.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "03", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) } diff --git a/doc/cancel_test.go b/doc/cancel_test.go index 4fa056d..0d0c091 100644 --- a/doc/cancel_test.go +++ b/doc/cancel_test.go @@ -1,9 +1,10 @@ -package doc +package doc_test import ( "testing" "time" + "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,17 +15,17 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("basic", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - doc, err := NewDocument(inv, time.Now(), IssuerRoleSupplier, nil, true) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, true) require.NoError(t, err) - reg := doc.RegistroFactura.RegistroAnulacion + reg := d.RegistroFactura.RegistroAnulacion assert.Equal(t, "E", reg.GeneradoPor) assert.NotNil(t, reg.Generador) assert.Equal(t, "Provide One S.L.", reg.Generador.NombreRazon) assert.Equal(t, "B98602642", reg.Generador.NIF) - data, err := doc.BytesIndent() + data, err := d.BytesIndent() require.NoError(t, err) assert.NotEmpty(t, data) }) @@ -32,17 +33,17 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("customer issuer", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - doc, err := NewDocument(inv, time.Now(), IssuerRoleCustomer, nil, true) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleCustomer, nil, true) require.NoError(t, err) - reg := doc.RegistroFactura.RegistroAnulacion + reg := d.RegistroFactura.RegistroAnulacion assert.Equal(t, "D", reg.GeneradoPor) assert.NotNil(t, reg.Generador) assert.Equal(t, "Sample Customer", reg.Generador.NombreRazon) assert.Equal(t, "54387763P", reg.Generador.NIF) - data, err := doc.BytesIndent() + data, err := d.BytesIndent() require.NoError(t, err) assert.NotEmpty(t, data) }) @@ -50,17 +51,17 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("third party issuer", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - doc, err := NewDocument(inv, time.Now(), IssuerRoleThirdParty, nil, true) + d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleThirdParty, nil, true) require.NoError(t, err) - reg := doc.RegistroFactura.RegistroAnulacion + reg := d.RegistroFactura.RegistroAnulacion assert.Equal(t, "T", reg.GeneradoPor) assert.NotNil(t, reg.Generador) assert.Equal(t, "Provide One S.L.", reg.Generador.NombreRazon) assert.Equal(t, "B98602642", reg.Generador.NIF) - data, err := doc.BytesIndent() + data, err := d.BytesIndent() require.NoError(t, err) assert.NotEmpty(t, data) }) diff --git a/doc/doc.go b/doc/doc.go index 0d1d756..eb8a9aa 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -54,7 +54,6 @@ func init() { // NewDocument creates a new VeriFactu document func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c bool) (*VeriFactu, error) { - doc := &VeriFactu{ Cabecera: &Cabecera{ Obligado: Obligado{ @@ -84,25 +83,23 @@ func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c b // QRCodes generates the QR code for the document func (d *VeriFactu) QRCodes() string { - if d.RegistroFactura.RegistroAlta.Encadenamiento == nil { - return "" - } return d.generateURL() } // ChainData generates the data to be used to link to this one // in the next entry. func (d *VeriFactu) ChainData() Encadenamiento { - if d.RegistroFactura.RegistroAlta != nil { - return Encadenamiento{ - RegistroAnterior: &RegistroAnterior{ - IDEmisorFactura: d.Cabecera.Obligado.NIF, - NumSerieFactura: d.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura, - FechaExpedicionFactura: d.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura, - Huella: d.RegistroFactura.RegistroAlta.Huella, - }, - } + return Encadenamiento{ + RegistroAnterior: &RegistroAnterior{ + IDEmisorFactura: d.Cabecera.Obligado.NIF, + NumSerieFactura: d.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura, + FechaExpedicionFactura: d.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura, + Huella: d.RegistroFactura.RegistroAlta.Huella, + }, } +} + +func (d *VeriFactu) ChainDataCancel() Encadenamiento { return Encadenamiento{ RegistroAnterior: &RegistroAnterior{ IDEmisorFactura: d.Cabecera.Obligado.NIF, @@ -115,12 +112,12 @@ func (d *VeriFactu) ChainData() Encadenamiento { // Fingerprint generates the SHA-256 fingerprint for the document func (d *VeriFactu) Fingerprint(prev *ChainData) error { - return d.GenerateHashAlta(prev) + return d.generateHashAlta(prev) } // Fingerprint generates the SHA-256 fingerprint for the document func (d *VeriFactu) FingerprintCancel(prev *ChainData) error { - return d.GenerateHashAnulacion(prev) + return d.generateHashAnulacion(prev) } // Bytes returns the XML document bytes diff --git a/doc/doc_test.go b/doc/doc_test.go index 3cc65a5..baa9e40 100644 --- a/doc/doc_test.go +++ b/doc/doc_test.go @@ -1,9 +1,10 @@ -package doc +package doc_test import ( "testing" "time" + "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -12,12 +13,12 @@ import ( func TestInvoiceConversion(t *testing.T) { ts, err := time.Parse(time.RFC3339, "2022-02-01T04:00:00Z") require.NoError(t, err) - role := IssuerRoleSupplier - sw := &Software{} + role := doc.IssuerRoleSupplier + sw := &doc.Software{} t.Run("should contain basic document info", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - doc, err := NewDocument(inv, ts, role, sw, false) + doc, err := doc.NewDocument(inv, ts, role, sw, false) require.NoError(t, err) assert.Equal(t, "Invopop S.L.", doc.Cabecera.Obligado.NombreRazon) diff --git a/doc/fingerprint.go b/doc/fingerprint.go index f8a2b66..7d7a935 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -66,7 +66,7 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { } // GenerateHash generates the SHA-256 hash for the invoice data. -func (d *VeriFactu) GenerateHashAlta(prev *ChainData) error { +func (d *VeriFactu) generateHashAlta(prev *ChainData) error { if prev == nil { s := "S" d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ @@ -89,7 +89,7 @@ func (d *VeriFactu) GenerateHashAlta(prev *ChainData) error { return nil } -func (d *VeriFactu) GenerateHashAnulacion(prev *ChainData) error { +func (d *VeriFactu) generateHashAnulacion(prev *ChainData) error { if prev == nil { s := "S" d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go index 0a883b7..a4ca946 100644 --- a/doc/fingerprint_test.go +++ b/doc/fingerprint_test.go @@ -1,7 +1,9 @@ -package doc +package doc_test import ( "testing" + + "github.com/invopop/gobl.verifactu/doc" ) var s = "S" @@ -10,13 +12,14 @@ func TestFingerprintAlta(t *testing.T) { t.Run("Alta", func(t *testing.T) { tests := []struct { name string - alta *RegistroAlta + alta *doc.RegistroAlta + prev *doc.ChainData expected string }{ { name: "Basic 1", - alta: &RegistroAlta{ - IDFactura: &IDFactura{ + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "A28083806", NumSerieFactura: "SAMPLE-001", FechaExpedicionFactura: "11-11-2024", @@ -24,15 +27,20 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "F1", CuotaTotal: 378.0, ImporteTotal: 2178.0, - Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, FechaHoraHusoGenRegistro: "2024-11-20T19:00:55+01:00", }, + prev: &doc.ChainData{ + IDEmisorFactura: "foo", + NumSerieFactura: "bar", + FechaExpedicionFactura: "baz", + Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + }, expected: "9F848AF7AECAA4C841654B37FD7119F4530B19141A2C3FF9968B5A229DEE21C2", }, { name: "Basic 2", - alta: &RegistroAlta{ - IDFactura: &IDFactura{ + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "A28083806", NumSerieFactura: "SAMPLE-002", FechaExpedicionFactura: "12-11-2024", @@ -40,15 +48,20 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "R3", CuotaTotal: 500.50, ImporteTotal: 2502.55, - Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", }, + prev: &doc.ChainData{ + IDEmisorFactura: "foo", + NumSerieFactura: "bar", + FechaExpedicionFactura: "baz", + Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + }, expected: "14543C022CBD197F247F77A88F41E636A3B2569CE5787A8D6C8A781BF1B9D25E", }, { name: "No Previous", - alta: &RegistroAlta{ - IDFactura: &IDFactura{ + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "B08194359", NumSerieFactura: "SAMPLE-003", FechaExpedicionFactura: "12-11-2024", @@ -56,15 +69,16 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "F1", CuotaTotal: 500.0, ImporteTotal: 2500.0, - Encadenamiento: &Encadenamiento{PrimerRegistro: &s}, + Encadenamiento: &doc.Encadenamiento{PrimerRegistro: &s}, FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", }, + prev: nil, expected: "95619096010E699BB4B88AD2B42DC30BBD809A4B1ED2AE2904DFF86D064FCF29", }, { name: "No Taxes", - alta: &RegistroAlta{ - IDFactura: &IDFactura{ + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "B85905495", NumSerieFactura: "SAMPLE-003", FechaExpedicionFactura: "15-11-2024", @@ -72,22 +86,28 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "F1", CuotaTotal: 0.0, ImporteTotal: 1800.0, - Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C"}}, + Encadenamiento: &doc.Encadenamiento{RegistroAnterior: &doc.RegistroAnterior{Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C"}}, FechaHoraHusoGenRegistro: "2024-11-21T17:59:41+01:00", }, + prev: &doc.ChainData{ + IDEmisorFactura: "foo", + NumSerieFactura: "bar", + FechaExpedicionFactura: "baz", + Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C", + }, expected: "9F44F498EA51C0C50FEB026CCE86BDCCF852C898EE33336EFFE1BD6F132B506E", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - d := &VeriFactu{ - RegistroFactura: &RegistroFactura{ + d := &doc.VeriFactu{ + RegistroFactura: &doc.RegistroFactura{ RegistroAlta: tt.alta, }, } - err := d.fingerprintAlta(tt.alta) + err := d.Fingerprint(tt.prev) if err != nil { t.Errorf("fingerprintAlta() error = %v", err) return @@ -105,45 +125,46 @@ func TestFingerprintAnulacion(t *testing.T) { t.Run("Anulacion", func(t *testing.T) { tests := []struct { name string - anulacion *RegistroAnulacion + anulacion *doc.RegistroAnulacion + prev *doc.ChainData expected string }{ { name: "Basic 1", - anulacion: &RegistroAnulacion{ - IDFactura: &IDFacturaAnulada{ + anulacion: &doc.RegistroAnulacion{ + IDFactura: &doc.IDFacturaAnulada{ IDEmisorFactura: "A28083806", NumSerieFactura: "SAMPLE-001", FechaExpedicionFactura: "11-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", - Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + Encadenamiento: &doc.Encadenamiento{RegistroAnterior: &doc.RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, }, expected: "BAB9B4AE157321642F6AFD8030288B7E595129B29A00A69CEB308CEAA53BFBD7", }, { name: "Basic 2", - anulacion: &RegistroAnulacion{ - IDFactura: &IDFacturaAnulada{ + anulacion: &doc.RegistroAnulacion{ + IDFactura: &doc.IDFacturaAnulada{ IDEmisorFactura: "B08194359", NumSerieFactura: "SAMPLE-002", FechaExpedicionFactura: "12-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T12:00:55+01:00", - Encadenamiento: &Encadenamiento{RegistroAnterior: &RegistroAnterior{Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A"}}, + Encadenamiento: &doc.Encadenamiento{RegistroAnterior: &doc.RegistroAnterior{Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A"}}, }, expected: "548707E0984AA867CC173B24389E648DECDEE48A2674DA8CE8A3682EF8F119DD", }, { name: "No Previous", - anulacion: &RegistroAnulacion{ - IDFactura: &IDFacturaAnulada{ + anulacion: &doc.RegistroAnulacion{ + IDFactura: &doc.IDFacturaAnulada{ IDEmisorFactura: "A28083806", NumSerieFactura: "SAMPLE-001", FechaExpedicionFactura: "11-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", - Encadenamiento: &Encadenamiento{PrimerRegistro: &s}, + Encadenamiento: &doc.Encadenamiento{PrimerRegistro: &s}, }, expected: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", }, @@ -151,13 +172,13 @@ func TestFingerprintAnulacion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - d := &VeriFactu{ - RegistroFactura: &RegistroFactura{ + d := &doc.VeriFactu{ + RegistroFactura: &doc.RegistroFactura{ RegistroAnulacion: tt.anulacion, }, } - err := d.fingerprintAnulacion(tt.anulacion) + err := d.FingerprintCancel(tt.prev) if err != nil { t.Errorf("fingerprintAnulacion() error = %v", err) return diff --git a/doc/invoice.go b/doc/invoice.go index 7e19524..02df080 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -2,13 +2,19 @@ package doc import ( "fmt" + "slices" "time" + "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" ) +var ( + rectificative = []string{"R1", "R2", "R3", "R4", "R5", "R6"} +) + // NewRegistroAlta creates a new VeriFactu registration for an invoice. func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { description, err := newDescription(inv.Notes) @@ -29,7 +35,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), }, NombreRazonEmisor: inv.Supplier.Name, - TipoFactura: mapInvoiceType(inv), + TipoFactura: inv.Tax.Ext[verifactu.ExtKeyDocType].String(), DescripcionOperacion: description, Desglose: desglose, CuotaTotal: newTotalTaxes(inv), @@ -52,6 +58,40 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) reg.FacturaSinIdentifDestinatarioArt61d = "S" } + if slices.Contains(rectificative, reg.TipoFactura) { + // GOBL does not currently have explicit support for Facturas Rectificativas por Sustitución + reg.TipoRectificativa = "I" + if inv.Preceding != nil { + rs := make([]*FacturaRectificada, 0, len(inv.Preceding)) + for _, ref := range inv.Preceding { + rs = append(rs, &FacturaRectificada{ + IDFactura: IDFactura{ + IDEmisorFactura: inv.Supplier.TaxID.Code.String(), + NumSerieFactura: invoiceNumber(ref.Series, ref.Code), + FechaExpedicionFactura: ref.IssueDate.Time().Format("02-01-2006"), + }, + }) + } + reg.FacturasRectificadas = rs + } + } + + if inv.HasTags(verifactu.TagSubstitution) { + if inv.Preceding != nil { + subs := make([]*FacturaSustituida, 0, len(inv.Preceding)) + for _, ref := range inv.Preceding { + subs = append(subs, &FacturaSustituida{ + IDFactura: IDFactura{ + IDEmisorFactura: inv.Supplier.TaxID.Code.String(), + NumSerieFactura: invoiceNumber(ref.Series, ref.Code), + FechaExpedicionFactura: ref.IssueDate.Time().Format("02-01-2006"), + }, + }) + } + reg.FacturasSustituidas = subs + } + } + if r == IssuerRoleThirdParty { reg.EmitidaPorTerceroODestinatario = "T" t, err := newParty(inv.Supplier) @@ -76,16 +116,6 @@ func invoiceNumber(series cbc.Code, code cbc.Code) string { return fmt.Sprintf("%s-%s", series, code) } -func mapInvoiceType(inv *bill.Invoice) string { - switch inv.Type { - case bill.InvoiceTypeStandard: - return "F1" - case bill.ShortSchemaInvoice: - return "F2" - } - return "F1" -} - func newDescription(notes []*cbc.Note) (string, error) { for _, note := range notes { if note.Key == cbc.NoteKeyGeneral { diff --git a/doc/invoice_test.go b/doc/invoice_test.go index 1f06aa9..290f1b3 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -1,10 +1,14 @@ -package doc +package doc_test import ( "testing" "time" + "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" + "github.com/invopop/gobl/addons/es/verifactu" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,15 +17,15 @@ import ( func TestNewRegistroAlta(t *testing.T) { ts, err := time.Parse(time.RFC3339, "2022-02-01T04:00:00Z") require.NoError(t, err) - role := IssuerRoleSupplier - sw := &Software{} + role := doc.IssuerRoleSupplier + sw := &doc.Software{} t.Run("should contain basic document info", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - doc, err := NewDocument(inv, ts, role, sw, false) + d, err := doc.NewDocument(inv, ts, role, sw, false) require.NoError(t, err) - reg := doc.RegistroFactura.RegistroAlta + reg := d.RegistroFactura.RegistroAlta assert.Equal(t, "1.0", reg.IDVersion) assert.Equal(t, "B85905495", reg.IDFactura.IDEmisorFactura) assert.Equal(t, "SAMPLE-003", reg.IDFactura.NumSerieFactura) @@ -51,9 +55,49 @@ func TestNewRegistroAlta(t *testing.T) { inv.SetTags(tax.TagSimplified) inv.Customer = nil - doc, err := NewDocument(inv, ts, role, sw, false) + d, err := doc.NewDocument(inv, ts, role, sw, false) require.NoError(t, err) - assert.Equal(t, "S", doc.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) + assert.Equal(t, "S", d.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) + }) + + t.Run("should handle rectificative invoices", func(t *testing.T) { + inv := test.LoadInvoice("cred-note-base.json") + + d, err := doc.NewDocument(inv, ts, role, sw, false) + require.NoError(t, err) + + reg := d.RegistroFactura.RegistroAlta + assert.Equal(t, "R1", reg.TipoFactura) + assert.Equal(t, "I", reg.TipoRectificativa) + require.Len(t, reg.FacturasRectificadas, 1) + + rectified := reg.FacturasRectificadas[0] + assert.Equal(t, "B98602642", rectified.IDFactura.IDEmisorFactura) + assert.Equal(t, "SAMPLE-085", rectified.IDFactura.NumSerieFactura) + assert.Equal(t, "10-01-2022", rectified.IDFactura.FechaExpedicionFactura) + }) + + t.Run("should handle substitution invoices", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.SetTags(verifactu.TagSubstitution) + inv.Preceding = []*org.DocumentRef{ + { + Series: "SAMPLE", + Code: "002", + IssueDate: cal.NewDate(2024, 1, 15), + }, + } + + d, err := doc.NewDocument(inv, ts, role, sw, false) + require.NoError(t, err) + + reg := d.RegistroFactura.RegistroAlta + require.Len(t, reg.FacturasSustituidas, 1) + + substituted := reg.FacturasSustituidas[0] + assert.Equal(t, "B85905495", substituted.IDFactura.IDEmisorFactura) + assert.Equal(t, "SAMPLE-002", substituted.IDFactura.NumSerieFactura) + assert.Equal(t, "15-01-2024", substituted.IDFactura.FechaExpedicionFactura) }) } diff --git a/doc/party_test.go b/doc/party_test.go index ebaa5b7..719960f 100644 --- a/doc/party_test.go +++ b/doc/party_test.go @@ -1,8 +1,11 @@ -package doc +package doc_test import ( "testing" + "time" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl.verifactu/test" "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" @@ -10,25 +13,24 @@ import ( ) func TestNewParty(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2022-02-01T04:00:00Z") + require.NoError(t, err) + role := doc.IssuerRoleSupplier + sw := &doc.Software{} t.Run("with tax ID", func(t *testing.T) { - party := &org.Party{ - Name: "Test Company", - TaxID: &tax.Identity{ - Country: "ES", - Code: "B12345678", - }, - } - - result, err := newParty(party) + inv := test.LoadInvoice("inv-base.json") + d, err := doc.NewDocument(inv, ts, role, sw, false) require.NoError(t, err) - assert.Equal(t, "Test Company", result.NombreRazon) - assert.Equal(t, "B12345678", result.NIF) - assert.Nil(t, result.IDOtro) + p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario + assert.Equal(t, "Sample Consumer", p.NombreRazon) + assert.Equal(t, "B63272603", p.NIF) + assert.Nil(t, p.IDOtro) }) t.Run("with passport", func(t *testing.T) { - party := &org.Party{ + inv := test.LoadInvoice("inv-base.json") + inv.Customer = &org.Party{ Name: "Mr. Pass Port", Identities: []*org.Identity{ { @@ -38,18 +40,20 @@ func TestNewParty(t *testing.T) { }, } - result, err := newParty(party) + d, err := doc.NewDocument(inv, ts, role, sw, false) require.NoError(t, err) - assert.Equal(t, "Mr. Pass Port", result.NombreRazon) - assert.Empty(t, result.NIF) - assert.NotNil(t, result.IDOtro) - assert.Equal(t, "03", result.IDOtro.IDType) - assert.Equal(t, "12345", result.IDOtro.ID) + p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario + assert.Equal(t, "Mr. Pass Port", p.NombreRazon) + assert.Empty(t, p.NIF) + assert.NotNil(t, p.IDOtro) + assert.Equal(t, "03", p.IDOtro.IDType) + assert.Equal(t, "12345", p.IDOtro.ID) }) t.Run("with foreign identity", func(t *testing.T) { - party := &org.Party{ + inv := test.LoadInvoice("inv-base.json") + inv.Customer = &org.Party{ Name: "Foreign Company", TaxID: &tax.Identity{ Country: "DE", @@ -57,22 +61,25 @@ func TestNewParty(t *testing.T) { }, } - result, err := newParty(party) + d, err := doc.NewDocument(inv, ts, role, sw, false) require.NoError(t, err) - assert.Equal(t, "Foreign Company", result.NombreRazon) - assert.Empty(t, result.NIF) - assert.NotNil(t, result.IDOtro) - assert.Equal(t, "04", result.IDOtro.IDType) - assert.Equal(t, "111111125", result.IDOtro.ID) + p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario + + assert.Equal(t, "Foreign Company", p.NombreRazon) + assert.Empty(t, p.NIF) + assert.NotNil(t, p.IDOtro) + assert.Equal(t, "04", p.IDOtro.IDType) + assert.Equal(t, "111111125", p.IDOtro.ID) }) t.Run("with no identifiers", func(t *testing.T) { - party := &org.Party{ + inv := test.LoadInvoice("inv-base.json") + inv.Customer = &org.Party{ Name: "Simple Company", } - _, err := newParty(party) + _, err := doc.NewDocument(inv, ts, role, sw, false) require.Error(t, err) }) } diff --git a/doc/qr_code_test.go b/doc/qr_code_test.go index 8e9bb63..6ea9f7d 100644 --- a/doc/qr_code_test.go +++ b/doc/qr_code_test.go @@ -1,21 +1,23 @@ -package doc +package doc_test import ( "testing" + + "github.com/invopop/gobl.verifactu/doc" ) func TestGenerateCodes(t *testing.T) { tests := []struct { name string - doc *VeriFactu + doc *doc.VeriFactu expected string }{ { name: "valid codes generation", - doc: &VeriFactu{ - RegistroFactura: &RegistroFactura{ - RegistroAlta: &RegistroAlta{ - IDFactura: &IDFactura{ + doc: &doc.VeriFactu{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "89890001K", NumSerieFactura: "12345678-G33", FechaExpedicionFactura: "01-09-2024", @@ -28,10 +30,10 @@ func TestGenerateCodes(t *testing.T) { }, { name: "empty fields", - doc: &VeriFactu{ - RegistroFactura: &RegistroFactura{ - RegistroAlta: &RegistroAlta{ - IDFactura: &IDFactura{ + doc: &doc.VeriFactu{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "", NumSerieFactura: "", FechaExpedicionFactura: "", @@ -46,7 +48,7 @@ func TestGenerateCodes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.doc.generateURL() + got := tt.doc.QRCodes() if got != tt.expected { t.Errorf("generateURL() = %v, want %v", got, tt.expected) } @@ -57,15 +59,15 @@ func TestGenerateCodes(t *testing.T) { func TestGenerateURLCodeAlta(t *testing.T) { tests := []struct { name string - doc *VeriFactu + doc *doc.VeriFactu expected string }{ { name: "valid URL generation", - doc: &VeriFactu{ - RegistroFactura: &RegistroFactura{ - RegistroAlta: &RegistroAlta{ - IDFactura: &IDFactura{ + doc: &doc.VeriFactu{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "89890001K", NumSerieFactura: "12345678-G33", FechaExpedicionFactura: "01-09-2024", @@ -78,10 +80,10 @@ func TestGenerateURLCodeAlta(t *testing.T) { }, { name: "URL with special characters", - doc: &VeriFactu{ - RegistroFactura: &RegistroFactura{ - RegistroAlta: &RegistroAlta{ - IDFactura: &IDFactura{ + doc: &doc.VeriFactu{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ IDEmisorFactura: "A12 345&67", NumSerieFactura: "SERIE/2023", FechaExpedicionFactura: "01-09-2024", @@ -96,7 +98,10 @@ func TestGenerateURLCodeAlta(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.doc.generateURL() + tt.doc.RegistroFactura.RegistroAlta.Encadenamiento = &doc.Encadenamiento{ + PrimerRegistro: &s, + } + got := tt.doc.QRCodes() if got != tt.expected { t.Errorf("generateURLCodeAlta() = %v, want %v", got, tt.expected) } diff --git a/examples_test.go b/examples_test.go new file mode 100644 index 0000000..b10aecc --- /dev/null +++ b/examples_test.go @@ -0,0 +1,141 @@ +package verifactu_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl.verifactu/test" + "github.com/lestrrat-go/libxml2" + "github.com/lestrrat-go/libxml2/xsd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + msgMissingOutFile = "output file %s missing, run tests with `--update` flag to create" + msgUnmatchingOutFile = "output file %s does not match, run tests with `--update` flag to update" +) + +func TestXMLGeneration(t *testing.T) { + schema, err := loadSchema() + require.NoError(t, err) + + examples, err := lookupExamples() + require.NoError(t, err) + + tbai, err := loadClient() + 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(tbai, example) + require.NoError(t, err) + + outPath := test.Path("test", "data", "out", + strings.TrimSuffix(example, ".json")+".xml", + ) + + if *test.UpdateOut { + errs := validateDoc(schema, data) + for _, e := range errs { + assert.NoError(t, e) + } + if len(errs) > 0 { + assert.Fail(t, "Invalid XML:\n"+string(data)) + return + } + + err = os.WriteFile(outPath, data, 0644) + require.NoError(t, err) + + return + } + + expected, err := os.ReadFile(outPath) + + require.False(t, os.IsNotExist(err), msgMissingOutFile, filepath.Base(outPath)) + require.NoError(t, err) + require.Equal(t, string(expected), string(data), msgUnmatchingOutFile, filepath.Base(outPath)) + }) + } +} + +func loadSchema() (*xsd.Schema, error) { + schemaPath := test.Path("test", "schema", "SuministroLR.xsd") + schema, err := xsd.ParseFromFile(schemaPath) + if err != nil { + return nil, err + } + + return schema, nil +} + +func loadClient() (*verifactu.Client, error) { + ts, err := time.Parse(time.RFC3339, "2024-11-26T04:00:00Z") + if err != nil { + return nil, err + } + + return verifactu.New(&doc.Software{ + NombreRazon: "My Software", + NIF: "12345678A", + NombreSistemaInformatico: "My Software", + IdSistemaInformatico: "12345678A", + Version: "1.0", + NumeroInstalacion: "12345678A", + }, + verifactu.WithCurrentTime(ts), + verifactu.WithThirdPartyIssuer(), + ) +} + +func lookupExamples() ([]string, error) { + examples, err := filepath.Glob(test.Path("test", "data", "*.json")) + if err != nil { + return nil, err + } + + for i, example := range examples { + examples[i] = filepath.Base(example) + } + + return examples, nil +} + +func convertExample(c *verifactu.Client, example string) ([]byte, error) { + env := test.LoadEnvelope(example) + + td, err := c.Convert(env) + if err != nil { + return nil, err + } + + err = c.Fingerprint(td, &doc.ChainData{}) + if err != nil { + return nil, err + } + + return td.BytesIndent() +} + +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 +} diff --git a/go.mod b/go.mod index 3f0d443..6205a8c 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,10 @@ toolchain go1.22.1 require ( github.com/go-resty/resty/v2 v2.15.3 - github.com/invopop/gobl v0.205.2-0.20241119180855-1b04b703647d + github.com/invopop/gobl v0.206.1-0.20241126104736-056b02fb77f1 github.com/invopop/xmldsig v0.10.0 github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3 github.com/magefile/mage v1.15.0 github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 github.com/spf13/cobra v1.8.1 @@ -33,6 +34,7 @@ require ( github.com/invopop/yaml v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/go.sum b/go.sum index a169ece..02d3e67 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.205.2-0.20241119180855-1b04b703647d h1:4xME4bzxrR0YrmjvvHAfRplMV+duEIvXwqZ/tsGib+U= -github.com/invopop/gobl v0.205.2-0.20241119180855-1b04b703647d/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.206.1-0.20241126104736-056b02fb77f1 h1:smOgSdtBss0upURUgIvvbJOo0iHFcyjxDGgCQNhMB6Q= +github.com/invopop/gobl v0.206.1-0.20241126104736-056b02fb77f1/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.8.0 h1:e5hXHGnONHImgJdonIpNbctg1hlWy1ncaHoVIQ0JWuw= @@ -41,12 +41,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3 h1:ZIYZ0+TEddrxA2dEx4ITTBCdRqRP8Zh+8nb4tSx0nOw= +github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3/go.mod h1:/0MMipmS+5SMXCSkulsvJwYmddKI4IL5tVy6AZMo9n0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 h1:1okRi+ZpH92VkeIJPJWfoNVXm3F4225PoT1LSO+gFfw= github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80/go.mod h1:990JnYmJZFrx1vI1TALoD6/fCqnWlTx2FrPbYy2wi5I= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 h1:igWZJluD8KtEtAgRyF4x6lqcxDry1ULztksMJh2mnQE= @@ -80,6 +84,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/xmlpath.v1 v1.0.0-20140413065638-a146725ea6e7 h1:zibSPXbkfB1Dwl76rJgLa68xcdHu42qmFTe6vAnU4wA= +gopkg.in/xmlpath.v1 v1.0.0-20140413065638-a146725ea6e7/go.mod h1:wo0SW5T6XqIKCCAge330Cd5sm+7VI6v85OrQHIk50KM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/data/invoice-es-es-freelance.json b/test/data/invoice-es-es-freelance.json deleted file mode 100644 index 4fd2316..0000000 --- a/test/data/invoice-es-es-freelance.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "65b068a83bb3db05f03e0c7c9cbe04763e204db186c773fa825e1b3d5aeb60fc" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-001", - "issue_date": "2022-02-01", - "currency": "EUR", - "supplier": { - "name": "MªF. Services", - "tax_id": { - "country": "ES", - "code": "58384285G" - }, - "people": [ - { - "name": { - "given": "MARIA FRANCISCA", - "surname": "MONTERO", - "surname2": "ESTEBAN" - } - } - ], - "addresses": [ - { - "num": "9", - "street": "CAMÍ MADRID", - "locality": "CANENA", - "region": "JAÉN", - "code": "23480", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Consumer", - "tax_id": { - "country": "ES", - "code": "54387763P" - } - }, - "lines": [ - { - "i": 1, - "quantity": "20", - "item": { - "name": "Development services", - "price": "90.00", - "unit": "h" - }, - "sum": "1800.00", - "discounts": [ - { - "reason": "Special discount", - "percent": "10%", - "amount": "180.00" - } - ], - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - }, - { - "cat": "IRPF", - "percent": "15.0%" - } - ], - "total": "1620.00" - } - ], - "payment": { - "terms": { - "key": "instant" - }, - "instructions": { - "key": "credit-transfer", - "credit_transfer": [ - { - "iban": "ES06 0128 0011 3901 0008 1391", - "name": "Bankinter" - } - ] - } - }, - "totals": { - "sum": "1620.00", - "total": "1620.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "1620.00", - "percent": "21.0%", - "amount": "340.20" - } - ], - "amount": "340.20" - }, - { - "code": "IRPF", - "retained": true, - "rates": [ - { - "base": "1620.00", - "percent": "15.0%", - "amount": "243.00" - } - ], - "amount": "243.00" - } - ], - "sum": "97.20" - }, - "tax": "97.20", - "total_with_tax": "1717.20", - "payable": "1717.20" - } - } -} \ No newline at end of file diff --git a/test/data/invoice-es-es-vateqs-provider.json b/test/data/invoice-es-es-vateqs-provider.json deleted file mode 100644 index ae24a1b..0000000 --- a/test/data/invoice-es-es-vateqs-provider.json +++ /dev/null @@ -1,169 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "d2cf7a48f6435ddfd9809dd9589be5c329098895ba6da96291b882d43db0151f" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-001", - "issue_date": "2022-02-01", - "currency": "EUR", - "tax": { - "prices_include": "VAT" - }, - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Simple Goods Store", - "tax_id": { - "country": "ES", - "code": "54387763P" - }, - "addresses": [ - { - "num": "43", - "street": "Calle Mayor", - "locality": "Madrid", - "region": "Madrid", - "code": "28003" - } - ] - }, - "lines": [ - { - "i": 1, - "quantity": "10", - "item": { - "name": "Mugs from provider", - "price": "10.00" - }, - "sum": "100.00", - "taxes": [ - { - "cat": "VAT", - "rate": "standard+eqs", - "percent": "21.0%", - "surcharge": "5.2%" - } - ], - "total": "100.00" - }, - { - "i": 2, - "quantity": "1", - "item": { - "name": "Delivery Costs", - "price": "10.00" - }, - "sum": "10.00", - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "10.00" - } - ], - "payment": { - "terms": { - "key": "due-date", - "due_dates": [ - { - "date": "2021-10-30", - "amount": "45.72", - "percent": "40%" - }, - { - "date": "2021-11-30", - "amount": "68.58", - "percent": "60%" - } - ] - }, - "advances": [ - { - "date": "2021-09-01", - "description": "Deposit paid upfront", - "amount": "25.00" - } - ], - "instructions": { - "key": "credit-transfer", - "credit_transfer": [ - { - "iban": "ES06 0128 0011 3901 0008 1391", - "bic": "BKBKESMMXXX", - "name": "Bankinter" - } - ] - } - }, - "totals": { - "sum": "110.00", - "tax_included": "19.09", - "total": "90.91", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard+eqs", - "base": "82.64", - "percent": "21.0%", - "surcharge": { - "percent": "5.2%", - "amount": "4.30" - }, - "amount": "17.36" - }, - { - "key": "standard", - "base": "8.26", - "percent": "21.0%", - "amount": "1.74" - } - ], - "amount": "19.09", - "surcharge": "4.30" - } - ], - "sum": "23.39" - }, - "tax": "23.39", - "total_with_tax": "114.30", - "payable": "114.30", - "advance": "25.00", - "due": "89.30" - } - } -} \ No newline at end of file diff --git a/test/data/invoice-es-es-vateqs-retailer.json b/test/data/invoice-es-es-vateqs-retailer.json deleted file mode 100644 index ff5d09e..0000000 --- a/test/data/invoice-es-es-vateqs-retailer.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "9933198da084cea1342a62d80014ae53262de1c33972b52ab7093bb002136ff5" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "$tags": [ - "simplified" - ], - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-001", - "issue_date": "2022-02-01", - "currency": "EUR", - "tax": { - "prices_include": "VAT" - }, - "supplier": { - "name": "Simple Goods Store", - "tax_id": { - "country": "ES", - "code": "54387763P" - }, - "addresses": [ - { - "num": "43", - "street": "Calle Mayor", - "locality": "Madrid", - "region": "Madrid", - "code": "28003" - } - ] - }, - "lines": [ - { - "i": 1, - "quantity": "10", - "item": { - "name": "Mugs from provider", - "price": "16.00", - "meta": { - "source": "provider" - } - }, - "sum": "160.00", - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "160.00" - } - ], - "totals": { - "sum": "160.00", - "tax_included": "27.77", - "total": "132.23", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "132.23", - "percent": "21.0%", - "amount": "27.77" - } - ], - "amount": "27.77" - } - ], - "sum": "27.77" - }, - "tax": "27.77", - "total_with_tax": "160.00", - "payable": "160.00" - } - } -} \ No newline at end of file diff --git a/test/data/invoice-es-es.env.json b/test/data/invoice-es-es.env.json deleted file mode 100644 index 7549812..0000000 --- a/test/data/invoice-es-es.env.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "892491c186d6e68b8b1b33098c25c09467278962884aee99e1c51026148086ae" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-001", - "issue_date": "2022-02-01", - "currency": "EUR", - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Consumer", - "tax_id": { - "country": "ES", - "code": "54387763P" - } - }, - "lines": [ - { - "i": 1, - "quantity": "10", - "item": { - "name": "Item being purchased", - "price": "100.00" - }, - "sum": "1000.00", - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "1000.00" - } - ], - "totals": { - "sum": "1000.00", - "total": "1000.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "1000.00", - "percent": "21.0%", - "amount": "210.00" - } - ], - "amount": "210.00" - } - ], - "sum": "210.00" - }, - "tax": "210.00", - "total_with_tax": "1210.00", - "payable": "1210.00" - } - } -} \ No newline at end of file diff --git a/test/data/invoice-es-es.json b/test/data/invoice-es-es.json deleted file mode 100644 index 63cc913..0000000 --- a/test/data/invoice-es-es.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "$addons": [ - "es-facturae-v3" - ], - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-001", - "issue_date": "2022-02-01", - "currency": "EUR", - "tax": { - "ext": { - "es-facturae-doc-type": "FC", - "es-facturae-invoice-class": "OO" - } - }, - "supplier": { - "name": "Invopop S.L.", - "tax_id": { - "country": "ES", - "code": "B85905495" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Consumer", - "tax_id": { - "country": "ES", - "code": "54387763P" - } - }, - "lines": [ - { - "i": 1, - "quantity": "20", - "item": { - "name": "Development services", - "price": "90.00", - "unit": "h" - }, - "sum": "1800.00", - "discounts": [ - { - "reason": "Special discount", - "percent": "10%", - "amount": "180.00" - } - ], - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "1620.00" - }, - { - "i": 2, - "quantity": "1", - "item": { - "name": "Financial service", - "price": "10.00" - }, - "sum": "10.00", - "taxes": [ - { - "cat": "VAT", - "rate": "zero", - "percent": "0.0%" - } - ], - "total": "10.00" - } - ], - "totals": { - "sum": "1630.00", - "total": "1630.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "1620.00", - "percent": "21.0%", - "amount": "340.20" - }, - { - "key": "zero", - "base": "10.00", - "percent": "0.0%", - "amount": "0.00" - } - ], - "amount": "340.20" - } - ], - "sum": "340.20" - }, - "tax": "340.20", - "total_with_tax": "1970.20", - "payable": "1970.20" - }, - "notes": [ - { - "key": "general", - "text": "This is a sample invoice" - } - ] - } -} \ No newline at end of file diff --git a/test/data/invoice-es-nl-b2b.json b/test/data/invoice-es-nl-b2b.json deleted file mode 100644 index 8aec2d7..0000000 --- a/test/data/invoice-es-nl-b2b.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "320cd30d7d2713ab79966ddb727d25194f79fe472bf3b1700344d5776e956c6c" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "$tags": [ - "reverse-charge" - ], - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-X-002", - "issue_date": "2022-02-01", - "currency": "EUR", - "tax": {}, - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Consumer", - "tax_id": { - "country": "NL", - "code": "000099995B57" - } - }, - "lines": [ - { - "i": 1, - "quantity": "10", - "item": { - "name": "Services exported", - "price": "20.00", - "unit": "day" - }, - "sum": "200.00", - "taxes": [ - { - "cat": "VAT", - "rate": "exempt" - } - ], - "total": "200.00" - }, - { - "i": 2, - "quantity": "50", - "item": { - "name": "Branded Mugs", - "price": "7.50" - }, - "sum": "375.00", - "taxes": [ - { - "cat": "VAT", - "rate": "exempt" - } - ], - "total": "375.00" - } - ], - "totals": { - "sum": "575.00", - "total": "575.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "exempt", - "base": "575.00", - "amount": "0.00" - } - ], - "amount": "0.00" - } - ], - "sum": "0.00" - }, - "tax": "0.00", - "total_with_tax": "575.00", - "payable": "575.00" - }, - "notes": [ - { - "key": "legal", - "src": "reverse-charge", - "text": "Reverse Charge / Inversión del sujeto pasivo." - } - ] - } -} \ No newline at end of file diff --git a/test/data/invoice-es-nl-digital-b2c.json b/test/data/invoice-es-nl-digital-b2c.json deleted file mode 100644 index 5ebed34..0000000 --- a/test/data/invoice-es-nl-digital-b2c.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "1c315a855ea6df7b7df5f6645d5c63552ca9cd58f14d6c11db7c3ff03ad12b9f" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "$tags": [ - "customer-rates" - ], - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-X-002", - "issue_date": "2022-02-01", - "currency": "EUR", - "tax": {}, - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Consumer", - "tax_id": { - "country": "NL" - } - }, - "lines": [ - { - "i": 1, - "quantity": "10", - "item": { - "name": "Services exported", - "price": "100.00" - }, - "sum": "1000.00", - "taxes": [ - { - "cat": "VAT", - "country": "NL", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "1000.00" - } - ], - "totals": { - "sum": "1000.00", - "total": "1000.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "country": "NL", - "base": "1000.00", - "percent": "21.0%", - "amount": "210.00" - } - ], - "amount": "210.00" - } - ], - "sum": "210.00" - }, - "tax": "210.00", - "total_with_tax": "1210.00", - "payable": "1210.00" - } - } -} \ No newline at end of file diff --git a/test/data/invoice-es-pt-digital.json b/test/data/invoice-es-pt-digital.json deleted file mode 100644 index a26ee5a..0000000 --- a/test/data/invoice-es-pt-digital.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "5d6d6423d5895dbb2c8ca93b5d85fc1df8b57e414b2f0303e496e48614850f05" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "$tags": [ - "customer-rates" - ], - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-X-002", - "issue_date": "2022-02-01", - "currency": "EUR", - "tax": {}, - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Consumer", - "tax_id": { - "country": "PT" - }, - "addresses": [ - { - "street": "Rua do Hotelzinho", - "locality": "Lisboa", - "code": "1000-000" - } - ] - }, - "lines": [ - { - "i": 1, - "quantity": "10", - "item": { - "name": "Services exported", - "price": "20.00", - "unit": "day" - }, - "sum": "200.00", - "taxes": [ - { - "cat": "VAT", - "country": "PT", - "rate": "standard", - "percent": "23.0%" - } - ], - "total": "200.00" - }, - { - "i": 2, - "quantity": "50", - "item": { - "name": "Branded Mugs", - "price": "7.50" - }, - "sum": "375.00", - "taxes": [ - { - "cat": "VAT", - "country": "PT", - "rate": "standard", - "percent": "23.0%" - } - ], - "total": "375.00" - } - ], - "totals": { - "sum": "575.00", - "total": "575.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "country": "PT", - "base": "575.00", - "percent": "23.0%", - "amount": "132.25" - } - ], - "amount": "132.25" - } - ], - "sum": "132.25" - }, - "tax": "132.25", - "total_with_tax": "707.25", - "payable": "707.25" - } - } -} \ No newline at end of file diff --git a/test/data/invoice-es-simplified.json b/test/data/invoice-es-simplified.json deleted file mode 100644 index d653c55..0000000 --- a/test/data/invoice-es-simplified.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "b358beb476a00bb382625762c55082a5ed78a74b620a3552146e5de9afcb7e17" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "$tags": [ - "simplified" - ], - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "code": "SAMPLE-001", - "issue_date": "2022-02-01", - "currency": "EUR", - "tax": {}, - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "lines": [ - { - "i": 1, - "quantity": "20", - "item": { - "name": "Main product", - "price": "90.00" - }, - "sum": "1800.00", - "discounts": [ - { - "reason": "Special discount", - "percent": "10%", - "amount": "180.00" - } - ], - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "1620.00" - }, - { - "i": 2, - "quantity": "1", - "item": { - "name": "Something else", - "price": "10.00" - }, - "sum": "10.00", - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "10.00" - } - ], - "totals": { - "sum": "1630.00", - "total": "1630.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "1630.00", - "percent": "21.0%", - "amount": "342.30" - } - ], - "amount": "342.30" - } - ], - "sum": "342.30" - }, - "tax": "342.30", - "total_with_tax": "1972.30", - "payable": "1972.30" - } - } -} \ No newline at end of file diff --git a/test/data/invoice-es-usd.json b/test/data/invoice-es-usd.json deleted file mode 100644 index 499df5d..0000000 --- a/test/data/invoice-es-usd.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", - "dig": { - "alg": "sha256", - "val": "b45bc74199faed76717bf154aeeb8e5e4a15d393cd423b8509f049f8f9a068bc" - } - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "$regime": "ES", - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", - "series": "EXPORT", - "code": "001", - "issue_date": "2024-05-09", - "currency": "USD", - "exchange_rates": [ - { - "from": "USD", - "to": "EUR", - "amount": "0.875967" - }, - { - "from": "MXN", - "to": "USD", - "amount": "0.059197" - } - ], - "supplier": { - "name": "Provide One S.L.", - "tax_id": { - "country": "ES", - "code": "B98602642" - }, - "addresses": [ - { - "num": "42", - "street": "Calle Pradillo", - "locality": "Madrid", - "region": "Madrid", - "code": "28002", - "country": "ES" - } - ], - "emails": [ - { - "addr": "billing@example.com" - } - ] - }, - "customer": { - "name": "Sample Consumer Inc.", - "tax_id": { - "country": "US" - } - }, - "lines": [ - { - "i": 1, - "quantity": "20", - "item": { - "name": "Development services from Spain", - "currency": "USD", - "price": "100.00", - "alt_prices": [ - { - "currency": "EUR", - "value": "90.00" - } - ], - "unit": "h" - }, - "sum": "2000.00", - "discounts": [ - { - "reason": "Special discount", - "percent": "10%", - "amount": "200.00" - } - ], - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "1800.00" - }, - { - "i": 2, - "quantity": "10", - "item": { - "name": "Development services from Mexico", - "currency": "USD", - "price": "88.80", - "alt_prices": [ - { - "currency": "MXN", - "value": "1500.00" - } - ], - "unit": "h" - }, - "sum": "888.00", - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "21.0%" - } - ], - "total": "888.00" - }, - { - "i": 3, - "quantity": "1", - "item": { - "name": "Financial service", - "price": "10.00" - }, - "sum": "10.00", - "taxes": [ - { - "cat": "VAT", - "rate": "zero", - "percent": "0.0%" - } - ], - "total": "10.00" - } - ], - "totals": { - "sum": "2698.00", - "total": "2698.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "2688.00", - "percent": "21.0%", - "amount": "564.48" - }, - { - "key": "zero", - "base": "10.00", - "percent": "0.0%", - "amount": "0.00" - } - ], - "amount": "564.48" - } - ], - "sum": "564.48" - }, - "tax": "564.48", - "total_with_tax": "3262.48", - "payable": "3262.48" - } - } -} \ No newline at end of file diff --git a/test/test.go b/test/test.go index 182f478..cb63689 100644 --- a/test/test.go +++ b/test/test.go @@ -3,6 +3,8 @@ package test import ( "bytes" + "encoding/json" + "flag" "os" "path" "path/filepath" @@ -12,6 +14,9 @@ import ( "github.com/invopop/gobl/bill" ) +// UpdateOut is a flag that can be set to update example files +var UpdateOut = flag.Bool("update", false, "Update the example files in test/data and test/data/out") + // LoadEnvelope loads a test file from the test/data folder as a GOBL envelope // and will rebuild it if necessary to ensure any changes are accounted for. func LoadEnvelope(file string) *gobl.Envelope { @@ -50,6 +55,17 @@ func LoadEnvelope(file string) *gobl.Envelope { panic(err) } + if *UpdateOut { + data, err := json.MarshalIndent(env, "", "\t") + if err != nil { + panic(err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + panic(err) + } + } + return env } From c6487b39050ca2bdbd35ad3a39aed26f63bcd90c Mon Sep 17 00:00:00 2001 From: apardods Date: Wed, 27 Nov 2024 16:20:01 +0000 Subject: [PATCH 25/56] XML Validation --- README.md | 7 +++ cancel.go | 2 +- cmd/gobl.verifactu/sendtest.go | 4 +- doc/doc.go | 4 +- doc/doc_test.go | 2 +- doc/document.go | 24 ++++----- doc/fingerprint.go | 28 +++++++--- doc/fingerprint_test.go | 21 +++++--- doc/invoice_test.go | 2 +- doc/qr_code_test.go | 2 +- examples_test.go | 86 +++++++++++++++++++------------ test/data/cred-note-base.json | 18 ++++--- test/data/inv-base.json | 12 ++--- test/data/inv-eqv-sur-b2c.json | 21 ++++---- test/data/inv-eqv-sur.json | 19 ++++--- test/data/inv-rev-charge.json | 12 ++--- test/data/inv-zero-tax.json | 11 ++-- test/data/out/cred-note-base.xml | 83 +++++++++++++++++++++-------- test/data/out/inv-base.xml | 32 ++++++++---- test/data/out/inv-eqv-sur-b2c.xml | 67 ++++++++++++++++++++++++ test/data/out/inv-eqv-sur.xml | 30 +++++++---- test/data/out/inv-rev-charge.xml | 70 +++++++++++++++++++++++++ test/data/out/inv-zero-tax.xml | 68 ++++++++++++++++++++++++ 23 files changed, 481 insertions(+), 144 deletions(-) create mode 100644 test/data/out/inv-eqv-sur-b2c.xml create mode 100644 test/data/out/inv-rev-charge.xml create mode 100644 test/data/out/inv-zero-tax.xml diff --git a/README.md b/README.md index d651c0f..1c0e7ea 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,13 @@ gobl.verifactu send ./test/data/sample-invoice.json - Currently VeriFactu supportts sending more than one invoice at a time (up to 1000). However, this module only currently supports 1 invoice at a time. +- VeriFactu supports the following rectification types: + - Factura Recitificativa por Diferencias + - Factura Recitificativa por Sustitución + - Factura en Sustitución de Facturas Simplificadas + - Registro de Anulación + - + ## Tags, Keys and Extensions In order to provide the supplier specific data required by Veri*Factu, invoices need to include a bit of extra data. We've managed to simplify these into specific cases. diff --git a/cancel.go b/cancel.go index 0b89574..3d5b8be 100644 --- a/cancel.go +++ b/cancel.go @@ -37,7 +37,7 @@ func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { return cd, nil } -// Fingerprint generates a fingerprint for the document using the +// FingerprintCancel generates a fingerprint for the cancellation document using the // data provided from the previous chain data. If there was no previous // document in the chain, the parameter should be nil. The document is updated // in place. diff --git a/cmd/gobl.verifactu/sendtest.go b/cmd/gobl.verifactu/sendtest.go index 8c35787..8b2069f 100644 --- a/cmd/gobl.verifactu/sendtest.go +++ b/cmd/gobl.verifactu/sendtest.go @@ -77,8 +77,8 @@ func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { c.previous = `{ "emisor": "B85905495", - "serie": "SAMPLE-001", - "fecha": "11-11-2024", + "serie": "SAMPLE-011", + "fecha": "21-11-2024", "huella": "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C" }` diff --git a/doc/doc.go b/doc/doc.go index eb8a9aa..ff03cf9 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -99,6 +99,8 @@ func (d *VeriFactu) ChainData() Encadenamiento { } } +// ChainDataCancel generates the data to be used to link to this one +// in the next entry for cancelling invoices. func (d *VeriFactu) ChainDataCancel() Encadenamiento { return Encadenamiento{ RegistroAnterior: &RegistroAnterior{ @@ -115,7 +117,7 @@ func (d *VeriFactu) Fingerprint(prev *ChainData) error { return d.generateHashAlta(prev) } -// Fingerprint generates the SHA-256 fingerprint for the document +// FingerprintCancel generates the SHA-256 fingerprint for the document func (d *VeriFactu) FingerprintCancel(prev *ChainData) error { return d.generateHashAnulacion(prev) } diff --git a/doc/doc_test.go b/doc/doc_test.go index baa9e40..f198955 100644 --- a/doc/doc_test.go +++ b/doc/doc_test.go @@ -25,7 +25,7 @@ func TestInvoiceConversion(t *testing.T) { assert.Equal(t, "B85905495", doc.Cabecera.Obligado.NIF) assert.Equal(t, "1.0", doc.RegistroFactura.RegistroAlta.IDVersion) assert.Equal(t, "B85905495", doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) - assert.Equal(t, "SAMPLE-003", doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) + assert.Equal(t, "SAMPLE-004", doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) assert.Equal(t, "13-11-2024", doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) }) } diff --git a/doc/document.go b/doc/document.go index 81373bf..cf842ff 100644 --- a/doc/document.go +++ b/doc/document.go @@ -50,14 +50,14 @@ type Obligado struct { // RemisionVoluntaria contains voluntary submission details type RemisionVoluntaria struct { - FechaFinVerifactu string `xml:"sum1:FechaFinVerifactu"` - Incidencia string `xml:"sum1:Incidencia"` + FechaFinVerifactu string `xml:"sum1:FechaFinVerifactu,omitempty"` + Incidencia string `xml:"sum1:Incidencia,omitempty"` } // RemisionRequerimiento contains requirement submission details type RemisionRequerimiento struct { RefRequerimiento string `xml:"sum1:RefRequerimiento"` - FinRequerimiento string `xml:"sum1:FinRequerimiento"` + FinRequerimiento string `xml:"sum1:FinRequerimiento,omitempty"` } // RegistroAlta contains the details of an invoice registration @@ -73,7 +73,7 @@ type RegistroAlta struct { FacturasRectificadas []*FacturaRectificada `xml:"sum1:FacturasRectificadas,omitempty"` FacturasSustituidas []*FacturaSustituida `xml:"sum1:FacturasSustituidas,omitempty"` ImporteRectificacion *ImporteRectificacion `xml:"sum1:ImporteRectificacion,omitempty"` - FechaOperacion string `xml:"sum1:FechaOperacion"` + FechaOperacion string `xml:"sum1:FechaOperacion,omitempty"` DescripcionOperacion string `xml:"sum1:DescripcionOperacion"` FacturaSimplificadaArt7273 string `xml:"sum1:FacturaSimplificadaArt7273,omitempty"` FacturaSinIdentifDestinatarioArt61d string `xml:"sum1:FacturaSinIdentifDestinatarioArt61d,omitempty"` @@ -100,16 +100,16 @@ type RegistroAnulacion struct { IDVersion string `xml:"sum1:IDVersion"` IDFactura *IDFacturaAnulada `xml:"sum1:IDFactura"` RefExterna string `xml:"sum1:RefExterna,omitempty"` - SinRegistroPrevio string `xml:"sum1:SinRegistroPrevio"` + SinRegistroPrevio string `xml:"sum1:SinRegistroPrevio,omitempty"` RechazoPrevio string `xml:"sum1:RechazoPrevio,omitempty"` - GeneradoPor string `xml:"sum1:GeneradoPor"` - Generador *Party `xml:"sum1:Generador"` + GeneradoPor string `xml:"sum1:GeneradoPor,omitempty"` + Generador *Party `xml:"sum1:Generador,omitempty"` Encadenamiento *Encadenamiento `xml:"sum1:Encadenamiento"` SistemaInformatico *Software `xml:"sum1:SistemaInformatico"` FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"` TipoHuella string `xml:"sum1:TipoHuella"` Huella string `xml:"sum1:Huella"` - Signature string `xml:"sum1:Signature"` + // Signature *xmldsig.Signature `xml:"sum1:Signature"` } // IDFactura contains the identifying information for an invoice @@ -119,7 +119,7 @@ type IDFactura struct { FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"` } -// IDFactura contains the identifying information for an invoice +// IDFacturaAnulada contains the identifying information for an invoice type IDFacturaAnulada struct { IDEmisorFactura string `xml:"sum1:IDEmisorFacturaAnulada"` NumSerieFactura string `xml:"sum1:NumSerieFacturaAnulada"` @@ -128,12 +128,12 @@ type IDFacturaAnulada struct { // FacturaRectificada represents a rectified invoice type FacturaRectificada struct { - IDFactura IDFactura `xml:"sum1:IDFactura"` + IDFactura IDFactura `xml:"sum1:IDFacturaRectificada"` } // FacturaSustituida represents a substituted invoice type FacturaSustituida struct { - IDFactura IDFactura `xml:"sum1:IDFactura"` + IDFactura IDFactura `xml:"sum1:IDFacturaSustituida"` } // ImporteRectificacion contains rectification amounts @@ -183,7 +183,7 @@ type DetalleDesglose struct { // Encadenamiento contains chaining information between documents type Encadenamiento struct { - PrimerRegistro *string `xml:"sum1:PrimerRegistro,omitempty"` + PrimerRegistro string `xml:"sum1:PrimerRegistro,omitempty"` RegistroAnterior *RegistroAnterior `xml:"sum1:RegistroAnterior,omitempty"` } diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 7d7a935..2d6d334 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -31,6 +31,12 @@ func FormatField(key, value string) string { // Concatenatef builds the concatenated string based on Verifactu requirements. func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { + var h string + if inv.Encadenamiento.PrimerRegistro == "S" { + h = "" + } else { + h = inv.Encadenamiento.RegistroAnterior.Huella + } f := []string{ FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), @@ -38,7 +44,7 @@ func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { FormatField("TipoFactura", inv.TipoFactura), FormatField("CuotaTotal", fmt.Sprintf("%g", inv.CuotaTotal)), FormatField("ImporteTotal", fmt.Sprintf("%g", inv.ImporteTotal)), - FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), + FormatField("Huella", h), FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } st := strings.Join(f, "&") @@ -50,11 +56,17 @@ func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { } func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { + var h string + if inv.Encadenamiento.PrimerRegistro == "S" { + h = "" + } else { + h = inv.Encadenamiento.RegistroAnterior.Huella + } f := []string{ FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), - FormatField("Huella", inv.Encadenamiento.RegistroAnterior.Huella), + FormatField("Huella", h), FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } st := strings.Join(f, "&") @@ -68,9 +80,11 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { // GenerateHash generates the SHA-256 hash for the invoice data. func (d *VeriFactu) generateHashAlta(prev *ChainData) error { if prev == nil { - s := "S" d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ - PrimerRegistro: &s, + PrimerRegistro: "S", + } + if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { + return err } return nil } @@ -91,9 +105,11 @@ func (d *VeriFactu) generateHashAlta(prev *ChainData) error { func (d *VeriFactu) generateHashAnulacion(prev *ChainData) error { if prev == nil { - s := "S" d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ - PrimerRegistro: &s, + PrimerRegistro: "S", + } + if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { + return err } return nil } diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go index a4ca946..3ee832b 100644 --- a/doc/fingerprint_test.go +++ b/doc/fingerprint_test.go @@ -6,8 +6,6 @@ import ( "github.com/invopop/gobl.verifactu/doc" ) -var s = "S" - func TestFingerprintAlta(t *testing.T) { t.Run("Alta", func(t *testing.T) { tests := []struct { @@ -69,7 +67,7 @@ func TestFingerprintAlta(t *testing.T) { TipoFactura: "F1", CuotaTotal: 500.0, ImporteTotal: 2500.0, - Encadenamiento: &doc.Encadenamiento{PrimerRegistro: &s}, + Encadenamiento: &doc.Encadenamiento{PrimerRegistro: "S"}, FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", }, prev: nil, @@ -138,7 +136,12 @@ func TestFingerprintAnulacion(t *testing.T) { FechaExpedicionFactura: "11-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", - Encadenamiento: &doc.Encadenamiento{RegistroAnterior: &doc.RegistroAnterior{Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1"}}, + }, + prev: &doc.ChainData{ + IDEmisorFactura: "foo", + NumSerieFactura: "bar", + FechaExpedicionFactura: "baz", + Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, expected: "BAB9B4AE157321642F6AFD8030288B7E595129B29A00A69CEB308CEAA53BFBD7", }, @@ -151,7 +154,12 @@ func TestFingerprintAnulacion(t *testing.T) { FechaExpedicionFactura: "12-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T12:00:55+01:00", - Encadenamiento: &doc.Encadenamiento{RegistroAnterior: &doc.RegistroAnterior{Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A"}}, + }, + prev: &doc.ChainData{ + IDEmisorFactura: "foo", + NumSerieFactura: "bar", + FechaExpedicionFactura: "baz", + Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", }, expected: "548707E0984AA867CC173B24389E648DECDEE48A2674DA8CE8A3682EF8F119DD", }, @@ -164,8 +172,9 @@ func TestFingerprintAnulacion(t *testing.T) { FechaExpedicionFactura: "11-11-2024", }, FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", - Encadenamiento: &doc.Encadenamiento{PrimerRegistro: &s}, + Encadenamiento: &doc.Encadenamiento{PrimerRegistro: "S"}, }, + prev: nil, expected: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", }, } diff --git a/doc/invoice_test.go b/doc/invoice_test.go index 290f1b3..be77948 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -28,7 +28,7 @@ func TestNewRegistroAlta(t *testing.T) { reg := d.RegistroFactura.RegistroAlta assert.Equal(t, "1.0", reg.IDVersion) assert.Equal(t, "B85905495", reg.IDFactura.IDEmisorFactura) - assert.Equal(t, "SAMPLE-003", reg.IDFactura.NumSerieFactura) + assert.Equal(t, "SAMPLE-004", reg.IDFactura.NumSerieFactura) assert.Equal(t, "13-11-2024", reg.IDFactura.FechaExpedicionFactura) assert.Equal(t, "Invopop S.L.", reg.NombreRazonEmisor) assert.Equal(t, "F1", reg.TipoFactura) diff --git a/doc/qr_code_test.go b/doc/qr_code_test.go index 6ea9f7d..ebe673a 100644 --- a/doc/qr_code_test.go +++ b/doc/qr_code_test.go @@ -99,7 +99,7 @@ func TestGenerateURLCodeAlta(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.doc.RegistroFactura.RegistroAlta.Encadenamiento = &doc.Encadenamiento{ - PrimerRegistro: &s, + PrimerRegistro: "S", } got := tt.doc.QRCodes() if got != tt.expected { diff --git a/examples_test.go b/examples_test.go index b10aecc..33d0d8a 100644 --- a/examples_test.go +++ b/examples_test.go @@ -11,6 +11,7 @@ import ( verifactu "github.com/invopop/gobl.verifactu" "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" + "github.com/lestrrat-go/libxml2" "github.com/lestrrat-go/libxml2/xsd" "github.com/stretchr/testify/assert" @@ -29,29 +30,49 @@ func TestXMLGeneration(t *testing.T) { examples, err := lookupExamples() require.NoError(t, err) - tbai, err := loadClient() + c, err := loadClient() 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(tbai, example) + env := test.LoadEnvelope(example) + td, err := c.Convert(env) + require.NoError(t, err) + + prev := &doc.ChainData{ + IDEmisorFactura: "B85905495", + NumSerieFactura: "SAMPLE-003", + FechaExpedicionFactura: "13-11-2024", + Huella: "E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371", + } + + err = c.Fingerprint(td, prev) require.NoError(t, err) outPath := test.Path("test", "data", "out", strings.TrimSuffix(example, ".json")+".xml", ) + valData, err := td.Bytes() + require.NoError(t, err) + + valData, err = addNamespaces(valData) + require.NoError(t, err) + + errs := validateDoc(schema, valData) + for _, e := range errs { + assert.NoError(t, e) + } + if len(errs) > 0 { + assert.Fail(t, "Invalid XML:\n"+string(valData)) + return + } + if *test.UpdateOut { - errs := validateDoc(schema, data) - for _, e := range errs { - assert.NoError(t, e) - } - if len(errs) > 0 { - assert.Fail(t, "Invalid XML:\n"+string(data)) - return - } + data, err := td.Envelop() + require.NoError(t, err) err = os.WriteFile(outPath, data, 0644) require.NoError(t, err) @@ -63,7 +84,9 @@ func TestXMLGeneration(t *testing.T) { require.False(t, os.IsNotExist(err), msgMissingOutFile, filepath.Base(outPath)) require.NoError(t, err) - require.Equal(t, string(expected), string(data), msgUnmatchingOutFile, filepath.Base(outPath)) + outData, err := td.Envelop() + require.NoError(t, err) + require.Equal(t, string(expected), string(outData), msgUnmatchingOutFile, filepath.Base(outPath)) }) } } @@ -85,12 +108,15 @@ func loadClient() (*verifactu.Client, error) { } return verifactu.New(&doc.Software{ - NombreRazon: "My Software", - NIF: "12345678A", - NombreSistemaInformatico: "My Software", - IdSistemaInformatico: "12345678A", - Version: "1.0", - NumeroInstalacion: "12345678A", + NombreRazon: "My Software", + NIF: "12345678A", + NombreSistemaInformatico: "My Software", + IdSistemaInformatico: "A1", + Version: "1.0", + NumeroInstalacion: "12345678A", + TipoUsoPosibleSoloVerifactu: "S", + TipoUsoPosibleMultiOT: "S", + IndicadorMultiplesOT: "N", }, verifactu.WithCurrentTime(ts), verifactu.WithThirdPartyIssuer(), @@ -110,22 +136,6 @@ func lookupExamples() ([]string, error) { return examples, nil } -func convertExample(c *verifactu.Client, example string) ([]byte, error) { - env := test.LoadEnvelope(example) - - td, err := c.Convert(env) - if err != nil { - return nil, err - } - - err = c.Fingerprint(td, &doc.ChainData{}) - if err != nil { - return nil, err - } - - return td.BytesIndent() -} - func validateDoc(schema *xsd.Schema, doc []byte) []error { xmlDoc, err := libxml2.ParseString(string(doc)) if err != nil { @@ -139,3 +149,13 @@ func validateDoc(schema *xsd.Schema, doc []byte) []error { return nil } + +// Helper function to inject namespaces into XML without using Envelop() +// Just for xsd validation purposes +func addNamespaces(data []byte) ([]byte, error) { + xmlString := string(data) + xmlNamespaces := ` xmlns:sum="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd"` + xmlString = strings.Replace(xmlString, "", "", 1) + finalXMLBytes := []byte(xmlString) + return finalXMLBytes, nil +} diff --git a/test/data/cred-note-base.json b/test/data/cred-note-base.json index 79a8d61..63dcdb1 100644 --- a/test/data/cred-note-base.json +++ b/test/data/cred-note-base.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "bd55d1603bc9a657f994684631ad5890c1adfe88c00dbb1f9edb40b7c6d215c5" + "val": "f6d40829a5535964e19c4ff27601578633ea48f670fd25c9a2c95d3c0740e8e5" } }, "doc": { @@ -19,11 +19,6 @@ "code": "012", "issue_date": "2022-02-01", "currency": "EUR", - "tax": { - "ext": { - "es-verifactu-doc-type": "R1" - } - }, "preceding": [ { "type": "standard", @@ -32,6 +27,11 @@ "code": "085" } ], + "tax": { + "ext": { + "es-verifactu-doc-type": "R1" + } + }, "supplier": { "name": "Provide One S.L.", "tax_id": { @@ -136,12 +136,18 @@ "rates": [ { "key": "standard", + "ext": { + "es-verifactu-tax-classification": "S1" + }, "base": "1620.00", "percent": "21.0%", "amount": "340.20" }, { "key": "zero", + "ext": { + "es-verifactu-tax-classification": "E1" + }, "base": "10.00", "percent": "0.0%", "amount": "0.00" diff --git a/test/data/inv-base.json b/test/data/inv-base.json index 80a9efd..217984e 100644 --- a/test/data/inv-base.json +++ b/test/data/inv-base.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + "val": "32be8f884459a5cb5d7cfb85c775c9a3a3710e52dfeca749dd21cc2d61352420" } }, "doc": { @@ -16,7 +16,7 @@ "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", "type": "standard", "series": "SAMPLE", - "code": "003", + "code": "004", "issue_date": "2024-11-13", "currency": "EUR", "tax": { @@ -86,12 +86,12 @@ "rates": [ { "key": "standard", - "base": "1800.00", - "percent": "21.0%", - "amount": "378.00", "ext": { "es-verifactu-tax-classification": "S1" - } + }, + "base": "1800.00", + "percent": "21.0%", + "amount": "378.00" } ], "amount": "378.00" diff --git a/test/data/inv-eqv-sur-b2c.json b/test/data/inv-eqv-sur-b2c.json index aafa717..78d9e7c 100644 --- a/test/data/inv-eqv-sur-b2c.json +++ b/test/data/inv-eqv-sur-b2c.json @@ -4,24 +4,24 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", "dig": { "alg": "sha256", - "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + "val": "a9779cdbd2ca3ad7f37226cae5cedd71f805721e13b1d37bdecb36194a8cb70d" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "ES", - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", "$addons": [ "es-verifactu-v1" ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", "series": "SAMPLE", "code": "432", "issue_date": "2024-11-11", "currency": "EUR", "tax": { "ext": { - "es-verifactu-doc-type": "F2" + "es-verifactu-doc-type": "F1" } }, "supplier": { @@ -61,7 +61,10 @@ "cat": "VAT", "rate": "standard+eqs", "percent": "21.0%", - "surcharge": "5.2%" + "surcharge": "5.2%", + "ext": { + "es-verifactu-tax-classification": "S1" + } } ], "total": "1800.00" @@ -77,16 +80,16 @@ "rates": [ { "key": "standard+eqs", + "ext": { + "es-verifactu-tax-classification": "S1" + }, "base": "1800.00", "percent": "21.0%", "surcharge": { "percent": "5.2%", "amount": "93.60" }, - "amount": "378.00", - "ext": { - "es-verifactu-tax-classification": "S1" - } + "amount": "378.00" } ], "amount": "378.00", diff --git a/test/data/inv-eqv-sur.json b/test/data/inv-eqv-sur.json index d95d9f9..77adce7 100644 --- a/test/data/inv-eqv-sur.json +++ b/test/data/inv-eqv-sur.json @@ -4,17 +4,17 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", "dig": { "alg": "sha256", - "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + "val": "f74efabf4c5cf10f1649230f84d33f62810a897b8b65d3a7eb260fc74161f87d" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "ES", - "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", - "type": "standard", "$addons": [ "es-verifactu-v1" ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", "series": "SAMPLE", "code": "432", "issue_date": "2024-11-11", @@ -68,7 +68,10 @@ "cat": "VAT", "rate": "standard+eqs", "percent": "21.0%", - "surcharge": "5.2%" + "surcharge": "5.2%", + "ext": { + "es-verifactu-tax-classification": "S1" + } } ], "total": "1800.00" @@ -84,16 +87,16 @@ "rates": [ { "key": "standard+eqs", + "ext": { + "es-verifactu-tax-classification": "S1" + }, "base": "1800.00", "percent": "21.0%", "surcharge": { "percent": "5.2%", "amount": "93.60" }, - "amount": "378.00", - "ext": { - "es-verifactu-tax-classification": "S1" - } + "amount": "378.00" } ], "amount": "378.00", diff --git a/test/data/inv-rev-charge.json b/test/data/inv-rev-charge.json index 80a9efd..8d5d3ce 100644 --- a/test/data/inv-rev-charge.json +++ b/test/data/inv-rev-charge.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + "val": "01616e964c42ffec6342b37e6cbf5018f6cf72f586a06a3662cafe5af235be3b" } }, "doc": { @@ -69,7 +69,7 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-tax-classification": "S2" } } ], @@ -86,12 +86,12 @@ "rates": [ { "key": "standard", + "ext": { + "es-verifactu-tax-classification": "S2" + }, "base": "1800.00", "percent": "21.0%", - "amount": "378.00", - "ext": { - "es-verifactu-tax-classification": "S1" - } + "amount": "378.00" } ], "amount": "378.00" diff --git a/test/data/inv-zero-tax.json b/test/data/inv-zero-tax.json index 16f329b..5bb11eb 100644 --- a/test/data/inv-zero-tax.json +++ b/test/data/inv-zero-tax.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "88c970d2e4aa21a7fd711856c45fcf847a4af61bce0d4c2dafb2c1550f9701fc" + "val": "1737d61d53252d2072ef02821cc386fbc52df806627354782cff46cd9213eff7" } }, "doc": { @@ -67,6 +67,7 @@ { "cat": "VAT", "rate": "zero", + "percent": "0.0%", "ext": { "es-verifactu-tax-classification": "E1" } @@ -85,12 +86,12 @@ "rates": [ { "key": "zero", - "base": "1800.00", - "percent": "0.0%", - "amount": "0.00", "ext": { "es-verifactu-tax-classification": "E1" - } + }, + "base": "1800.00", + "percent": "0.0%", + "amount": "0.00" } ], "amount": "0.00" diff --git a/test/data/out/cred-note-base.xml b/test/data/out/cred-note-base.xml index 6227e8d..59892ac 100755 --- a/test/data/out/cred-note-base.xml +++ b/test/data/out/cred-note-base.xml @@ -8,36 +8,77 @@ - - 1.0 - + + 1.0 + B98602642 FR-012 01-02-2022 - - - E - + + Provide One S.L. + R1 + I + + + B98602642 + SAMPLE-085 + 10-01-2022 + + + Some random description + T + Provide One S.L. B98602642 - - - Invopop S.L. - B85905495 - gobl.verifactu + + + + Sample Customer + 54387763P + + + + + 01 + 01 + S1 + 21 + 1620 + 340.2 + + + 01 + 01 + E1 + 10 + + + 340.2 + 1970.2 + + + B85905495 + SAMPLE-003 + 13-11-2024 + E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + + + + My Software + 12345678A + My Software A1 1.0 - 00001 + 12345678A S S - S - - 2024-11-21T18:26:22+01:00 - 01 - - - + N + + 2024-11-26T05:00:00+01:00 + 01 + E5DFED6D2E3C1BF8C47EF57823FFF15CC8CB638CE95FDB235DB2CA9BC51FE9F5 + - + \ No newline at end of file diff --git a/test/data/out/inv-base.xml b/test/data/out/inv-base.xml index 1881ea3..22fe742 100755 --- a/test/data/out/inv-base.xml +++ b/test/data/out/inv-base.xml @@ -12,13 +12,17 @@ 1.0 B85905495 - SAMPLE-003 + SAMPLE-004 13-11-2024 Invopop S.L. F1 - This is a sample invoice + T + + Invopop S.L. + B85905495 + Sample Consumer @@ -37,22 +41,30 @@ 378 2178 + + + B85905495 + SAMPLE-003 + 13-11-2024 + E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + + - Invopop S.L. - B85905495 - gobl.verifactu + My Software + 12345678A + My Software A1 1.0 - 00001 + 12345678A S S - S + N - 2024-11-21T18:27:13+01:00 + 2024-11-26T05:00:00+01:00 01 - + FDE0B32B6316FA31CCE8D2AD97118CC0B8FB4F9AEBBA8B66C016044B733FEE8E - + \ No newline at end of file diff --git a/test/data/out/inv-eqv-sur-b2c.xml b/test/data/out/inv-eqv-sur-b2c.xml new file mode 100644 index 0000000..9194261 --- /dev/null +++ b/test/data/out/inv-eqv-sur-b2c.xml @@ -0,0 +1,67 @@ + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-432 + 11-11-2024 + + Invopop S.L. + F1 + This is a sample invoice + S + T + + Invopop S.L. + B85905495 + + + + 01 + 01 + S1 + 21 + 1800 + 378 + 5.2 + 93.6 + + + 378 + 2178 + + + B85905495 + SAMPLE-003 + 13-11-2024 + E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + + + + My Software + 12345678A + My Software + A1 + 1.0 + 12345678A + S + S + N + + 2024-11-26T05:00:00+01:00 + 01 + AD2FA611FACE79B5077DF536BA1700636D08A39F243F141A5BF92927FBE69A18 + + + + + \ No newline at end of file diff --git a/test/data/out/inv-eqv-sur.xml b/test/data/out/inv-eqv-sur.xml index 2c6d493..4e5649f 100755 --- a/test/data/out/inv-eqv-sur.xml +++ b/test/data/out/inv-eqv-sur.xml @@ -17,8 +17,12 @@ Invopop S.L. F1 - This is a sample invoice + T + + Invopop S.L. + B85905495 + Sample Consumer @@ -39,22 +43,30 @@ 378 2178 + + + B85905495 + SAMPLE-003 + 13-11-2024 + E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + + - Invopop S.L. - B85905495 - gobl.verifactu + My Software + 12345678A + My Software A1 1.0 - 00001 + 12345678A S S - S + N - 2024-11-21T18:26:59+01:00 + 2024-11-26T05:00:00+01:00 01 - + AD2FA611FACE79B5077DF536BA1700636D08A39F243F141A5BF92927FBE69A18 - + \ No newline at end of file diff --git a/test/data/out/inv-rev-charge.xml b/test/data/out/inv-rev-charge.xml new file mode 100644 index 0000000..7351d5d --- /dev/null +++ b/test/data/out/inv-rev-charge.xml @@ -0,0 +1,70 @@ + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-003 + 13-11-2024 + + Invopop S.L. + F1 + This is a sample invoice + T + + Invopop S.L. + B85905495 + + + + Sample Consumer + B63272603 + + + + + 01 + 01 + S2 + 21 + 1800 + 378 + + + 378 + 2178 + + + B85905495 + SAMPLE-003 + 13-11-2024 + E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + + + + My Software + 12345678A + My Software + A1 + 1.0 + 12345678A + S + S + N + + 2024-11-26T05:00:00+01:00 + 01 + 1F342DA6DA6294B3A186EB14910C5A21C46125ACA5E067456372B671161C4C04 + + + + + \ No newline at end of file diff --git a/test/data/out/inv-zero-tax.xml b/test/data/out/inv-zero-tax.xml new file mode 100644 index 0000000..47d5e36 --- /dev/null +++ b/test/data/out/inv-zero-tax.xml @@ -0,0 +1,68 @@ + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-003 + 15-11-2024 + + Invopop S.L. + F1 + This is a sample invoice + T + + Invopop S.L. + B85905495 + + + + Sample Consumer + B63272603 + + + + + 01 + 01 + E1 + 1800 + + + 0 + 1800 + + + B85905495 + SAMPLE-003 + 13-11-2024 + E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + + + + My Software + 12345678A + My Software + A1 + 1.0 + 12345678A + S + S + N + + 2024-11-26T05:00:00+01:00 + 01 + A3CFDDB6236E2671AB29D134343420187146C3C92E5184F22A804DE1079FC7D3 + + + + + \ No newline at end of file From b9dad6e4b94a85055068c29cad629cf44bdc72ee Mon Sep 17 00:00:00 2001 From: apardods Date: Wed, 27 Nov 2024 18:10:50 +0000 Subject: [PATCH 26/56] Renaming --- cancel.go | 6 +- cmd/gobl.verifactu/cancel.go | 2 +- cmd/gobl.verifactu/root.go | 3 +- cmd/gobl.verifactu/send.go | 10 ++- cmd/gobl.verifactu/sendtest.go | 126 --------------------------------- doc/breakdown_test.go | 16 ++--- doc/cancel_test.go | 6 +- doc/doc.go | 4 +- doc/doc_test.go | 2 +- doc/invoice_test.go | 8 +-- doc/party_test.go | 8 +-- document.go | 2 +- examples_test.go | 3 + internal/gateways/gateways.go | 1 - test/data/cred-note-base.json | 2 +- verifactu.go | 4 +- 16 files changed, 41 insertions(+), 162 deletions(-) delete mode 100644 cmd/gobl.verifactu/sendtest.go diff --git a/cancel.go b/cancel.go index 3d5b8be..3d1117e 100644 --- a/cancel.go +++ b/cancel.go @@ -29,7 +29,7 @@ func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { } // Create the document - cd, err := doc.NewDocument(inv, ts, c.issuerRole, c.software, true) + cd, err := doc.NewVerifactu(inv, ts, c.issuerRole, c.software, true) if err != nil { return nil, err } @@ -38,9 +38,7 @@ func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { } // FingerprintCancel generates a fingerprint for the cancellation document using the -// data provided from the previous chain data. If there was no previous -// document in the chain, the parameter should be nil. The document is updated -// in place. +// data provided from the previous chain data. The document is updated in place. func (c *Client) FingerprintCancel(d *doc.VeriFactu, prev *doc.ChainData) error { return d.FingerprintCancel(prev) } diff --git a/cmd/gobl.verifactu/cancel.go b/cmd/gobl.verifactu/cancel.go index 250ef49..58b10bc 100644 --- a/cmd/gobl.verifactu/cancel.go +++ b/cmd/gobl.verifactu/cancel.go @@ -67,7 +67,7 @@ func (c *cancelOpts) runE(cmd *cobra.Command, args []string) error { if c.production { opts = append(opts, verifactu.InProduction()) } else { - opts = append(opts, verifactu.InTesting()) + opts = append(opts, verifactu.InSandbox()) } tc, err := verifactu.New(c.software(), opts...) diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 3664089..046ac4a 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -35,7 +35,6 @@ func (o *rootOpts) cmd() *cobra.Command { cmd.AddCommand(versionCmd()) cmd.AddCommand(send(o).cmd()) - cmd.AddCommand(sendTest(o).cmd()) cmd.AddCommand(convert(o).cmd()) cmd.AddCommand(cancel(o).cmd()) @@ -64,7 +63,7 @@ func (o *rootOpts) software() *doc.Software { NombreSistemaInformatico: o.swName, TipoUsoPosibleSoloVerifactu: "S", TipoUsoPosibleMultiOT: "S", - IndicadorMultiplesOT: "S", + IndicadorMultiplesOT: "N", } } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index 734fe06..4e16392 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -9,6 +9,7 @@ import ( "github.com/invopop/gobl" verifactu "github.com/invopop/gobl.verifactu" "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/xmldsig" "github.com/spf13/cobra" ) @@ -53,14 +54,19 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("unmarshaling gobl envelope: %w", err) } + cert, err := xmldsig.LoadCertificate(c.cert, c.password) + if err != nil { + return err + } + opts := []verifactu.Option{ - verifactu.WithThirdPartyIssuer(), + verifactu.WithCertificate(cert), } if c.production { opts = append(opts, verifactu.InProduction()) } else { - opts = append(opts, verifactu.InTesting()) + opts = append(opts, verifactu.InSandbox()) } tc, err := verifactu.New(c.software(), opts...) diff --git a/cmd/gobl.verifactu/sendtest.go b/cmd/gobl.verifactu/sendtest.go deleted file mode 100644 index 8b2069f..0000000 --- a/cmd/gobl.verifactu/sendtest.go +++ /dev/null @@ -1,126 +0,0 @@ -// Package main provides the command line interface to the VeriFactu package. -package main - -import ( - "bytes" - "encoding/json" - "fmt" - - "github.com/invopop/gobl" - verifactu "github.com/invopop/gobl.verifactu" - "github.com/invopop/gobl.verifactu/doc" - "github.com/invopop/xmldsig" - "github.com/spf13/cobra" -) - -type sendTestOpts struct { - *rootOpts - previous string -} - -func sendTest(o *rootOpts) *sendTestOpts { - return &sendTestOpts{rootOpts: o} -} - -func (c *sendTestOpts) cmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "sendTest [infile]", - Short: "Sends the GOBL to the VeriFactu service", - RunE: c.runE, - } - - f := cmd.Flags() - c.prepareFlags(f) - - f.StringVar(&c.previous, "prev", "", "Previous document fingerprint to chain with") - - return cmd -} - -func (c *sendTestOpts) runE(cmd *cobra.Command, args []string) error { - input, err := openInput(cmd, args) - if err != nil { - return err - } - defer input.Close() // nolint:errcheck - - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(input); err != nil { - return fmt.Errorf("reading input: %w", err) - } - - env := new(gobl.Envelope) - if err := json.Unmarshal(buf.Bytes(), env); err != nil { - return fmt.Errorf("unmarshaling gobl envelope: %w", err) - } - - cert, err := xmldsig.LoadCertificate(c.cert, c.password) - if err != nil { - return err - } - - opts := []verifactu.Option{ - verifactu.WithCertificate(cert), - verifactu.WithSupplierIssuer(), - verifactu.InTesting(), - } - - tc, err := verifactu.New(c.software(), opts...) - if err != nil { - return err - } - - td, err := tc.Convert(env) - if err != nil { - return err - } - - c.previous = `{ - "emisor": "B85905495", - "serie": "SAMPLE-011", - "fecha": "21-11-2024", - "huella": "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C" - }` - - prev := new(doc.ChainData) - if err := json.Unmarshal([]byte(c.previous), prev); err != nil { - return err - } - - err = tc.Fingerprint(td, prev) - if err != nil { - return err - } - - if err := tc.AddQR(td, env); err != nil { - return err - } - - out, err := c.openOutput(cmd, args) - if err != nil { - return err - } - defer out.Close() // nolint:errcheck - - convOut, err := td.BytesIndent() - if err != nil { - return fmt.Errorf("generating verifactu xml: %w", err) - } - if _, err = out.Write(append(convOut, '\n')); err != nil { - return fmt.Errorf("writing verifactu xml: %w", err) - } - - err = tc.Post(cmd.Context(), td) - if err != nil { - return err - } - fmt.Println("made it!") - - data, err := json.Marshal(td.ChainData()) - if err != nil { - return err - } - fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) - - return nil -} diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index f69ab47..5993f0b 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -20,7 +20,7 @@ func TestBreakdownConversion(t *testing.T) { t.Run("basic-invoice", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") _ = inv.Calculate() - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 1800.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) @@ -50,7 +50,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) @@ -92,7 +92,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 21.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -125,7 +125,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 0.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -151,7 +151,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - _, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + _, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.Error(t, err) }) @@ -175,7 +175,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 21.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -206,7 +206,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 10.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) @@ -236,7 +236,7 @@ func TestBreakdownConversion(t *testing.T) { }, } _ = inv.Calculate() - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) assert.Equal(t, 1000.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) diff --git a/doc/cancel_test.go b/doc/cancel_test.go index 0d0c091..a0ab11a 100644 --- a/doc/cancel_test.go +++ b/doc/cancel_test.go @@ -15,7 +15,7 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("basic", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleSupplier, nil, true) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, true) require.NoError(t, err) reg := d.RegistroFactura.RegistroAnulacion @@ -33,7 +33,7 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("customer issuer", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleCustomer, nil, true) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleCustomer, nil, true) require.NoError(t, err) reg := d.RegistroFactura.RegistroAnulacion @@ -51,7 +51,7 @@ func TestNewRegistroAnulacion(t *testing.T) { t.Run("third party issuer", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - d, err := doc.NewDocument(inv, time.Now(), doc.IssuerRoleThirdParty, nil, true) + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleThirdParty, nil, true) require.NoError(t, err) reg := d.RegistroFactura.RegistroAnulacion diff --git a/doc/doc.go b/doc/doc.go index ff03cf9..2f3e5f6 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -52,8 +52,8 @@ func init() { } } -// NewDocument creates a new VeriFactu document -func NewDocument(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c bool) (*VeriFactu, error) { +// NewVerifactu creates a new VeriFactu document +func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c bool) (*VeriFactu, error) { doc := &VeriFactu{ Cabecera: &Cabecera{ Obligado: Obligado{ diff --git a/doc/doc_test.go b/doc/doc_test.go index f198955..39fdfdf 100644 --- a/doc/doc_test.go +++ b/doc/doc_test.go @@ -18,7 +18,7 @@ func TestInvoiceConversion(t *testing.T) { t.Run("should contain basic document info", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - doc, err := doc.NewDocument(inv, ts, role, sw, false) + doc, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) assert.Equal(t, "Invopop S.L.", doc.Cabecera.Obligado.NombreRazon) diff --git a/doc/invoice_test.go b/doc/invoice_test.go index be77948..a88275e 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -22,7 +22,7 @@ func TestNewRegistroAlta(t *testing.T) { t.Run("should contain basic document info", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - d, err := doc.NewDocument(inv, ts, role, sw, false) + d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) reg := d.RegistroFactura.RegistroAlta @@ -55,7 +55,7 @@ func TestNewRegistroAlta(t *testing.T) { inv.SetTags(tax.TagSimplified) inv.Customer = nil - d, err := doc.NewDocument(inv, ts, role, sw, false) + d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) assert.Equal(t, "S", d.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) @@ -64,7 +64,7 @@ func TestNewRegistroAlta(t *testing.T) { t.Run("should handle rectificative invoices", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") - d, err := doc.NewDocument(inv, ts, role, sw, false) + d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) reg := d.RegistroFactura.RegistroAlta @@ -89,7 +89,7 @@ func TestNewRegistroAlta(t *testing.T) { }, } - d, err := doc.NewDocument(inv, ts, role, sw, false) + d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) reg := d.RegistroFactura.RegistroAlta diff --git a/doc/party_test.go b/doc/party_test.go index 719960f..0a08c22 100644 --- a/doc/party_test.go +++ b/doc/party_test.go @@ -19,7 +19,7 @@ func TestNewParty(t *testing.T) { sw := &doc.Software{} t.Run("with tax ID", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - d, err := doc.NewDocument(inv, ts, role, sw, false) + d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario @@ -40,7 +40,7 @@ func TestNewParty(t *testing.T) { }, } - d, err := doc.NewDocument(inv, ts, role, sw, false) + d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario @@ -61,7 +61,7 @@ func TestNewParty(t *testing.T) { }, } - d, err := doc.NewDocument(inv, ts, role, sw, false) + d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario @@ -79,7 +79,7 @@ func TestNewParty(t *testing.T) { Name: "Simple Company", } - _, err := doc.NewDocument(inv, ts, role, sw, false) + _, err := doc.NewVerifactu(inv, ts, role, sw, false) require.Error(t, err) }) } diff --git a/document.go b/document.go index a2f62a0..970196f 100644 --- a/document.go +++ b/document.go @@ -27,7 +27,7 @@ func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { return nil, errors.New("only spanish invoices are supported") } - out, err := doc.NewDocument(inv, c.CurrentTime(), c.issuerRole, c.software, false) + out, err := doc.NewVerifactu(inv, c.CurrentTime(), c.issuerRole, c.software, false) if err != nil { return nil, err } diff --git a/examples_test.go b/examples_test.go index 33d0d8a..304928d 100644 --- a/examples_test.go +++ b/examples_test.go @@ -155,6 +155,9 @@ func validateDoc(schema *xsd.Schema, doc []byte) []error { func addNamespaces(data []byte) ([]byte, error) { xmlString := string(data) xmlNamespaces := ` xmlns:sum="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd"` + if !strings.Contains(xmlString, "") { + return nil, fmt.Errorf("could not find RegFactuSistemaFacturacion tag in XML") + } xmlString = strings.Replace(xmlString, "", "", 1) finalXMLBytes := []byte(xmlString) return finalXMLBytes, nil diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index ad2bc92..e67658b 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -86,7 +86,6 @@ func (c *Connection) post(ctx context.Context, path string, payload []byte) erro e1 := out.Body.Respuesta.RespuestaLinea[0] err = err.withMessage(e1.DescripcionErrorRegistro).withCode(e1.CodigoErrorRegistro) } - fmt.Println(out.Body.Respuesta) return err } diff --git a/test/data/cred-note-base.json b/test/data/cred-note-base.json index 63dcdb1..eff8163 100644 --- a/test/data/cred-note-base.json +++ b/test/data/cred-note-base.json @@ -36,7 +36,7 @@ "name": "Provide One S.L.", "tax_id": { "country": "ES", - "code": "B98602642" + "code": "B85905495" }, "addresses": [ { diff --git a/verifactu.go b/verifactu.go index 1d3ca57..54f9745 100644 --- a/verifactu.go +++ b/verifactu.go @@ -119,8 +119,8 @@ func InProduction() Option { } } -// InTesting defines the connection to use the testing environment. -func InTesting() Option { +// InSandbox defines the connection to use the testing environment. +func InSandbox() Option { return func(c *Client) { c.env = gateways.EnvironmentSandbox } From dc1ed4560b3d4d215047443f38fc77ca0c703b30 Mon Sep 17 00:00:00 2001 From: apardods Date: Wed, 27 Nov 2024 19:04:12 +0000 Subject: [PATCH 27/56] Remove Generador from Cancel --- cancel.go | 9 +----- doc/cancel.go | 24 +-------------- doc/cancel_test.go | 51 ++++---------------------------- doc/doc.go | 2 +- doc/invoice_test.go | 2 +- test/data/cred-note-base.json | 2 +- test/data/out/cred-note-base.xml | 10 +++---- 7 files changed, 15 insertions(+), 85 deletions(-) diff --git a/cancel.go b/cancel.go index 3d1117e..2400972 100644 --- a/cancel.go +++ b/cancel.go @@ -2,7 +2,6 @@ package verifactu import ( "errors" - "time" "github.com/invopop/gobl" "github.com/invopop/gobl.verifactu/doc" @@ -21,15 +20,9 @@ func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { if inv.Supplier.TaxID.Country != l10n.ES.Tax() { return nil, errors.New("only spanish invoices are supported") } - // Extract the time when the invoice was posted to TicketBAI gateway - // ts, err := extractPostTime(env) - ts, err := time.Parse("2006-01-02", inv.IssueDate.String()) // REVISAR - if err != nil { - return nil, err - } // Create the document - cd, err := doc.NewVerifactu(inv, ts, c.issuerRole, c.software, true) + cd, err := doc.NewVerifactu(inv, c.CurrentTime(), c.issuerRole, c.software, true) if err != nil { return nil, err } diff --git a/doc/cancel.go b/doc/cancel.go index b814677..61d49d0 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -7,7 +7,7 @@ import ( ) // NewRegistroAnulacion provides support for credit notes -func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAnulacion, error) { +func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, s *Software) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ IDVersion: CurrentVersion, IDFactura: &IDFacturaAnulada{ @@ -15,10 +15,6 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Soft NumSerieFactura: invoiceNumber(inv.Series, inv.Code), FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), }, - // SinRegistroPrevio: "N", // TODO: Think what to do with this field - // RechazoPrevio: "N", // TODO: Think what to do with this field - GeneradoPor: string(r), - Generador: makeGenerador(inv, r), SistemaInformatico: s, FechaHoraHusoGenRegistro: formatDateTimeZone(ts), TipoHuella: TipoHuella, @@ -27,21 +23,3 @@ func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Soft return reg, nil } - -func makeGenerador(inv *bill.Invoice, r IssuerRole) *Party { - switch r { - case IssuerRoleSupplier, IssuerRoleThirdParty: - p, err := newParty(inv.Supplier) - if err != nil { - return nil - } - return p - case IssuerRoleCustomer: - p, err := newParty(inv.Customer) - if err != nil { - return nil - } - return p - } - return nil -} diff --git a/doc/cancel_test.go b/doc/cancel_test.go index a0ab11a..6dbc5c7 100644 --- a/doc/cancel_test.go +++ b/doc/cancel_test.go @@ -18,52 +18,11 @@ func TestNewRegistroAnulacion(t *testing.T) { d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, true) require.NoError(t, err) - reg := d.RegistroFactura.RegistroAnulacion + ra := d.RegistroFactura.RegistroAnulacion + assert.Equal(t, "B85905495", ra.IDFactura.IDEmisorFactura) + assert.Equal(t, "FR-012", ra.IDFactura.NumSerieFactura) + assert.Equal(t, "01-02-2022", ra.IDFactura.FechaExpedicionFactura) + assert.Equal(t, "01", ra.TipoHuella) - assert.Equal(t, "E", reg.GeneradoPor) - assert.NotNil(t, reg.Generador) - assert.Equal(t, "Provide One S.L.", reg.Generador.NombreRazon) - assert.Equal(t, "B98602642", reg.Generador.NIF) - - data, err := d.BytesIndent() - require.NoError(t, err) - assert.NotEmpty(t, data) - }) - - t.Run("customer issuer", func(t *testing.T) { - inv := test.LoadInvoice("cred-note-base.json") - - d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleCustomer, nil, true) - require.NoError(t, err) - - reg := d.RegistroFactura.RegistroAnulacion - - assert.Equal(t, "D", reg.GeneradoPor) - assert.NotNil(t, reg.Generador) - assert.Equal(t, "Sample Customer", reg.Generador.NombreRazon) - assert.Equal(t, "54387763P", reg.Generador.NIF) - - data, err := d.BytesIndent() - require.NoError(t, err) - assert.NotEmpty(t, data) }) - - t.Run("third party issuer", func(t *testing.T) { - inv := test.LoadInvoice("cred-note-base.json") - - d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleThirdParty, nil, true) - require.NoError(t, err) - - reg := d.RegistroFactura.RegistroAnulacion - - assert.Equal(t, "T", reg.GeneradoPor) - assert.NotNil(t, reg.Generador) - assert.Equal(t, "Provide One S.L.", reg.Generador.NombreRazon) - assert.Equal(t, "B98602642", reg.Generador.NIF) - - data, err := d.BytesIndent() - require.NoError(t, err) - assert.NotEmpty(t, data) - }) - } diff --git a/doc/doc.go b/doc/doc.go index 2f3e5f6..19acede 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -65,7 +65,7 @@ func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c } if c { - reg, err := NewRegistroAnulacion(inv, ts, r, s) + reg, err := NewRegistroAnulacion(inv, ts, s) if err != nil { return nil, err } diff --git a/doc/invoice_test.go b/doc/invoice_test.go index a88275e..a281b2f 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -73,7 +73,7 @@ func TestNewRegistroAlta(t *testing.T) { require.Len(t, reg.FacturasRectificadas, 1) rectified := reg.FacturasRectificadas[0] - assert.Equal(t, "B98602642", rectified.IDFactura.IDEmisorFactura) + assert.Equal(t, "B85905495", rectified.IDFactura.IDEmisorFactura) assert.Equal(t, "SAMPLE-085", rectified.IDFactura.NumSerieFactura) assert.Equal(t, "10-01-2022", rectified.IDFactura.FechaExpedicionFactura) }) diff --git a/test/data/cred-note-base.json b/test/data/cred-note-base.json index eff8163..15cbcaa 100644 --- a/test/data/cred-note-base.json +++ b/test/data/cred-note-base.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "f6d40829a5535964e19c4ff27601578633ea48f670fd25c9a2c95d3c0740e8e5" + "val": "b76e4c256c9c0e593545bbc31c13714d736c533a9ad19bcab83902d78cd95332" } }, "doc": { diff --git a/test/data/out/cred-note-base.xml b/test/data/out/cred-note-base.xml index 59892ac..f157a76 100755 --- a/test/data/out/cred-note-base.xml +++ b/test/data/out/cred-note-base.xml @@ -4,14 +4,14 @@ Provide One S.L. - B98602642 + B85905495 1.0 - B98602642 + B85905495 FR-012 01-02-2022 @@ -20,7 +20,7 @@ I - B98602642 + B85905495 SAMPLE-085 10-01-2022 @@ -29,7 +29,7 @@ T Provide One S.L. - B98602642 + B85905495 @@ -76,7 +76,7 @@ 2024-11-26T05:00:00+01:00 01 - E5DFED6D2E3C1BF8C47EF57823FFF15CC8CB638CE95FDB235DB2CA9BC51FE9F5 + 1FA6A85768A46410EE4D9A5E53A12DADBF5C75F75E2D13C27557C3C4B073F606 From 48044e96aa13752d425e3fbb96d0d6b2880b3dab Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 28 Nov 2024 11:50:32 +0000 Subject: [PATCH 28/56] Update Fingerprint & README --- README.md | 151 ++++++++++++++---------------- cancel.go | 2 +- cmd/gobl.verifactu/cancel.go | 13 --- cmd/gobl.verifactu/send.go | 17 ---- doc/cancel.go | 2 +- doc/cancel_test.go | 2 - doc/fingerprint.go | 7 +- doc/fingerprint_test.go | 6 +- examples_test.go | 9 +- prev.json | 2 +- test/data/out/cred-note-base.xml | 10 +- test/data/out/inv-base.xml | 10 +- test/data/out/inv-eqv-sur-b2c.xml | 10 +- test/data/out/inv-eqv-sur.xml | 10 +- test/data/out/inv-rev-charge.xml | 10 +- test/data/out/inv-zero-tax.xml | 10 +- 16 files changed, 114 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 1c0e7ea..500fade 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ Copyright [Invopop Ltd.](https://invopop.com) 2023. Released publicly under the ## Source -The main resources for Veri*Factu can be found in the AEAT website and include: +The main resources used in this module include: - [Veri*Factu documentation](https://www.agenciatributaria.es/AEAT.desarrolladores/Desarrolladores/_menu_/Documentacion/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU.html) - [Veri*Factu Ministerial Order](https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138) +- [Spanish VAT Law](https://www.boe.es/buscar/act.php?id=BOE-A-1992-28740). ## Usage @@ -38,14 +39,13 @@ func main() { // Load sample envelope: data, _ := os.ReadFile("./test/data/sample-invoice.json") - env := new(gobl.Envelope) if err := json.Unmarshal(data, env); err != nil { panic(err) } - // Prepare software configuration: - soft := &verifactu.Software{ + // VeriFactu requires a software definition to be provided. This is an example + software := &verifactu.Software{ NombreRazon: "Company LTD", // Company Name NIF: "B123456789", // Software company's tax code NombreSistemaInformatico: "Software Name", // Name of application @@ -55,82 +55,67 @@ func main() { } // Load the certificate - cert, err := xmldsig.LoadCertificate(c.cert, c.password) + cert, err := xmldsig.LoadCertificate( + "./path/to/certificate.p12", + "password", + ) if err != nil { - return err + panic(err) } // Create the client with the software and certificate opts := []verifactu.Option{ verifactu.WithCertificate(cert), - verifactu.WithSupplierIssuer(), // The issuer can be either the supplier, the - verifactu.InTesting(), + verifactu.WithSupplierIssuer(), // The issuer can be either the supplier, the customer or a third party + verifactu.InTesting(), // Use the testing environment, as the production endpoint is not yet published } - - tc, err := verifactu.New(c.software(), opts...) + tc, err := verifactu.New(software, opts...) if err != nil { - return err + panic(err) } // Convert the GOBL envelope to a Veri*Factu document td, err := tc.Convert(env) if err != nil { - return err + panic(err) } // Prepare the previous document chain data - c.previous = `{ - "emisor": "B85905495", - "serie": "SAMPLE-001", - "fecha": "11-11-2024", - "huella": "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C" - }` prev := new(doc.ChainData) if err := json.Unmarshal([]byte(c.previous), prev); err != nil { - return err + panic(err) } // Create the document fingerprint based on the previous document chain err = tc.Fingerprint(td, prev) if err != nil { - return err + panic(err) } // Add the QR code to the document if err := tc.AddQR(td, env); err != nil { - return err - } - - out, err := c.openOutput(cmd, args) - if err != nil { - return err - } - defer out.Close() // nolint:errcheck - - convOut, err := td.BytesIndent() - if err != nil { - return fmt.Errorf("generating verifactu xml: %w", err) + panic(err) } - - err = tc.Post(cmd.Context(), td) + // Send the document to the tax agency + err = tc.Post(ctx, td) if err != nil { - return err + panic(err) } - data, err := json.Marshal(td.ChainData()) + // Print the data to be used as previous document chain for the next invoice + // Persist the data somewhere to be used by the next invoice + cd, err := json.Marshal(td.ChainData()) if err != nil { - return err + panic(err) } - fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) - - return nil + fmt.Printf("Generated document with fingerprint: \n%s\n", string(cd)) } ``` -## Command Line +### Command Line The GOBL Veri*Factu package tool also includes a command line helper. You can install manually in your Go environment with: @@ -148,68 +133,60 @@ SOFTWARE_VERSION=1.0 SOFTWARE_ID_SISTEMA_INFORMATICO=A1 SOFTWARE_NUMERO_INSTALACION=00001 -CERTIFICATE_PATH=./xxxxxxxxx.p12 -CERTIFICATE_PASSWORD=xxxxxxxx - +CERTIFICATE_PATH=xxxxxxxxx +CERTIFICATE_PASSWORD=xxxxxxxxx ``` - To convert a document to XML, run: ```bash gobl.verifactu convert ./test/data/sample-invoice.json ``` +This function will output the XML to the terminal, or to a file if a second argument is provided. The output file will not include a fingerprint, and therefore will not be able to be submitted to the tax agency. To submit to the tax agency testing environment: ```bash -gobl.verifactu send ./test/data/sample-invoice.json +gobl.verifactu send ./test/data/sample-invoice.json ./test/data/previous-invoice-info.json +``` +Now, the output file will include a fingerprint, linked to the previous document, and will be submitted to the tax agency. An example for a previous file would look like this: + +```json +{ + "emisor": "B12345678", + "serie": "SAMPLE-001", + "fecha": "2024-11-28", + "huella": "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" +} ``` -## Limitations - -- Veri*Factu allows more than one customer per invoice, but GOBL only has one possible customer. - -- Invoices should have a note of type general that will be used as a general description of the invoice. If an invoice is missing this info, it will be rejected with an error. - -- Currently VeriFactu supportts sending more than one invoice at a time (up to 1000). However, this module only currently supports 1 invoice at a time. - -- VeriFactu supports the following rectification types: - - Factura Recitificativa por Diferencias - - Factura Recitificativa por Sustitución - - Factura en Sustitución de Facturas Simplificadas - - Registro de Anulación - - - -## Tags, Keys and Extensions +## Tags and Extensions In order to provide the supplier specific data required by Veri*Factu, invoices need to include a bit of extra data. We've managed to simplify these into specific cases. -### Tax Tags +### Invoice Tags Invoice tax tags can be added to invoice documents in order to reflect a special situation. The following schemes are supported: -- `simplified-scheme` - a retailer operating under a simplified tax regime (regimen simplificado) that must indicate that all of their sales are under this scheme. This implies that all operations in the invoice will have the `FacturaSinIdentifDestinatarioArt61d` tag set to `S`. -- `reverse-charge` - B2B services or goods sold to a tax registered EU member who will pay VAT on the suppliers behalf. Implies that all items will be classified under the `TipoNoExenta` value of `S2`. +- `simplified` - a retailer operating under a simplified tax regime (regimen simplificado) that must indicate that all of their sales are under this scheme. This implies that all operations in the invoice will have the `FacturaSinIdentifDestinatarioArt61d` tag set to `S` and the `TipoFactura` field set to `F2` in case of a regular invoice and `R5` in case of a corrective invoice. +- `substitution` - A simplified invoice that is being replaced by a standard invoice. Called a `Factura en Sustitución de Facturas Simplificadas` in Veri*Factu. -## Tax Extensions +### Tax Extensions -The following extension can be applied to each line tax: - -- `es-verifactu-doc-type` – defines the type of invoice being sent. In most cases this will be set automatically by the GOBL add-on. These are the valid values: +The following extensions must be added to the document: +- `es-verifactu-doc-type` – defines the type of invoice being sent. In most cases this will be set automatically by GOBL, but it must be present. These are the valid values: - `F1` - Standard invoice. - `F2` - Simplified invoice. - `F3` - Invoice in substitution of simplified invoices. - - `R1` - Rectified invoice based on law and Article 80.1, 80.2 and 80.6 in the Spanish VAT Law ([LIVA](https://www.boe.es/buscar/act.php?id=BOE-A-1992-28740)). + - `R1` - Rectified invoice based on law and Article 80.1, 80.2 and 80.6 in the Spanish VAT Law. - `R2` - Rectified invoice based on law and Article 80.3. - `R3` - Rectified invoice based on law and Article 80.4. - `R4` - Rectified invoice based on law and other reasons. - `R5` - Rectified invoice based on simplified invoices. -- `es-verifactu-tax-classification` - combines the tax classification and exemption codes used in Veri*Factu. These are the valid values: - +- `es-verifactu-tax-classification` - combines the tax classification and exemption codes used in Veri*Factu. Must be included in each line item, or an error will be raised. These are the valid values: - `S1` - Subject and not exempt - Without reverse charge - - `S2` - Subject and not exempt - With reverse charge + - `S2` - Subject and not exempt - With reverse charge. Known as `Inversión del Sujeto Pasivo` in Spanish VAT Law - `N1` - Not subject - Articles 7, 14, others - `N2` - Not subject - Due to location rules - `E1` - Exempt pursuant to Article 20 of the VAT Law @@ -219,19 +196,29 @@ The following extension can be applied to each line tax: - `E5` - Exempt pursuant to Article 25 of the VAT Law - `E6` - Exempt for other reasons +As a small consideration GOBL's tax internal tax framework differentiates between `exempt` and `zero-rated` taxes. In Veri*Factu, GOBL `zero-rated` taxes refer to `Exenciones` (values `E1` to `E6` in the list above) and `exempt` taxes refer to `No Sujeto` (values `N1` and `N2` in the list above). -### Use-Cases +### Example -Under what situations should the Veri*Factu system be expected to function: +An example of a -- B2B & B2C: regular national invoice with VAT. Operation with minimal data. -- B2B Provider to Retailer: Include equalisation surcharge VAT rates -- B2B Retailer: Same as regular invoice, except with invoice lines that include `ext[es-tbai-product] = resale` when the goods being provided are being sold without modification (recargo de equivalencia), very much related to the next point. -- B2B Retailer Simplified: Include the simplified scheme key. (This implies that the `OperacionEnRecargoDeEquivalenciaORegimenSimplificado` tag will be set to `S`). -- EU B2B: Reverse charge EU export, scheme: reverse-charge taxes calculated, but not applied to totals. By default all line items assumed to be services. Individual lines can use the `ext[es-tbai-product] = goods` value to identify when the line is a physical good. Operations like this are normally assigned the TipoNoExenta value of S2. If however the service or goods are exempt of tax, each line's tax `ext[exempt]` field can be used to identify a reason. -- EU B2C Digital Goods: use tax tag `customer-rates`, that applies VAT according to customer location. In TicketBAI, these cases are "not subject" to tax, and thus should have the cause N2 (por reglas de localización). +## Limitations + +- Veri*Factu allows more than one customer per invoice, but GOBL only has one possible customer. + +- Invoices must have a note of type general that will be used as a general description of the invoice. If an invoice is missing this info, it will be rejected with an error. + +- VeriFactu supports sending more than one invoice at a time (up to 1000). However, this module only currently supports 1 invoice at a time. -## Test Data +- VeriFactu requires a valid certificate to be provided, even when using the testing environment. It is the same certificate needed to access the AEAT's portal. + +## Testing + +This library includes a set of tests that can be used to validate the conversion and submission process. To run the tests, use the following command: + +```bash +go test +``` Some sample test data is available in the `./test` directory. To update the JSON documents and regenerate the XML files for testing, use the following command: diff --git a/cancel.go b/cancel.go index 2400972..afde320 100644 --- a/cancel.go +++ b/cancel.go @@ -9,7 +9,7 @@ import ( "github.com/invopop/gobl/l10n" ) -// GenerateCancel creates a new AnulaTicketBAI document from the provided +// GenerateCancel creates a new cancellation document from the provided // GOBL Envelope. func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { // Extract the Invoice diff --git a/cmd/gobl.verifactu/cancel.go b/cmd/gobl.verifactu/cancel.go index 58b10bc..cec769e 100644 --- a/cmd/gobl.verifactu/cancel.go +++ b/cmd/gobl.verifactu/cancel.go @@ -93,19 +93,6 @@ func (c *cancelOpts) runE(cmd *cobra.Command, args []string) error { return err } - out, err := c.openOutput(cmd, args) - if err != nil { - return err - } - defer out.Close() // nolint:errcheck - convOut, err := td.BytesIndent() - if err != nil { - return fmt.Errorf("generating verifactu xml: %w", err) - } - if _, err = out.Write(append(convOut, '\n')); err != nil { - return fmt.Errorf("writing verifactu xml: %w", err) - } - err = tc.Post(cmd.Context(), td) if err != nil { return err diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index 4e16392..19d874f 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -107,22 +107,5 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { } fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) - // TEMP - - out, err := c.openOutput(cmd, args) - if err != nil { - return err - } - defer out.Close() // nolint:errcheck - - convOut, err := td.BytesIndent() - if err != nil { - return fmt.Errorf("generating verifactu xml: %w", err) - } - - if _, err = out.Write(append(convOut, '\n')); err != nil { - return fmt.Errorf("writing verifactu xml: %w", err) - } - return nil } diff --git a/doc/cancel.go b/doc/cancel.go index 61d49d0..c512245 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -6,7 +6,7 @@ import ( "github.com/invopop/gobl/bill" ) -// NewRegistroAnulacion provides support for credit notes +// NewRegistroAnulacion provides support for cancelling invoices func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, s *Software) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ IDVersion: CurrentVersion, diff --git a/doc/cancel_test.go b/doc/cancel_test.go index 6dbc5c7..8e001bb 100644 --- a/doc/cancel_test.go +++ b/doc/cancel_test.go @@ -11,7 +11,6 @@ import ( ) func TestNewRegistroAnulacion(t *testing.T) { - t.Run("basic", func(t *testing.T) { inv := test.LoadInvoice("cred-note-base.json") @@ -23,6 +22,5 @@ func TestNewRegistroAnulacion(t *testing.T) { assert.Equal(t, "FR-012", ra.IDFactura.NumSerieFactura) assert.Equal(t, "01-02-2022", ra.IDFactura.FechaExpedicionFactura) assert.Equal(t, "01", ra.TipoHuella) - }) } diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 2d6d334..da804eb 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -8,6 +8,7 @@ import ( ) // TipoHuella is the SHA-256 fingerprint type for Verifactu - L12 +// Might include support for other encryption types in the future. const TipoHuella = "01" // ChainData contains the fields of this invoice that will be @@ -63,9 +64,9 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { h = inv.Encadenamiento.RegistroAnterior.Huella } f := []string{ - FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), - FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), - FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), + FormatField("IDEmisorFacturaAnulada", inv.IDFactura.IDEmisorFactura), + FormatField("NumSerieFacturaAnulada", inv.IDFactura.NumSerieFactura), + FormatField("FechaExpedicionFacturaAnulada", inv.IDFactura.FechaExpedicionFactura), FormatField("Huella", h), FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go index 3ee832b..c3b35ca 100644 --- a/doc/fingerprint_test.go +++ b/doc/fingerprint_test.go @@ -143,7 +143,7 @@ func TestFingerprintAnulacion(t *testing.T) { FechaExpedicionFactura: "baz", Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, - expected: "BAB9B4AE157321642F6AFD8030288B7E595129B29A00A69CEB308CEAA53BFBD7", + expected: "F5AB85A94450DF8752F4A7840C72456B753010E5EC1F26D8EAE0D4523E287948", }, { name: "Basic 2", @@ -161,7 +161,7 @@ func TestFingerprintAnulacion(t *testing.T) { FechaExpedicionFactura: "baz", Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", }, - expected: "548707E0984AA867CC173B24389E648DECDEE48A2674DA8CE8A3682EF8F119DD", + expected: "E86A5172477A636958B2F98770FB796BEEDA43F3F1C6A1C601EC3EEDF9C033B1", }, { name: "No Previous", @@ -175,7 +175,7 @@ func TestFingerprintAnulacion(t *testing.T) { Encadenamiento: &doc.Encadenamiento{PrimerRegistro: "S"}, }, prev: nil, - expected: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", + expected: "A166B0391BCE34DA3A5B022837D0C426F7A4E2F795EBB4581B7BD79E74BCAA95", }, } diff --git a/examples_test.go b/examples_test.go index 304928d..f861fed 100644 --- a/examples_test.go +++ b/examples_test.go @@ -41,11 +41,12 @@ func TestXMLGeneration(t *testing.T) { td, err := c.Convert(env) require.NoError(t, err) + // Example Data to Test the Fingerprint. prev := &doc.ChainData{ - IDEmisorFactura: "B85905495", - NumSerieFactura: "SAMPLE-003", - FechaExpedicionFactura: "13-11-2024", - Huella: "E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371", + IDEmisorFactura: "B12345678", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "26-11-2024", + Huella: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", } err = c.Fingerprint(td, prev) diff --git a/prev.json b/prev.json index 6e3d408..f936530 100644 --- a/prev.json +++ b/prev.json @@ -1,7 +1,7 @@ { "emisor": "B123456789", "serie": "FACT-001", - "fecha": "2024-11-11", + "fecha": "2024-11-15", "huella": "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" } diff --git a/test/data/out/cred-note-base.xml b/test/data/out/cred-note-base.xml index f157a76..af2120d 100755 --- a/test/data/out/cred-note-base.xml +++ b/test/data/out/cred-note-base.xml @@ -57,10 +57,10 @@ 1970.2 - B85905495 - SAMPLE-003 - 13-11-2024 - E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + B12345678 + SAMPLE-001 + 26-11-2024 + 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF @@ -76,7 +76,7 @@ 2024-11-26T05:00:00+01:00 01 - 1FA6A85768A46410EE4D9A5E53A12DADBF5C75F75E2D13C27557C3C4B073F606 + AB1C4B1F38943CCDB33FC73DA9E4A85DD57EBC19A1706E8674A649A04D3BB3D3 diff --git a/test/data/out/inv-base.xml b/test/data/out/inv-base.xml index 22fe742..50bf52e 100755 --- a/test/data/out/inv-base.xml +++ b/test/data/out/inv-base.xml @@ -43,10 +43,10 @@ 2178 - B85905495 - SAMPLE-003 - 13-11-2024 - E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + B12345678 + SAMPLE-001 + 26-11-2024 + 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF @@ -62,7 +62,7 @@ 2024-11-26T05:00:00+01:00 01 - FDE0B32B6316FA31CCE8D2AD97118CC0B8FB4F9AEBBA8B66C016044B733FEE8E + E3BCF6A0DA47A13EB99A7F3C64D7F701AF9EBDDF9D4498D5805E59F8A2BBF3C9 diff --git a/test/data/out/inv-eqv-sur-b2c.xml b/test/data/out/inv-eqv-sur-b2c.xml index 9194261..16c5acc 100644 --- a/test/data/out/inv-eqv-sur-b2c.xml +++ b/test/data/out/inv-eqv-sur-b2c.xml @@ -40,10 +40,10 @@ 2178 - B85905495 - SAMPLE-003 - 13-11-2024 - E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + B12345678 + SAMPLE-001 + 26-11-2024 + 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF @@ -59,7 +59,7 @@ 2024-11-26T05:00:00+01:00 01 - AD2FA611FACE79B5077DF536BA1700636D08A39F243F141A5BF92927FBE69A18 + 8C446C65AD424BBFB8A8E7DC8B3DC331B7FFBBDD08C87D8DEAEAB82E6EBA921F diff --git a/test/data/out/inv-eqv-sur.xml b/test/data/out/inv-eqv-sur.xml index 4e5649f..dc4b095 100755 --- a/test/data/out/inv-eqv-sur.xml +++ b/test/data/out/inv-eqv-sur.xml @@ -45,10 +45,10 @@ 2178 - B85905495 - SAMPLE-003 - 13-11-2024 - E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + B12345678 + SAMPLE-001 + 26-11-2024 + 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF @@ -64,7 +64,7 @@ 2024-11-26T05:00:00+01:00 01 - AD2FA611FACE79B5077DF536BA1700636D08A39F243F141A5BF92927FBE69A18 + 8C446C65AD424BBFB8A8E7DC8B3DC331B7FFBBDD08C87D8DEAEAB82E6EBA921F diff --git a/test/data/out/inv-rev-charge.xml b/test/data/out/inv-rev-charge.xml index 7351d5d..71ede78 100644 --- a/test/data/out/inv-rev-charge.xml +++ b/test/data/out/inv-rev-charge.xml @@ -43,10 +43,10 @@ 2178 - B85905495 - SAMPLE-003 - 13-11-2024 - E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + B12345678 + SAMPLE-001 + 26-11-2024 + 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF @@ -62,7 +62,7 @@ 2024-11-26T05:00:00+01:00 01 - 1F342DA6DA6294B3A186EB14910C5A21C46125ACA5E067456372B671161C4C04 + E1DD8953C31CB41244215FAE1EB5ADCB9884856A652A612FEFFC3A6AF092CF72 diff --git a/test/data/out/inv-zero-tax.xml b/test/data/out/inv-zero-tax.xml index 47d5e36..003d48d 100644 --- a/test/data/out/inv-zero-tax.xml +++ b/test/data/out/inv-zero-tax.xml @@ -41,10 +41,10 @@ 1800 - B85905495 - SAMPLE-003 - 13-11-2024 - E7A3C41C5CA53E7B78A3F3A1E9BA2BB5C2BFDD63E1BF8E2E0B4178F3961B2371 + B12345678 + SAMPLE-001 + 26-11-2024 + 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF @@ -60,7 +60,7 @@ 2024-11-26T05:00:00+01:00 01 - A3CFDDB6236E2671AB29D134343420187146C3C92E5184F22A804DE1079FC7D3 + 332139FCB5DF23D65ABB3FB9B7B76D3E3C260DD5D4863B2E6A4E64FF10EE459E From 5ffe02dc516e44f61d91ed4a4cf4beb6d41964a0 Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 28 Nov 2024 14:15:06 +0000 Subject: [PATCH 29/56] Added Production Support for QR --- README.md | 35 +++++++++++++---------------- doc/doc.go | 4 ++-- doc/qr_code.go | 11 ++++----- doc/qr_code_test.go | 8 +++---- document.go | 3 ++- internal/gateways/errors.go | 3 --- prev.json => test/example_prev.json | 0 7 files changed, 29 insertions(+), 35 deletions(-) rename prev.json => test/example_prev.json (100%) diff --git a/README.md b/README.md index 500fade..0577117 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# GOBL to Veri*Factu +# GOBL to VeriFactu -Go library to convert [GOBL](https://github.com/invopop/gobl) invoices into Veri*Factu declarations and send them to the AEAT (Agencia Estatal de Administración Tributaria) web services. This library assumes that clients will handle a local database of previous invoices in order to comply with the local requirements of chaining all invoices together. +Go library to convert [GOBL](https://github.com/invopop/gobl) invoices into VeriFactu declarations and send them to the AEAT (Agencia Estatal de Administración Tributaria) web services. This library assumes that clients will handle a local database of previous invoices in order to comply with the local requirements of chaining all invoices together. Copyright [Invopop Ltd.](https://invopop.com) 2023. Released publicly under the [GNU Affero General Public License v3.0](LICENSE). For commercial licenses please contact the [dev team at invopop](mailto:dev@invopop.com). For contributions to this library to be accepted, we will require you to accept transferring your copyright to Invopop Ltd. @@ -8,8 +8,8 @@ Copyright [Invopop Ltd.](https://invopop.com) 2023. Released publicly under the The main resources used in this module include: -- [Veri*Factu documentation](https://www.agenciatributaria.es/AEAT.desarrolladores/Desarrolladores/_menu_/Documentacion/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU.html) -- [Veri*Factu Ministerial Order](https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138) +- [VeriFactu documentation](https://www.agenciatributaria.es/AEAT.desarrolladores/Desarrolladores/_menu_/Documentacion/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU/Sistemas_Informaticos_de_Facturacion_y_Sistemas_VERI_FACTU.html) +- [VeriFactu Ministerial Order](https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138) - [Spanish VAT Law](https://www.boe.es/buscar/act.php?id=BOE-A-1992-28740). ## Usage @@ -18,7 +18,7 @@ The main resources used in this module include: You must have first created a GOBL Envelope containing an Invoice that you'd like to send to the AEAT. For the document to be converted, the supplier contained in the invoice should have a "Tax ID" with the country set to `ES`. -The following is an example of how the GOBL Veri*Factu package could be used: +The following is an example of how the GOBL VeriFactu package could be used: ```go package main @@ -75,7 +75,7 @@ func main() { panic(err) } - // Convert the GOBL envelope to a Veri*Factu document + // Convert the GOBL envelope to a VeriFactu document td, err := tc.Convert(env) if err != nil { panic(err) @@ -117,7 +117,7 @@ func main() { ### Command Line -The GOBL Veri*Factu package tool also includes a command line helper. You can install manually in your Go environment with: +The GOBL VeriFactu package tool also includes a command line helper. You can install manually in your Go environment with: ```bash go install github.com/invopop/gobl.verifactu @@ -161,14 +161,14 @@ Now, the output file will include a fingerprint, linked to the previous document ## Tags and Extensions -In order to provide the supplier specific data required by Veri*Factu, invoices need to include a bit of extra data. We've managed to simplify these into specific cases. +In order to provide the supplier specific data required by VeriFactu, invoices need to include a bit of extra data. We've managed to simplify these into specific cases. ### Invoice Tags Invoice tax tags can be added to invoice documents in order to reflect a special situation. The following schemes are supported: - `simplified` - a retailer operating under a simplified tax regime (regimen simplificado) that must indicate that all of their sales are under this scheme. This implies that all operations in the invoice will have the `FacturaSinIdentifDestinatarioArt61d` tag set to `S` and the `TipoFactura` field set to `F2` in case of a regular invoice and `R5` in case of a corrective invoice. -- `substitution` - A simplified invoice that is being replaced by a standard invoice. Called a `Factura en Sustitución de Facturas Simplificadas` in Veri*Factu. +- `substitution` - A simplified invoice that is being replaced by a standard invoice. Called a `Factura en Sustitución de Facturas Simplificadas` in VeriFactu. The `TipoFactura` field will be set to `F3`. ### Tax Extensions @@ -184,7 +184,8 @@ The following extensions must be added to the document: - `R4` - Rectified invoice based on law and other reasons. - `R5` - Rectified invoice based on simplified invoices. -- `es-verifactu-tax-classification` - combines the tax classification and exemption codes used in Veri*Factu. Must be included in each line item, or an error will be raised. These are the valid values: + +- `es-verifactu-tax-classification` - combines the tax classification and exemption codes used in VeriFactu. Must be included in each line item, or an error will be raised. These are the valid values: - `S1` - Subject and not exempt - Without reverse charge - `S2` - Subject and not exempt - With reverse charge. Known as `Inversión del Sujeto Pasivo` in Spanish VAT Law - `N1` - Not subject - Articles 7, 14, others @@ -196,21 +197,15 @@ The following extensions must be added to the document: - `E5` - Exempt pursuant to Article 25 of the VAT Law - `E6` - Exempt for other reasons -As a small consideration GOBL's tax internal tax framework differentiates between `exempt` and `zero-rated` taxes. In Veri*Factu, GOBL `zero-rated` taxes refer to `Exenciones` (values `E1` to `E6` in the list above) and `exempt` taxes refer to `No Sujeto` (values `N1` and `N2` in the list above). - -### Example - -An example of a +As a small consideration GOBL's tax internal tax framework differentiates between `exempt` and `zero-rated` taxes. In VeriFactu, GOBL `zero-rated` taxes refer to `Exenciones` (values `E1` to `E6` in the list above) and `exempt` taxes refer to `No Sujeto` (values `N1` and `N2` in the list above). ## Limitations -- Veri*Factu allows more than one customer per invoice, but GOBL only has one possible customer. - +- VeriFactu allows more than one customer per invoice, but GOBL only has one possible customer. - Invoices must have a note of type general that will be used as a general description of the invoice. If an invoice is missing this info, it will be rejected with an error. - - VeriFactu supports sending more than one invoice at a time (up to 1000). However, this module only currently supports 1 invoice at a time. - - VeriFactu requires a valid certificate to be provided, even when using the testing environment. It is the same certificate needed to access the AEAT's portal. +- When cancelling invoices, this module assumes the party issuing the cancellation is the same as the party that issued the original invoice. In the context of the app this would always be true, but VeriFactu does allow for a different issuer. ## Testing @@ -223,5 +218,5 @@ go test Some sample test data is available in the `./test` directory. To update the JSON documents and regenerate the XML files for testing, use the following command: ```bash -go test ./examples_test.go --update +go test --update ``` \ No newline at end of file diff --git a/doc/doc.go b/doc/doc.go index 19acede..e53d238 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -82,8 +82,8 @@ func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c } // QRCodes generates the QR code for the document -func (d *VeriFactu) QRCodes() string { - return d.generateURL() +func (d *VeriFactu) QRCodes(production bool) string { + return d.generateURL(production) } // ChainData generates the data to be used to link to this one diff --git a/doc/qr_code.go b/doc/qr_code.go index 0631651..80d1b8e 100644 --- a/doc/qr_code.go +++ b/doc/qr_code.go @@ -11,14 +11,15 @@ const ( ) // generateURL generates the encoded URL code with parameters. -func (doc *VeriFactu) generateURL() string { +func (doc *VeriFactu) generateURL(production bool) string { nif := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) numSerie := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) fecha := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) importe := url.QueryEscape(fmt.Sprintf("%g", doc.RegistroFactura.RegistroAlta.ImporteTotal)) - urlCode := fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", - testURL, nif, numSerie, fecha, importe) - - return urlCode + if production { + return fmt.Sprintf("%s&nif=%s&numserie=%s&fecha=%s&importe=%s", prodURL, nif, numSerie, fecha, importe) + } else { + return fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", testURL, nif, numSerie, fecha, importe) + } } diff --git a/doc/qr_code_test.go b/doc/qr_code_test.go index ebe673a..2cd3ce9 100644 --- a/doc/qr_code_test.go +++ b/doc/qr_code_test.go @@ -48,9 +48,9 @@ func TestGenerateCodes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.doc.QRCodes() + got := tt.doc.QRCodes(false) if got != tt.expected { - t.Errorf("generateURL() = %v, want %v", got, tt.expected) + t.Errorf("got %v, want %v", got, tt.expected) } }) } @@ -101,9 +101,9 @@ func TestGenerateURLCodeAlta(t *testing.T) { tt.doc.RegistroFactura.RegistroAlta.Encadenamiento = &doc.Encadenamiento{ PrimerRegistro: "S", } - got := tt.doc.QRCodes() + got := tt.doc.QRCodes(false) if got != tt.expected { - t.Errorf("generateURLCodeAlta() = %v, want %v", got, tt.expected) + t.Errorf("got %v, want %v", got, tt.expected) } }) } diff --git a/document.go b/document.go index 970196f..484cbd9 100644 --- a/document.go +++ b/document.go @@ -5,6 +5,7 @@ import ( "github.com/invopop/gobl" "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl.verifactu/internal/gateways" "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/head" @@ -46,7 +47,7 @@ func (c *Client) Fingerprint(d *doc.VeriFactu, prev *doc.ChainData) error { // AddQR adds the QR code stamp to the envelope. func (c *Client) AddQR(d *doc.VeriFactu, env *gobl.Envelope) error { // now generate the QR codes and add them to the envelope - code := d.QRCodes() + code := d.QRCodes(c.env == gateways.EnvironmentProduction) env.Head.AddStamp( &head.Stamp{ Provider: verifactu.StampQR, diff --git a/internal/gateways/errors.go b/internal/gateways/errors.go index 3ceb004..89ec2b2 100644 --- a/internal/gateways/errors.go +++ b/internal/gateways/errors.go @@ -7,7 +7,6 @@ import ( // ErrorCodes and their descriptions from VeriFactu var ErrorCodes = map[string]string{ - // Errors that cause rejection of the entire submission "4102": "El XML no cumple el esquema. Falta informar campo obligatorio.", "4103": "Se ha producido un error inesperado al parsear el XML.", "4104": "Error en la cabecera: el valor del campo NIF del bloque ObligadoEmision no está identificado.", @@ -43,8 +42,6 @@ var ErrorCodes = map[string]string{ "3501": "Error técnico de base de datos.", "3502": "La factura consultada para el suministro de pagos/cobros/inmuebles no existe.", "3503": "La factura especificada no pertenece al titular registrado en el sistema.", - - // Errors that cause rejection of the invoice or entire request if in header "1100": "Valor o tipo incorrecto del campo.", "1101": "El valor del campo CodigoPais es incorrecto.", "1102": "El valor del campo IDType es incorrecto.", diff --git a/prev.json b/test/example_prev.json similarity index 100% rename from prev.json rename to test/example_prev.json From 5ef22ce3936934239ee96ef364fb132a458a710b Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 28 Nov 2024 14:36:04 +0000 Subject: [PATCH 30/56] Update README --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0577117..e5b8e81 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The following is an example of how the GOBL VeriFactu package could be used: package main import ( + "context" "encoding/json" "fmt" "os" @@ -82,8 +83,13 @@ func main() { } // Prepare the previous document chain data + previous, err := os.ReadFile("./path/to/previous_invoice.json") + if err != nil { + panic(err) + } + prev := new(doc.ChainData) - if err := json.Unmarshal([]byte(c.previous), prev); err != nil { + if err := json.Unmarshal([]byte(previous), prev); err != nil { panic(err) } From 11c6ea628a4fabb444a75e6460eab7a24d61b712 Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 28 Nov 2024 15:32:06 +0000 Subject: [PATCH 31/56] Invert Credit Notes and Add Examples --- doc/doc.go | 11 ++ doc/fingerprint_test.go | 30 ++--- doc/invoice_test.go | 4 + doc/qr_code.go | 3 +- internal/gateways/gateways.go | 2 +- test/data/cred-note-base.json | 39 +----- test/data/cred-note-exemption.json | 172 ++++++++++++++++++++++++++ test/data/out/cred-note-base.xml | 16 +-- test/data/out/cred-note-exemption.xml | 84 +++++++++++++ 9 files changed, 298 insertions(+), 63 deletions(-) create mode 100644 test/data/cred-note-exemption.json create mode 100644 test/data/out/cred-note-exemption.xml diff --git a/doc/doc.go b/doc/doc.go index e53d238..92dfd4e 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -64,6 +64,17 @@ func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c RegistroFactura: &RegistroFactura{}, } + if inv.Type == bill.InvoiceTypeCreditNote { + // GOBL credit note's amounts represent the amounts to be credited to the customer, + // and they are provided as positive numbers. In VeriFactu, however, credit notes + // become "facturas rectificativas por diferencias" and, when a correction is for a + // credit operation, the amounts must be negative to cancel out the ones in the + // original invoice. For that reason, we invert the credit note quantities here. + if err := inv.Invert(); err != nil { + return nil, err + } + } + if c { reg, err := NewRegistroAnulacion(inv, ts, s) if err != nil { diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go index c3b35ca..ef3b0bd 100644 --- a/doc/fingerprint_test.go +++ b/doc/fingerprint_test.go @@ -28,9 +28,9 @@ func TestFingerprintAlta(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-20T19:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "foo", - NumSerieFactura: "bar", - FechaExpedicionFactura: "baz", + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-000", + FechaExpedicionFactura: "10-11-2024", Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, expected: "9F848AF7AECAA4C841654B37FD7119F4530B19141A2C3FF9968B5A229DEE21C2", @@ -49,9 +49,9 @@ func TestFingerprintAlta(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "foo", - NumSerieFactura: "bar", - FechaExpedicionFactura: "baz", + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, expected: "14543C022CBD197F247F77A88F41E636A3B2569CE5787A8D6C8A781BF1B9D25E", @@ -88,9 +88,9 @@ func TestFingerprintAlta(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-21T17:59:41+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "foo", - NumSerieFactura: "bar", - FechaExpedicionFactura: "baz", + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-002", + FechaExpedicionFactura: "11-11-2024", Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C", }, expected: "9F44F498EA51C0C50FEB026CCE86BDCCF852C898EE33336EFFE1BD6F132B506E", @@ -138,9 +138,9 @@ func TestFingerprintAnulacion(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "foo", - NumSerieFactura: "bar", - FechaExpedicionFactura: "baz", + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-000", + FechaExpedicionFactura: "10-11-2024", Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, expected: "F5AB85A94450DF8752F4A7840C72456B753010E5EC1F26D8EAE0D4523E287948", @@ -156,9 +156,9 @@ func TestFingerprintAnulacion(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-21T12:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "foo", - NumSerieFactura: "bar", - FechaExpedicionFactura: "baz", + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", }, expected: "E86A5172477A636958B2F98770FB796BEEDA43F3F1C6A1C601EC3EEDF9C033B1", diff --git a/doc/invoice_test.go b/doc/invoice_test.go index a281b2f..034b374 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -76,6 +76,10 @@ func TestNewRegistroAlta(t *testing.T) { assert.Equal(t, "B85905495", rectified.IDFactura.IDEmisorFactura) assert.Equal(t, "SAMPLE-085", rectified.IDFactura.NumSerieFactura) assert.Equal(t, "10-01-2022", rectified.IDFactura.FechaExpedicionFactura) + assert.Equal(t, float64(-1620), reg.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, float64(-340.2), reg.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, float64(-340.2), reg.CuotaTotal) + assert.Equal(t, float64(-1960.2), reg.ImporteTotal) }) t.Run("should handle substitution invoices", func(t *testing.T) { diff --git a/doc/qr_code.go b/doc/qr_code.go index 80d1b8e..551a1a4 100644 --- a/doc/qr_code.go +++ b/doc/qr_code.go @@ -19,7 +19,6 @@ func (doc *VeriFactu) generateURL(production bool) string { if production { return fmt.Sprintf("%s&nif=%s&numserie=%s&fecha=%s&importe=%s", prodURL, nif, numSerie, fecha, importe) - } else { - return fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", testURL, nif, numSerie, fecha, importe) } + return fmt.Sprintf("%snif=%s&numserie=%s&fecha=%s&importe=%s", testURL, nif, numSerie, fecha, importe) } diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index e67658b..c64caec 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -33,7 +33,7 @@ type Connection struct { client *resty.Client } -// New instantiates a new connection using the provided config. +// New instantiates and configures a new connection to the VeriFactu gateway. func New(env Environment, cert *xmldsig.Certificate) (*Connection, error) { tlsConf, err := cert.TLSAuthConfig() if err != nil { diff --git a/test/data/cred-note-base.json b/test/data/cred-note-base.json index 15cbcaa..75d9825 100644 --- a/test/data/cred-note-base.json +++ b/test/data/cred-note-base.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "b76e4c256c9c0e593545bbc31c13714d736c533a9ad19bcab83902d78cd95332" + "val": "b3b00f6a955ad8a51e8ebaed574124162053a6fcccffba6921880114f94be99f" } }, "doc": { @@ -104,31 +104,11 @@ } ], "total": "1620.00" - }, - { - "i": 2, - "quantity": "1", - "item": { - "name": "Financial service", - "price": "10.00" - }, - "sum": "10.00", - "taxes": [ - { - "cat": "VAT", - "rate": "zero", - "percent": "0.0%", - "ext": { - "es-verifactu-tax-classification": "E1" - } - } - ], - "total": "10.00" } ], "totals": { - "sum": "1630.00", - "total": "1630.00", + "sum": "1620.00", + "total": "1620.00", "taxes": { "categories": [ { @@ -142,15 +122,6 @@ "base": "1620.00", "percent": "21.0%", "amount": "340.20" - }, - { - "key": "zero", - "ext": { - "es-verifactu-tax-classification": "E1" - }, - "base": "10.00", - "percent": "0.0%", - "amount": "0.00" } ], "amount": "340.20" @@ -159,8 +130,8 @@ "sum": "340.20" }, "tax": "340.20", - "total_with_tax": "1970.20", - "payable": "1970.20" + "total_with_tax": "1960.20", + "payable": "1960.20" }, "notes": [ { diff --git a/test/data/cred-note-exemption.json b/test/data/cred-note-exemption.json new file mode 100644 index 0000000..15cbcaa --- /dev/null +++ b/test/data/cred-note-exemption.json @@ -0,0 +1,172 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "b76e4c256c9c0e593545bbc31c13714d736c533a9ad19bcab83902d78cd95332" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-verifactu-v1" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "credit-note", + "series": "FR", + "code": "012", + "issue_date": "2022-02-01", + "currency": "EUR", + "preceding": [ + { + "type": "standard", + "issue_date": "2022-01-10", + "series": "SAMPLE", + "code": "085" + } + ], + "tax": { + "ext": { + "es-verifactu-doc-type": "R1" + } + }, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B85905495" + }, + "addresses": [ + { + "num": "42", + "street": "San Frantzisko", + "locality": "Bilbo", + "region": "Bizkaia", + "code": "48003", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Customer", + "tax_id": { + "country": "ES", + "code": "54387763P" + }, + "addresses": [ + { + "num": "13", + "street": "Calle del Barro", + "locality": "Alcañiz", + "region": "Teruel", + "code": "44600", + "country": "ES" + } + ], + "emails": [ + { + "addr": "customer@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%", + "ext": { + "es-verifactu-tax-classification": "S1" + } + } + ], + "total": "1620.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "zero", + "percent": "0.0%", + "ext": { + "es-verifactu-tax-classification": "E1" + } + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "ext": { + "es-verifactu-tax-classification": "S1" + }, + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + }, + { + "key": "zero", + "ext": { + "es-verifactu-tax-classification": "E1" + }, + "base": "10.00", + "percent": "0.0%", + "amount": "0.00" + } + ], + "amount": "340.20" + } + ], + "sum": "340.20" + }, + "tax": "340.20", + "total_with_tax": "1970.20", + "payable": "1970.20" + }, + "notes": [ + { + "key": "general", + "text": "Some random description" + } + ] + } +} \ No newline at end of file diff --git a/test/data/out/cred-note-base.xml b/test/data/out/cred-note-base.xml index af2120d..92127e6 100755 --- a/test/data/out/cred-note-base.xml +++ b/test/data/out/cred-note-base.xml @@ -43,18 +43,12 @@ 01 S1 21 - 1620 - 340.2 - - - 01 - 01 - E1 - 10 + -1620 + -340.2 - 340.2 - 1970.2 + -340.2 + -1960.2 B12345678 @@ -76,7 +70,7 @@ 2024-11-26T05:00:00+01:00 01 - AB1C4B1F38943CCDB33FC73DA9E4A85DD57EBC19A1706E8674A649A04D3BB3D3 + B03877764C7C609DE6C81BCDDA862D686F7EB96855EC88EF89FCEDFB6C9008F1 diff --git a/test/data/out/cred-note-exemption.xml b/test/data/out/cred-note-exemption.xml new file mode 100644 index 0000000..d476326 --- /dev/null +++ b/test/data/out/cred-note-exemption.xml @@ -0,0 +1,84 @@ + + + + + + Provide One S.L. + B85905495 + + + + + 1.0 + + B85905495 + FR-012 + 01-02-2022 + + Provide One S.L. + R1 + I + + + B85905495 + SAMPLE-085 + 10-01-2022 + + + Some random description + T + + Provide One S.L. + B85905495 + + + + Sample Customer + 54387763P + + + + + 01 + 01 + S1 + 21 + -1620 + -340.2 + + + 01 + 01 + E1 + -10 + + + -340.2 + -1970.2 + + + B12345678 + SAMPLE-001 + 26-11-2024 + 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF + + + + My Software + 12345678A + My Software + A1 + 1.0 + 12345678A + S + S + N + + 2024-11-26T05:00:00+01:00 + 01 + 3FA230448CC184F4C2EF923E7F2F1ACF2CF83CA25F17C03E35D861C21E2315DC + + + + + \ No newline at end of file From 5e44288b7b6434945a35b215b96acab2aee143f0 Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 28 Nov 2024 19:34:56 +0000 Subject: [PATCH 32/56] Added more advanced errors --- doc/document.go | 8 +++- errors.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ verifactu.go | 15 +++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 errors.go diff --git a/doc/document.go b/doc/document.go index cf842ff..e049e46 100644 --- a/doc/document.go +++ b/doc/document.go @@ -1,6 +1,9 @@ package doc -import "encoding/xml" +import ( + "encoding/xml" + "time" +) // SUM is the namespace for the main VeriFactu schema const ( @@ -26,6 +29,9 @@ type VeriFactu struct { XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"` Cabecera *Cabecera `xml:"sum:Cabecera"` RegistroFactura *RegistroFactura `xml:"sum:RegistroFactura"` + + //Internal + ts time.Time } // RegistroFactura contains either an invoice registration or cancellation diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..7197709 --- /dev/null +++ b/errors.go @@ -0,0 +1,106 @@ +package verifactu + +import ( + "errors" + "fmt" + "strings" + + "github.com/invopop/gobl.verifactu/internal/gateways" +) + +// Main error types return by this package. +var ( + ErrValidation = newError("validation") + ErrDuplicate = newError("duplicate") + ErrConnection = newError("connection") + ErrInternal = newError("internal") +) + +// Error allows for structured responses to better handle errors upstream. +type Error struct { + key string + code string + message string + cause error +} + +func newError(key string) *Error { + return &Error{key: key} +} + +// newErrorFrom attempts to wrap the provided error into the Error type. +func newErrorFrom(err error) *Error { + if err == nil { + return nil + } + if e, ok := err.(*Error); ok { + return e + } else if e, ok := err.(*gateways.Error); ok { + return &Error{ + key: e.Key(), + code: e.Code(), + message: e.Message(), + cause: e, + } + } + return &Error{ + key: "internal", + message: err.Error(), + cause: err, + } +} + +// Error produces a human readable error message. +func (e *Error) Error() string { + out := []string{e.key} + if e.code != "" { + out = append(out, e.code) + } + if e.message != "" { + out = append(out, e.message) + } + return strings.Join(out, ": ") +} + +// Key returns the key for the error. +func (e *Error) Key() string { + return e.key +} + +// Message returns the human message for the error. +func (e *Error) Message() string { + return e.message +} + +// Code returns the code provided by the remote service. +func (e *Error) Code() string { + return e.code +} + +// Cause returns the undlying error that caused this error. +func (e *Error) Cause() error { + return e.cause +} + +// withMessage duplicates and adds the message to the error. +func (e *Error) withMessage(msg string, args ...any) *Error { + e = e.clone() + e.message = fmt.Sprintf(msg, args...) + return e +} + +func (e *Error) clone() *Error { + ne := new(Error) + *ne = *e + return ne +} + +// Is checks to see if the target error is the same as the current one +// or forms part of the chain. +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + if !ok { + return errors.Is(e.cause, target) + } + return e.key == t.key +} diff --git a/verifactu.go b/verifactu.go index 54f9745..d462341 100644 --- a/verifactu.go +++ b/verifactu.go @@ -3,6 +3,7 @@ package verifactu import ( "context" + "encoding/xml" "errors" "time" @@ -134,6 +135,15 @@ func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { return nil } +// ParseDocument will parse the XML data into a VeriFactu document. +func ParseDocument(data []byte) (*doc.VeriFactu, error) { + d := new(doc.VeriFactu) + if err := xml.Unmarshal(data, d); err != nil { + return nil, err + } + return d, nil +} + // CurrentTime returns the current time to use when generating // the VeriFactu document. func (c *Client) CurrentTime() time.Time { @@ -142,3 +152,8 @@ func (c *Client) CurrentTime() time.Time { } return time.Now() } + +// Sandbox returns true if the client is using the sandbox environment. +func (c *Client) Sandbox() bool { + return c.env == gateways.EnvironmentSandbox +} From 36db7554536f80d914f365081129b902ec46d5ef Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 2 Dec 2024 15:18:07 +0000 Subject: [PATCH 33/56] Move errors to doc --- doc/document.go | 4 - {internal/gateways => doc}/errors.go | 32 ++++- errors.go | 106 --------------- internal/gateways/gateways.go | 6 +- test/schema/ConsultaLR.xsd | 38 ++++++ test/schema/RespuestaConsultaLR.xsd | 195 +++++++++++++++++++++++++++ test/schema/SistemaFacturacion.wsdl | 172 +++++++++++------------ verifactu.go | 2 +- 8 files changed, 350 insertions(+), 205 deletions(-) rename {internal/gateways => doc}/errors.go (89%) delete mode 100644 errors.go create mode 100644 test/schema/ConsultaLR.xsd create mode 100644 test/schema/RespuestaConsultaLR.xsd diff --git a/doc/document.go b/doc/document.go index e049e46..d63e7e4 100644 --- a/doc/document.go +++ b/doc/document.go @@ -2,7 +2,6 @@ package doc import ( "encoding/xml" - "time" ) // SUM is the namespace for the main VeriFactu schema @@ -29,9 +28,6 @@ type VeriFactu struct { XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"` Cabecera *Cabecera `xml:"sum:Cabecera"` RegistroFactura *RegistroFactura `xml:"sum:RegistroFactura"` - - //Internal - ts time.Time } // RegistroFactura contains either an invoice registration or cancellation diff --git a/internal/gateways/errors.go b/doc/errors.go similarity index 89% rename from internal/gateways/errors.go rename to doc/errors.go index 89ec2b2..77120ce 100644 --- a/internal/gateways/errors.go +++ b/doc/errors.go @@ -1,4 +1,4 @@ -package gateways +package doc import ( "errors" @@ -72,6 +72,28 @@ type Error struct { cause error } +// NewErrorFrom attempts to wrap the provided error into the Error type. +func NewErrorFrom(err error) *Error { + if err == nil { + return nil + } + if e, ok := err.(*Error); ok { + return e + } else if e, ok := err.(*Error); ok { + return &Error{ + key: e.Key(), + code: e.Code(), + message: e.Message(), + cause: e, + } + } + return &Error{ + key: "internal", + message: err.Error(), + cause: err, + } +} + // Error produces a human readable error message. func (e *Error) Error() string { out := []string{e.key} @@ -103,15 +125,15 @@ func newError(key string) *Error { return &Error{key: key} } -// withCode duplicates and adds the code to the error. -func (e *Error) withCode(code string) *Error { +// WithCode duplicates and adds the code to the error. +func (e *Error) WithCode(code string) *Error { e = e.clone() e.code = code return e } -// withMessage duplicates and adds the message to the error. -func (e *Error) withMessage(msg string) *Error { +// WithMessage duplicates and adds the message to the error. +func (e *Error) WithMessage(msg string) *Error { e = e.clone() e.message = msg return e diff --git a/errors.go b/errors.go deleted file mode 100644 index 7197709..0000000 --- a/errors.go +++ /dev/null @@ -1,106 +0,0 @@ -package verifactu - -import ( - "errors" - "fmt" - "strings" - - "github.com/invopop/gobl.verifactu/internal/gateways" -) - -// Main error types return by this package. -var ( - ErrValidation = newError("validation") - ErrDuplicate = newError("duplicate") - ErrConnection = newError("connection") - ErrInternal = newError("internal") -) - -// Error allows for structured responses to better handle errors upstream. -type Error struct { - key string - code string - message string - cause error -} - -func newError(key string) *Error { - return &Error{key: key} -} - -// newErrorFrom attempts to wrap the provided error into the Error type. -func newErrorFrom(err error) *Error { - if err == nil { - return nil - } - if e, ok := err.(*Error); ok { - return e - } else if e, ok := err.(*gateways.Error); ok { - return &Error{ - key: e.Key(), - code: e.Code(), - message: e.Message(), - cause: e, - } - } - return &Error{ - key: "internal", - message: err.Error(), - cause: err, - } -} - -// Error produces a human readable error message. -func (e *Error) Error() string { - out := []string{e.key} - if e.code != "" { - out = append(out, e.code) - } - if e.message != "" { - out = append(out, e.message) - } - return strings.Join(out, ": ") -} - -// Key returns the key for the error. -func (e *Error) Key() string { - return e.key -} - -// Message returns the human message for the error. -func (e *Error) Message() string { - return e.message -} - -// Code returns the code provided by the remote service. -func (e *Error) Code() string { - return e.code -} - -// Cause returns the undlying error that caused this error. -func (e *Error) Cause() error { - return e.cause -} - -// withMessage duplicates and adds the message to the error. -func (e *Error) withMessage(msg string, args ...any) *Error { - e = e.clone() - e.message = fmt.Sprintf(msg, args...) - return e -} - -func (e *Error) clone() *Error { - ne := new(Error) - *ne = *e - return ne -} - -// Is checks to see if the target error is the same as the current one -// or forms part of the chain. -func (e *Error) Is(target error) bool { - t, ok := target.(*Error) - if !ok { - return errors.Is(e.cause, target) - } - return e.key == t.key -} diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index c64caec..0659eac 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -78,13 +78,13 @@ func (c *Connection) post(ctx context.Context, path string, payload []byte) erro return err } if res.StatusCode() != http.StatusOK { - return ErrInvalid.withCode(strconv.Itoa(res.StatusCode())) + return doc.ErrInvalid.WithCode(strconv.Itoa(res.StatusCode())) } if out.Body.Respuesta.EstadoEnvio != correctStatus { - err := ErrInvalid + err := doc.ErrInvalid.WithCode(strconv.Itoa(res.StatusCode())) if len(out.Body.Respuesta.RespuestaLinea) > 0 { e1 := out.Body.Respuesta.RespuestaLinea[0] - err = err.withMessage(e1.DescripcionErrorRegistro).withCode(e1.CodigoErrorRegistro) + err = err.WithMessage(e1.DescripcionErrorRegistro).WithCode(e1.CodigoErrorRegistro) } return err } diff --git a/test/schema/ConsultaLR.xsd b/test/schema/ConsultaLR.xsd new file mode 100644 index 0000000..5b06d60 --- /dev/null +++ b/test/schema/ConsultaLR.xsd @@ -0,0 +1,38 @@ + + + + + + + + + Servicio de consulta Registros Facturacion + + + + + + + + + + + + + + Nº Serie+Nº Factura de la Factura del Emisor. + + + + + Contraparte del NIF de la cabecera que realiza la consulta. + Obligado si la cosulta la realiza el Destinatario de los registros de facturacion. + Destinatario si la cosulta la realiza el Obligado dde los registros de facturacion. + + + + + + + + diff --git a/test/schema/RespuestaConsultaLR.xsd b/test/schema/RespuestaConsultaLR.xsd new file mode 100644 index 0000000..f9fa6ee --- /dev/null +++ b/test/schema/RespuestaConsultaLR.xsd @@ -0,0 +1,195 @@ + + + + + + + + + Servicio de consulta de regIstros de facturacion + + + + + + + + + + + + + + + + + + + Estado del registro almacenado en el sistema. Los estados posibles son: Correcta, AceptadaConErrores y Anulada + + + + + + + Código del error de registro, en su caso. + + + + + + + Descripción detallada del error de registro, en su caso. + + + + + + + + + + + + + + + + + + + + Período al que corresponden los apuntes. todos los apuntes deben corresponder al mismo período impositivo + + + + + + + + + + + + + + + Apunte correspondiente al libro de facturas expedidas. + + + + + + + + + + + Clave del tipo de factura + + + + + Identifica si el tipo de factura rectificativa es por sustitución o por diferencia + + + + + + El ID de las facturas rectificadas, únicamente se rellena en el caso de rectificación de facturas + + + + + + + + + + El ID de las facturas sustituidas, únicamente se rellena en el caso de facturas sustituidas + + + + + + + + + + + + + + + + Tercero que expida la factura y/o genera el registro de alta. + + + + + + Contraparte de la operación. Cliente + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + El registro se almacenado sin errores + + + + + El registro se almacenado tiene algunos errores. Ver detalle del error + + + + + El registro almacenado ha sido anulado + + + + + diff --git a/test/schema/SistemaFacturacion.wsdl b/test/schema/SistemaFacturacion.wsdl index 2c9e164..9c72929 100644 --- a/test/schema/SistemaFacturacion.wsdl +++ b/test/schema/SistemaFacturacion.wsdl @@ -1,89 +1,89 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/verifactu.go b/verifactu.go index d462341..fd9f77b 100644 --- a/verifactu.go +++ b/verifactu.go @@ -130,7 +130,7 @@ func InSandbox() Option { // Post will send the document to the VeriFactu gateway. func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { if err := c.gw.Post(ctx, *d); err != nil { - return err + return doc.NewErrorFrom(err) } return nil } From 0d943752766f36054265e4dc3b5b395194695266 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 3 Dec 2024 11:24:20 +0000 Subject: [PATCH 34/56] Make fingerprint internal methods unexported --- doc/fingerprint.go | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/doc/fingerprint.go b/doc/fingerprint.go index da804eb..e137373 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -21,8 +21,7 @@ type ChainData struct { Huella string `json:"huella"` } -// FormatField returns a formatted field as key=value or key= if the value is empty. -func FormatField(key, value string) string { +func formatField(key, value string) string { value = strings.TrimSpace(value) // Remove whitespace if value == "" { return fmt.Sprintf("%s=", key) @@ -30,7 +29,6 @@ func FormatField(key, value string) string { return fmt.Sprintf("%s=%s", key, value) } -// Concatenatef builds the concatenated string based on Verifactu requirements. func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { var h string if inv.Encadenamiento.PrimerRegistro == "S" { @@ -39,14 +37,14 @@ func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { h = inv.Encadenamiento.RegistroAnterior.Huella } f := []string{ - FormatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), - FormatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), - FormatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), - FormatField("TipoFactura", inv.TipoFactura), - FormatField("CuotaTotal", fmt.Sprintf("%g", inv.CuotaTotal)), - FormatField("ImporteTotal", fmt.Sprintf("%g", inv.ImporteTotal)), - FormatField("Huella", h), - FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), + formatField("IDEmisorFactura", inv.IDFactura.IDEmisorFactura), + formatField("NumSerieFactura", inv.IDFactura.NumSerieFactura), + formatField("FechaExpedicionFactura", inv.IDFactura.FechaExpedicionFactura), + formatField("TipoFactura", inv.TipoFactura), + formatField("CuotaTotal", fmt.Sprintf("%g", inv.CuotaTotal)), + formatField("ImporteTotal", fmt.Sprintf("%g", inv.ImporteTotal)), + formatField("Huella", h), + formatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } st := strings.Join(f, "&") hash := sha256.New() @@ -64,11 +62,11 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { h = inv.Encadenamiento.RegistroAnterior.Huella } f := []string{ - FormatField("IDEmisorFacturaAnulada", inv.IDFactura.IDEmisorFactura), - FormatField("NumSerieFacturaAnulada", inv.IDFactura.NumSerieFactura), - FormatField("FechaExpedicionFacturaAnulada", inv.IDFactura.FechaExpedicionFactura), - FormatField("Huella", h), - FormatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), + formatField("IDEmisorFacturaAnulada", inv.IDFactura.IDEmisorFactura), + formatField("NumSerieFacturaAnulada", inv.IDFactura.NumSerieFactura), + formatField("FechaExpedicionFacturaAnulada", inv.IDFactura.FechaExpedicionFactura), + formatField("Huella", h), + formatField("FechaHoraHusoGenRegistro", inv.FechaHoraHusoGenRegistro), } st := strings.Join(f, "&") hash := sha256.New() From 4fbede7c535e559e2988613c0b78bb50b5636dbf Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 5 Dec 2024 16:16:12 +0000 Subject: [PATCH 35/56] Do not invert debit notes --- doc/breakdown.go | 70 +++++------------------------ doc/breakdown_test.go | 20 ++++----- doc/doc.go | 2 +- doc/invoice.go | 48 +++++++++++++++----- doc/invoice_test.go | 2 - go.mod | 6 +-- go.sum | 8 ++-- test/schema/SistemaFacturacion.wsdl | 31 ++++++++++--- 8 files changed, 91 insertions(+), 96 deletions(-) diff --git a/doc/breakdown.go b/doc/breakdown.go index ffdca1b..1439056 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -2,13 +2,10 @@ package doc import ( "fmt" - "strings" "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/l10n" - "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" ) @@ -24,7 +21,7 @@ func newDesglose(inv *bill.Invoice) (*Desglose, error) { for _, c := range inv.Totals.Taxes.Categories { for _, r := range c.Rates { - detalleDesglose, err := buildDetalleDesglose(inv, c, r) + detalleDesglose, err := buildDetalleDesglose(c, r) if err != nil { return nil, err } @@ -37,7 +34,7 @@ func newDesglose(inv *bill.Invoice) (*Desglose, error) { // Rules applied to build the breakdown come from: // https://www.agenciatributaria.es/static_files/AEAT_Desarrolladores/EEDD/IVA/VERI-FACTU/Validaciones_Errores_Veri-Factu.pdf -func buildDetalleDesglose(inv *bill.Invoice, c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesglose, error) { +func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesglose, error) { detalle := &DetalleDesglose{ BaseImponibleOImporteNoSujeto: r.Base.Float64(), CuotaRepercutida: r.Amount.Float64(), @@ -51,21 +48,17 @@ func buildDetalleDesglose(inv *bill.Invoice, c *tax.CategoryTotal, r *tax.RateTo } if c.Code == tax.CategoryVAT || c.Code == es.TaxCategoryIGIC { - detalle.ClaveRegimen = parseClave(inv, c, r) + detalle.ClaveRegimen = r.Ext.Get(verifactu.ExtKeyRegime).String() } - // Rate zero is what VeriFactu calls "Exempt operation", in difference to GOBL's exempt operation, which in - // VeriFactu is called "No sujeta". - if r.Key == tax.RateZero { - detalle.OperacionExenta = r.Ext[verifactu.ExtKeyTaxClassification].String() - if detalle.OperacionExenta != "" && !strings.HasPrefix(detalle.OperacionExenta, "E") { - return nil, fmt.Errorf("invalid exemption code %s - must be E1-E6", detalle.OperacionExenta) - } - } else { - detalle.CalificacionOperacion = r.Ext[verifactu.ExtKeyTaxClassification].String() - if detalle.CalificacionOperacion == "" { - return nil, fmt.Errorf("missing operation classification for rate %s", r.Key) - } + if r.Ext == nil { + return nil, fmt.Errorf("missing tax extensions for rate %s", r.Key) + } + + if r.Percent == nil { + detalle.OperacionExenta = r.Ext[verifactu.ExtKeyOpClass].String() + } else if !r.Ext.Has(verifactu.ExtKeyOpClass) { + detalle.CalificacionOperacion = r.Ext.Get(verifactu.ExtKeyExempt).String() } if detalle.Impuesto == "02" || detalle.Impuesto == "05" || detalle.ClaveRegimen == "06" { @@ -87,44 +80,3 @@ func buildDetalleDesglose(inv *bill.Invoice, c *tax.CategoryTotal, r *tax.RateTo return detalle, nil } - -func parseClave(inv *bill.Invoice, c *tax.CategoryTotal, r *tax.RateTotal) string { - switch c.Code { - case tax.CategoryVAT: - if inv.Customer != nil && partyTaxCountry(inv.Customer) != "ES" { - return "02" - } - if inv.HasTags(es.TagSecondHandGoods) || inv.HasTags(es.TagAntiques) || inv.HasTags(es.TagArt) { - return "03" - } - if inv.HasTags(es.TagTravelAgency) { - return "05" - } - if r.Key == es.TaxRateEquivalence { - return "18" - } - if inv.HasTags(es.TagSimplifiedScheme) { - return "20" - } - return "01" - case es.TaxCategoryIGIC: - if inv.Customer != nil && partyTaxCountry(inv.Customer) != "ES" { - return "02" - } - if inv.HasTags(es.TagSecondHandGoods) || inv.HasTags(es.TagAntiques) || inv.HasTags(es.TagArt) { - return "03" - } - if inv.HasTags(es.TagTravelAgency) { - return "05" - } - return "01" - } - return "" -} - -func partyTaxCountry(party *org.Party) l10n.TaxCountryCode { - if party != nil && party.TaxID != nil { - return party.TaxID.Country - } - return "ES" -} diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index 5993f0b..c59204e 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -43,7 +43,7 @@ func TestBreakdownConversion(t *testing.T) { Category: "VAT", Rate: "zero", Ext: tax.Extensions{ - verifactu.ExtKeyTaxClassification: "E1", + verifactu.ExtKeyExempt: "E1", }, }, }, @@ -70,7 +70,7 @@ func TestBreakdownConversion(t *testing.T) { Category: "VAT", Rate: "standard", Ext: tax.Extensions{ - verifactu.ExtKeyTaxClassification: "S1", + verifactu.ExtKeyOpClass: "S1", }, }, }, @@ -85,7 +85,7 @@ func TestBreakdownConversion(t *testing.T) { Category: "VAT", Rate: "reduced", Ext: tax.Extensions{ - verifactu.ExtKeyTaxClassification: "S1", + verifactu.ExtKeyOpClass: "S1", }, }, }, @@ -118,7 +118,7 @@ func TestBreakdownConversion(t *testing.T) { Category: "VAT", Rate: "exempt", Ext: tax.Extensions{ - verifactu.ExtKeyTaxClassification: "N1", + verifactu.ExtKeyOpClass: "N1", }, }, }, @@ -168,7 +168,7 @@ func TestBreakdownConversion(t *testing.T) { Category: "VAT", Rate: "standard+eqs", Ext: tax.Extensions{ - verifactu.ExtKeyTaxClassification: "S1", + verifactu.ExtKeyOpClass: "S1", }, }, }, @@ -199,7 +199,7 @@ func TestBreakdownConversion(t *testing.T) { Category: es.TaxCategoryIPSI, Percent: &p, Ext: tax.Extensions{ - verifactu.ExtKeyTaxClassification: "S1", + verifactu.ExtKeyOpClass: "S1", }, }, }, @@ -211,13 +211,12 @@ func TestBreakdownConversion(t *testing.T) { assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 10.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "03", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Empty(t, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Nil(t, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("antiques", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - inv.Tags = tax.WithTags(es.TagAntiques) inv.Lines = []*bill.Line{ { Quantity: num.MakeAmount(1, 0), @@ -229,7 +228,8 @@ func TestBreakdownConversion(t *testing.T) { Category: "VAT", Rate: "reduced", Ext: tax.Extensions{ - verifactu.ExtKeyTaxClassification: "S1", + verifactu.ExtKeyOpClass: "S1", + verifactu.ExtKeyRegime: "04", }, }, }, @@ -241,7 +241,7 @@ func TestBreakdownConversion(t *testing.T) { assert.Equal(t, 1000.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "03", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "04", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) } diff --git a/doc/doc.go b/doc/doc.go index 92dfd4e..d5dca73 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -65,7 +65,7 @@ func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c } if inv.Type == bill.InvoiceTypeCreditNote { - // GOBL credit note's amounts represent the amounts to be credited to the customer, + // GOBL credit and debit notes' amounts represent the amounts to be credited to the customer, // and they are provided as positive numbers. In VeriFactu, however, credit notes // become "facturas rectificativas por diferencias" and, when a correction is for a // credit operation, the amounts must be negative to cancel out the ones in the diff --git a/doc/invoice.go b/doc/invoice.go index 02df080..5e49e9e 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -1,28 +1,35 @@ package doc import ( + "errors" "fmt" - "slices" "time" "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" ) -var ( - rectificative = []string{"R1", "R2", "R3", "R4", "R5", "R6"} -) +var docTypesCreditDebit = []tax.ExtValue{ // Credit or Debit notes + "R1", "R2", "R3", "R4", "R5", +} // NewRegistroAlta creates a new VeriFactu registration for an invoice. func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { - description, err := newDescription(inv.Notes) + tf, err := getTaxKey(inv, verifactu.ExtKeyDocType) if err != nil { return nil, err } - desglose, err := newDesglose(inv) + desc, err := newDescription(inv.Notes) + if err != nil { + return nil, err + } + + dg, err := newDesglose(inv) if err != nil { return nil, err } @@ -35,9 +42,9 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), }, NombreRazonEmisor: inv.Supplier.Name, - TipoFactura: inv.Tax.Ext[verifactu.ExtKeyDocType].String(), - DescripcionOperacion: description, - Desglose: desglose, + TipoFactura: tf, + DescripcionOperacion: desc, + Desglose: dg, CuotaTotal: newTotalTaxes(inv), ImporteTotal: newImporteTotal(inv), SistemaInformatico: s, @@ -58,9 +65,13 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) reg.FacturaSinIdentifDestinatarioArt61d = "S" } - if slices.Contains(rectificative, reg.TipoFactura) { + if inv.Tax.Ext[verifactu.ExtKeyDocType].In(docTypesCreditDebit...) { // GOBL does not currently have explicit support for Facturas Rectificativas por Sustitución - reg.TipoRectificativa = "I" + k, err := getTaxKey(inv, verifactu.ExtKeyCorrectionType) + if err != nil { + return nil, err + } + reg.TipoRectificativa = k if inv.Preceding != nil { rs := make([]*FacturaRectificada, 0, len(inv.Preceding)) for _, ref := range inv.Preceding { @@ -76,7 +87,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) } } - if inv.HasTags(verifactu.TagSubstitution) { + if reg.TipoFactura == "F3" { if inv.Preceding != nil { subs := make([]*FacturaSustituida, 0, len(inv.Preceding)) for _, ref := range inv.Preceding { @@ -148,3 +159,16 @@ func newTotalTaxes(inv *bill.Invoice) float64 { return totalTaxes.Float64() } + +func getTaxKey(inv *bill.Invoice, k cbc.Key) (string, error) { + if inv.Tax == nil || inv.Tax.Ext == nil || inv.Tax.Ext[k].String() == "" { + return "", validation.Errors{ + "tax": validation.Errors{ + "ext": validation.Errors{ + k.String(): errors.New("required"), + }, + }, + } + } + return inv.Tax.Ext[k].String(), nil +} diff --git a/doc/invoice_test.go b/doc/invoice_test.go index 034b374..7bbdd76 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -6,7 +6,6 @@ import ( "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" - "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" @@ -84,7 +83,6 @@ func TestNewRegistroAlta(t *testing.T) { t.Run("should handle substitution invoices", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - inv.SetTags(verifactu.TagSubstitution) inv.Preceding = []*org.DocumentRef{ { Series: "SAMPLE", diff --git a/go.mod b/go.mod index 6205a8c..bf8b58f 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,8 @@ toolchain go1.22.1 require ( github.com/go-resty/resty/v2 v2.15.3 - github.com/invopop/gobl v0.206.1-0.20241126104736-056b02fb77f1 + github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9 + github.com/invopop/validation v0.8.0 github.com/invopop/xmldsig v0.10.0 github.com/joho/godotenv v1.5.1 github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3 @@ -30,7 +31,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect - github.com/invopop/validation v0.8.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -38,7 +38,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/crypto v0.29.0 // indirect + golang.org/x/crypto v0.30.0 // indirect golang.org/x/net v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect diff --git a/go.sum b/go.sum index 02d3e67..9faded1 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.206.1-0.20241126104736-056b02fb77f1 h1:smOgSdtBss0upURUgIvvbJOo0iHFcyjxDGgCQNhMB6Q= -github.com/invopop/gobl v0.206.1-0.20241126104736-056b02fb77f1/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9 h1:VIIyZspyf68JHHHQ6uk5Z2AufYPzkqCz1gF4WGRh32g= +github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.8.0 h1:e5hXHGnONHImgJdonIpNbctg1hlWy1ncaHoVIQ0JWuw= @@ -70,8 +70,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= diff --git a/test/schema/SistemaFacturacion.wsdl b/test/schema/SistemaFacturacion.wsdl index 9c72929..cdf6e52 100644 --- a/test/schema/SistemaFacturacion.wsdl +++ b/test/schema/SistemaFacturacion.wsdl @@ -1,24 +1,36 @@ - - + + - + + + + + + + + + + + + + @@ -37,6 +49,15 @@ + + + + + + + + + @@ -79,11 +100,11 @@ - + - + \ No newline at end of file From 35ad9d96c7c7d88eef29864dcd424b569cae181b Mon Sep 17 00:00:00 2001 From: apardods Date: Thu, 5 Dec 2024 17:54:17 +0000 Subject: [PATCH 36/56] Update Examples --- test/data/cred-note-base.json | 4 ++-- test/data/cred-note-exemption.json | 8 ++++---- test/data/inv-base.json | 4 ++-- test/data/inv-eqv-sur-b2c.json | 4 ++-- test/data/inv-eqv-sur.json | 4 ++-- test/data/inv-rev-charge.json | 4 ++-- test/data/inv-zero-tax.json | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/test/data/cred-note-base.json b/test/data/cred-note-base.json index 75d9825..6784fa5 100644 --- a/test/data/cred-note-base.json +++ b/test/data/cred-note-base.json @@ -99,7 +99,7 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" } } ], @@ -117,7 +117,7 @@ { "key": "standard", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" }, "base": "1620.00", "percent": "21.0%", diff --git a/test/data/cred-note-exemption.json b/test/data/cred-note-exemption.json index 15cbcaa..266d481 100644 --- a/test/data/cred-note-exemption.json +++ b/test/data/cred-note-exemption.json @@ -99,7 +99,7 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" } } ], @@ -119,7 +119,7 @@ "rate": "zero", "percent": "0.0%", "ext": { - "es-verifactu-tax-classification": "E1" + "es-verifactu-exempt": "E1" } } ], @@ -137,7 +137,7 @@ { "key": "standard", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" }, "base": "1620.00", "percent": "21.0%", @@ -146,7 +146,7 @@ { "key": "zero", "ext": { - "es-verifactu-tax-classification": "E1" + "es-verifactu-exempt": "E1" }, "base": "10.00", "percent": "0.0%", diff --git a/test/data/inv-base.json b/test/data/inv-base.json index 217984e..bed2104 100644 --- a/test/data/inv-base.json +++ b/test/data/inv-base.json @@ -69,7 +69,7 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" } } ], @@ -87,7 +87,7 @@ { "key": "standard", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" }, "base": "1800.00", "percent": "21.0%", diff --git a/test/data/inv-eqv-sur-b2c.json b/test/data/inv-eqv-sur-b2c.json index 78d9e7c..d11d309 100644 --- a/test/data/inv-eqv-sur-b2c.json +++ b/test/data/inv-eqv-sur-b2c.json @@ -63,7 +63,7 @@ "percent": "21.0%", "surcharge": "5.2%", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" } } ], @@ -81,7 +81,7 @@ { "key": "standard+eqs", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" }, "base": "1800.00", "percent": "21.0%", diff --git a/test/data/inv-eqv-sur.json b/test/data/inv-eqv-sur.json index 77adce7..41610f4 100644 --- a/test/data/inv-eqv-sur.json +++ b/test/data/inv-eqv-sur.json @@ -70,7 +70,7 @@ "percent": "21.0%", "surcharge": "5.2%", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" } } ], @@ -88,7 +88,7 @@ { "key": "standard+eqs", "ext": { - "es-verifactu-tax-classification": "S1" + "es-verifactu-op-class": "S1" }, "base": "1800.00", "percent": "21.0%", diff --git a/test/data/inv-rev-charge.json b/test/data/inv-rev-charge.json index 8d5d3ce..e934de1 100644 --- a/test/data/inv-rev-charge.json +++ b/test/data/inv-rev-charge.json @@ -69,7 +69,7 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-tax-classification": "S2" + "es-verifactu-op-class": "S2" } } ], @@ -87,7 +87,7 @@ { "key": "standard", "ext": { - "es-verifactu-tax-classification": "S2" + "es-verifactu-op-class": "S2" }, "base": "1800.00", "percent": "21.0%", diff --git a/test/data/inv-zero-tax.json b/test/data/inv-zero-tax.json index 5bb11eb..63e6a8a 100644 --- a/test/data/inv-zero-tax.json +++ b/test/data/inv-zero-tax.json @@ -69,7 +69,7 @@ "rate": "zero", "percent": "0.0%", "ext": { - "es-verifactu-tax-classification": "E1" + "es-verifactu-exempt": "E1" } } ], @@ -87,7 +87,7 @@ { "key": "zero", "ext": { - "es-verifactu-tax-classification": "E1" + "es-verifactu-exempt": "E1" }, "base": "1800.00", "percent": "0.0%", From c7e68bec4cc2b064de548afd005312c91fbbbf27 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 9 Dec 2024 17:30:17 +0000 Subject: [PATCH 37/56] Update Tests --- doc/breakdown.go | 8 ++++---- doc/breakdown_test.go | 28 +++++---------------------- doc/invoice_test.go | 4 +++- test/data/cred-note-base.json | 11 +++++++---- test/data/cred-note-exemption.json | 21 ++++++++++---------- test/data/inv-base.json | 10 ++++++---- test/data/inv-eqv-sur-b2c.json | 10 ++++++---- test/data/inv-eqv-sur.json | 10 ++++++---- test/data/inv-rev-charge.json | 26 ++++++++++++------------- test/data/inv-zero-tax.json | 14 ++++++-------- test/data/out/cred-note-base.xml | 2 +- test/data/out/cred-note-exemption.xml | 2 +- test/data/out/inv-base.xml | 2 +- test/data/out/inv-eqv-sur-b2c.xml | 2 +- test/data/out/inv-eqv-sur.xml | 2 +- test/data/out/inv-rev-charge.xml | 10 ++++------ test/data/out/inv-zero-tax.xml | 2 +- 17 files changed, 76 insertions(+), 88 deletions(-) diff --git a/doc/breakdown.go b/doc/breakdown.go index 1439056..512b39a 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -55,10 +55,10 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl return nil, fmt.Errorf("missing tax extensions for rate %s", r.Key) } - if r.Percent == nil { - detalle.OperacionExenta = r.Ext[verifactu.ExtKeyOpClass].String() - } else if !r.Ext.Has(verifactu.ExtKeyOpClass) { - detalle.CalificacionOperacion = r.Ext.Get(verifactu.ExtKeyExempt).String() + if r.Percent == nil && r.Ext.Has(verifactu.ExtKeyExempt) { + detalle.OperacionExenta = r.Ext[verifactu.ExtKeyExempt].String() + } else if r.Ext.Has(verifactu.ExtKeyOpClass) { + detalle.CalificacionOperacion = r.Ext.Get(verifactu.ExtKeyOpClass).String() } if detalle.Impuesto == "02" || detalle.Impuesto == "05" || detalle.ClaveRegimen == "06" { diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index c59204e..9591512 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -41,9 +41,9 @@ func TestBreakdownConversion(t *testing.T) { Taxes: tax.Set{ &tax.Combo{ Category: "VAT", - Rate: "zero", Ext: tax.Extensions{ verifactu.ExtKeyExempt: "E1", + verifactu.ExtKeyRegime: "01", }, }, }, @@ -71,6 +71,7 @@ func TestBreakdownConversion(t *testing.T) { Rate: "standard", Ext: tax.Extensions{ verifactu.ExtKeyOpClass: "S1", + verifactu.ExtKeyRegime: "01", }, }, }, @@ -119,6 +120,7 @@ func TestBreakdownConversion(t *testing.T) { Rate: "exempt", Ext: tax.Extensions{ verifactu.ExtKeyOpClass: "N1", + verifactu.ExtKeyRegime: "01", }, }, }, @@ -134,27 +136,6 @@ func TestBreakdownConversion(t *testing.T) { assert.Equal(t, "N1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) - t.Run("missing-tax-classification", func(t *testing.T) { - inv := test.LoadInvoice("inv-base.json") - inv.Lines = []*bill.Line{ - { - Quantity: num.MakeAmount(1, 0), - Item: &org.Item{ - Price: num.MakeAmount(100, 0), - }, - Taxes: tax.Set{ - &tax.Combo{ - Category: "VAT", - Rate: "standard", - }, - }, - }, - } - _ = inv.Calculate() - _, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) - require.Error(t, err) - }) - t.Run("equivalence-surcharge", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") inv.Lines = []*bill.Line{ @@ -169,6 +150,7 @@ func TestBreakdownConversion(t *testing.T) { Rate: "standard+eqs", Ext: tax.Extensions{ verifactu.ExtKeyOpClass: "S1", + verifactu.ExtKeyRegime: "01", }, }, }, @@ -211,7 +193,7 @@ func TestBreakdownConversion(t *testing.T) { assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) assert.Equal(t, 10.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) assert.Equal(t, "03", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Nil(t, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Empty(t, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) diff --git a/doc/invoice_test.go b/doc/invoice_test.go index 7bbdd76..8d114bf 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -6,6 +6,7 @@ import ( "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" + "github.com/invopop/gobl/addons/es/verifactu" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" @@ -31,7 +32,7 @@ func TestNewRegistroAlta(t *testing.T) { assert.Equal(t, "13-11-2024", reg.IDFactura.FechaExpedicionFactura) assert.Equal(t, "Invopop S.L.", reg.NombreRazonEmisor) assert.Equal(t, "F1", reg.TipoFactura) - assert.Equal(t, "This is a sample invoice", reg.DescripcionOperacion) + assert.Equal(t, "This is a sample invoice with a standard tax", reg.DescripcionOperacion) assert.Equal(t, float64(378), reg.CuotaTotal) assert.Equal(t, float64(2178), reg.ImporteTotal) @@ -90,6 +91,7 @@ func TestNewRegistroAlta(t *testing.T) { IssueDate: cal.NewDate(2024, 1, 15), }, } + inv.Tax.Ext[verifactu.ExtKeyDocType] = "F3" d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) diff --git a/test/data/cred-note-base.json b/test/data/cred-note-base.json index 6784fa5..3598f9c 100644 --- a/test/data/cred-note-base.json +++ b/test/data/cred-note-base.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "b3b00f6a955ad8a51e8ebaed574124162053a6fcccffba6921880114f94be99f" + "val": "9c40da45b9c066e38ce938250b723dd1a443e2ab00508d8cfe74c16827b42e48" } }, "doc": { @@ -29,6 +29,7 @@ ], "tax": { "ext": { + "es-verifactu-correction-type": "I", "es-verifactu-doc-type": "R1" } }, @@ -99,7 +100,8 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" } } ], @@ -117,7 +119,8 @@ { "key": "standard", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" }, "base": "1620.00", "percent": "21.0%", @@ -136,7 +139,7 @@ "notes": [ { "key": "general", - "text": "Some random description" + "text": "This is a credit note with a standard tax" } ] } diff --git a/test/data/cred-note-exemption.json b/test/data/cred-note-exemption.json index 266d481..c76afc3 100644 --- a/test/data/cred-note-exemption.json +++ b/test/data/cred-note-exemption.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "b76e4c256c9c0e593545bbc31c13714d736c533a9ad19bcab83902d78cd95332" + "val": "6fdaba86c331bfb20fcfcad5d92aa3b62ceddd822647ab0c83b8d74c43d53d10" } }, "doc": { @@ -29,6 +29,7 @@ ], "tax": { "ext": { + "es-verifactu-correction-type": "I", "es-verifactu-doc-type": "R1" } }, @@ -99,7 +100,8 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" } } ], @@ -116,10 +118,9 @@ "taxes": [ { "cat": "VAT", - "rate": "zero", - "percent": "0.0%", "ext": { - "es-verifactu-exempt": "E1" + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" } } ], @@ -137,19 +138,19 @@ { "key": "standard", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" }, "base": "1620.00", "percent": "21.0%", "amount": "340.20" }, { - "key": "zero", "ext": { - "es-verifactu-exempt": "E1" + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" }, "base": "10.00", - "percent": "0.0%", "amount": "0.00" } ], @@ -165,7 +166,7 @@ "notes": [ { "key": "general", - "text": "Some random description" + "text": "This is a credit note with an exemption" } ] } diff --git a/test/data/inv-base.json b/test/data/inv-base.json index bed2104..aa85f4a 100644 --- a/test/data/inv-base.json +++ b/test/data/inv-base.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "32be8f884459a5cb5d7cfb85c775c9a3a3710e52dfeca749dd21cc2d61352420" + "val": "6d05f8571cef28298a289b5c0ca4ba91f2bc65c442fcaeadd483f7383be3d86d" } }, "doc": { @@ -69,7 +69,8 @@ "rate": "standard", "percent": "21.0%", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" } } ], @@ -87,7 +88,8 @@ { "key": "standard", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" }, "base": "1800.00", "percent": "21.0%", @@ -106,7 +108,7 @@ "notes": [ { "key": "general", - "text": "This is a sample invoice" + "text": "This is a sample invoice with a standard tax" } ] } diff --git a/test/data/inv-eqv-sur-b2c.json b/test/data/inv-eqv-sur-b2c.json index d11d309..2c6cbc1 100644 --- a/test/data/inv-eqv-sur-b2c.json +++ b/test/data/inv-eqv-sur-b2c.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", "dig": { "alg": "sha256", - "val": "a9779cdbd2ca3ad7f37226cae5cedd71f805721e13b1d37bdecb36194a8cb70d" + "val": "c42197ee05d5a9698fcc8637284ea0917c35bd4f7f87102d5c88fe148b67f0b9" } }, "doc": { @@ -63,7 +63,8 @@ "percent": "21.0%", "surcharge": "5.2%", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" } } ], @@ -81,7 +82,8 @@ { "key": "standard+eqs", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" }, "base": "1800.00", "percent": "21.0%", @@ -105,7 +107,7 @@ "notes": [ { "key": "general", - "text": "This is a sample invoice" + "text": "This is a sample B2C invoice with a standard tax and an equivalence surcharge" } ] } diff --git a/test/data/inv-eqv-sur.json b/test/data/inv-eqv-sur.json index 41610f4..a6abc05 100644 --- a/test/data/inv-eqv-sur.json +++ b/test/data/inv-eqv-sur.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", "dig": { "alg": "sha256", - "val": "f74efabf4c5cf10f1649230f84d33f62810a897b8b65d3a7eb260fc74161f87d" + "val": "f1f558d9398d298d5921952b5ec7fadb8c7556c939481be5fc2d0be04a39daf0" } }, "doc": { @@ -70,7 +70,8 @@ "percent": "21.0%", "surcharge": "5.2%", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" } } ], @@ -88,7 +89,8 @@ { "key": "standard+eqs", "ext": { - "es-verifactu-op-class": "S1" + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" }, "base": "1800.00", "percent": "21.0%", @@ -112,7 +114,7 @@ "notes": [ { "key": "general", - "text": "This is a sample invoice" + "text": "This is a sample invoice with a standard tax and an equivalence surcharge" } ] } diff --git a/test/data/inv-rev-charge.json b/test/data/inv-rev-charge.json index e934de1..b07163d 100644 --- a/test/data/inv-rev-charge.json +++ b/test/data/inv-rev-charge.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "01616e964c42ffec6342b37e6cbf5018f6cf72f586a06a3662cafe5af235be3b" + "val": "76bd0b5eaf3ffb5619271d2bcd0054b051202db7a350a0818f1a0a150a3287a1" } }, "doc": { @@ -66,10 +66,9 @@ "taxes": [ { "cat": "VAT", - "rate": "standard", - "percent": "21.0%", "ext": { - "es-verifactu-op-class": "S2" + "es-verifactu-op-class": "S2", + "es-verifactu-regime": "01" } } ], @@ -85,28 +84,27 @@ "code": "VAT", "rates": [ { - "key": "standard", "ext": { - "es-verifactu-op-class": "S2" + "es-verifactu-op-class": "S2", + "es-verifactu-regime": "01" }, "base": "1800.00", - "percent": "21.0%", - "amount": "378.00" + "amount": "0.00" } ], - "amount": "378.00" + "amount": "0.00" } ], - "sum": "378.00" + "sum": "0.00" }, - "tax": "378.00", - "total_with_tax": "2178.00", - "payable": "2178.00" + "tax": "0.00", + "total_with_tax": "1800.00", + "payable": "1800.00" }, "notes": [ { "key": "general", - "text": "This is a sample invoice" + "text": "This is a sample invoice with a reverse charge" } ] } diff --git a/test/data/inv-zero-tax.json b/test/data/inv-zero-tax.json index 63e6a8a..f1874a4 100644 --- a/test/data/inv-zero-tax.json +++ b/test/data/inv-zero-tax.json @@ -4,7 +4,7 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "1737d61d53252d2072ef02821cc386fbc52df806627354782cff46cd9213eff7" + "val": "d554de33c67a8646180b661d8600fe7d7ba1047d95643e6a3f49d2e9fceaa7a1" } }, "doc": { @@ -66,10 +66,9 @@ "taxes": [ { "cat": "VAT", - "rate": "zero", - "percent": "0.0%", "ext": { - "es-verifactu-exempt": "E1" + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" } } ], @@ -85,12 +84,11 @@ "code": "VAT", "rates": [ { - "key": "zero", "ext": { - "es-verifactu-exempt": "E1" + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" }, "base": "1800.00", - "percent": "0.0%", "amount": "0.00" } ], @@ -106,7 +104,7 @@ "notes": [ { "key": "general", - "text": "This is a sample invoice" + "text": "This is an invoice exempt from tax" } ] } diff --git a/test/data/out/cred-note-base.xml b/test/data/out/cred-note-base.xml index 92127e6..107465c 100755 --- a/test/data/out/cred-note-base.xml +++ b/test/data/out/cred-note-base.xml @@ -25,7 +25,7 @@ 10-01-2022 - Some random description + This is a credit note with a standard tax T Provide One S.L. diff --git a/test/data/out/cred-note-exemption.xml b/test/data/out/cred-note-exemption.xml index d476326..46f78fb 100644 --- a/test/data/out/cred-note-exemption.xml +++ b/test/data/out/cred-note-exemption.xml @@ -25,7 +25,7 @@ 10-01-2022 - Some random description + This is a credit note with an exemption T Provide One S.L. diff --git a/test/data/out/inv-base.xml b/test/data/out/inv-base.xml index 50bf52e..ae35a95 100755 --- a/test/data/out/inv-base.xml +++ b/test/data/out/inv-base.xml @@ -17,7 +17,7 @@ Invopop S.L. F1 - This is a sample invoice + This is a sample invoice with a standard tax T Invopop S.L. diff --git a/test/data/out/inv-eqv-sur-b2c.xml b/test/data/out/inv-eqv-sur-b2c.xml index 16c5acc..5fef434 100644 --- a/test/data/out/inv-eqv-sur-b2c.xml +++ b/test/data/out/inv-eqv-sur-b2c.xml @@ -17,7 +17,7 @@ Invopop S.L. F1 - This is a sample invoice + This is a sample B2C invoice with a standard tax and an equivalence surcharge S T diff --git a/test/data/out/inv-eqv-sur.xml b/test/data/out/inv-eqv-sur.xml index dc4b095..86b7b6b 100755 --- a/test/data/out/inv-eqv-sur.xml +++ b/test/data/out/inv-eqv-sur.xml @@ -17,7 +17,7 @@ Invopop S.L. F1 - This is a sample invoice + This is a sample invoice with a standard tax and an equivalence surcharge T Invopop S.L. diff --git a/test/data/out/inv-rev-charge.xml b/test/data/out/inv-rev-charge.xml index 71ede78..0ee84c6 100644 --- a/test/data/out/inv-rev-charge.xml +++ b/test/data/out/inv-rev-charge.xml @@ -17,7 +17,7 @@ Invopop S.L. F1 - This is a sample invoice + This is a sample invoice with a reverse charge T Invopop S.L. @@ -34,13 +34,11 @@ 01 01 S2 - 21 1800 - 378 - 378 - 2178 + 0 + 1800 B12345678 @@ -62,7 +60,7 @@ 2024-11-26T05:00:00+01:00 01 - E1DD8953C31CB41244215FAE1EB5ADCB9884856A652A612FEFFC3A6AF092CF72 + E843054910E72C301EB910265BC4903BBE523F342F93341A8704F830A747F333 diff --git a/test/data/out/inv-zero-tax.xml b/test/data/out/inv-zero-tax.xml index 003d48d..e94b36f 100644 --- a/test/data/out/inv-zero-tax.xml +++ b/test/data/out/inv-zero-tax.xml @@ -17,7 +17,7 @@ Invopop S.L. F1 - This is a sample invoice + This is an invoice exempt from tax T Invopop S.L. From 3be767d3c9236c98feecddf785b13f0bf0901103 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 10 Dec 2024 10:49:15 +0000 Subject: [PATCH 38/56] Update README --- README.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e5b8e81..d8a12cf 100644 --- a/README.md +++ b/README.md @@ -173,14 +173,13 @@ In order to provide the supplier specific data required by VeriFactu, invoices n Invoice tax tags can be added to invoice documents in order to reflect a special situation. The following schemes are supported: -- `simplified` - a retailer operating under a simplified tax regime (regimen simplificado) that must indicate that all of their sales are under this scheme. This implies that all operations in the invoice will have the `FacturaSinIdentifDestinatarioArt61d` tag set to `S` and the `TipoFactura` field set to `F2` in case of a regular invoice and `R5` in case of a corrective invoice. -- `substitution` - A simplified invoice that is being replaced by a standard invoice. Called a `Factura en Sustitución de Facturas Simplificadas` in VeriFactu. The `TipoFactura` field will be set to `F3`. +- `simplified` - a retailer operating under a simplified tax regime (regimen simplificado). This implies that all operations in the invoice will have the `FacturaSinIdentifDestinatarioArt61d` tag set to `S` and the `TipoFactura` field set to `F2`. For corrective invoices, and credit and debit notes, the `TipoFactura` should instead be set to `R5` through the `es-verifactu-doc-type` extension. ### Tax Extensions The following extensions must be added to the document: -- `es-verifactu-doc-type` – defines the type of invoice being sent. In most cases this will be set automatically by GOBL, but it must be present. These are the valid values: +- `es-verifactu-doc-type` – defines the type of invoice being sent. In most cases this will be set automatically by GOBL, but it must be present. It is taken from list L2 of the VeriFactu Ministerial Order. These are the valid values: - `F1` - Standard invoice. - `F2` - Simplified invoice. - `F3` - Invoice in substitution of simplified invoices. @@ -191,11 +190,13 @@ The following extensions must be added to the document: - `R5` - Rectified invoice based on simplified invoices. -- `es-verifactu-tax-classification` - combines the tax classification and exemption codes used in VeriFactu. Must be included in each line item, or an error will be raised. These are the valid values: +- `es-verifactu-op-class` - Operation classification code used to identify if taxes should be applied to the line. It is taken from list L9 of the VeriFactu Ministerial Order. These are the valid values: - `S1` - Subject and not exempt - Without reverse charge - `S2` - Subject and not exempt - With reverse charge. Known as `Inversión del Sujeto Pasivo` in Spanish VAT Law - `N1` - Not subject - Articles 7, 14, others - `N2` - Not subject - Due to location rules + +- `es-verifactu-exempt` - Exemption code used to identify if the line item is exempt from taxes. It is taken from list L10 of the VeriFactu Ministerial Order. These are the valid values: - `E1` - Exempt pursuant to Article 20 of the VAT Law - `E2` - Exempt pursuant to Article 21 of the VAT Law - `E3` - Exempt pursuant to Article 22 of the VAT Law @@ -203,7 +204,30 @@ The following extensions must be added to the document: - `E5` - Exempt pursuant to Article 25 of the VAT Law - `E6` - Exempt for other reasons -As a small consideration GOBL's tax internal tax framework differentiates between `exempt` and `zero-rated` taxes. In VeriFactu, GOBL `zero-rated` taxes refer to `Exenciones` (values `E1` to `E6` in the list above) and `exempt` taxes refer to `No Sujeto` (values `N1` and `N2` in the list above). +- `es-verifactu-correction-type` - Differentiates between the correction method. Corrective invoices in VeriFactu can be *Facturas Rectificativas por Diferencias* or *Facturas Rectificativas por Sustitución*. It is taken from list L3 of the VeriFactu Ministerial Order. These are the valid values: + - `I` - Differences. Used for credit and debit notes. In case of credit notes the values of the invoice are inverted to reflect the amount being a credit instead of a debit. + - `S` - Substitution. Used for corrective invoices. + + +- `es-verifactu-regime` - Regime code used to identify the type of VAT/IGIC regime to be applied to the invoice. It combines the values of lists L8A and L8B of the VeriFactu Ministerial Order. These are the valid values: + - `01` - General regime operation + - `02` - Export + - `03` - Special regime for used goods, art objects, antiques and collectibles + - `04` - Special regime for investment gold + - `05` - Special regime for travel agencies + - `06` - Special regime for VAT/IGIC groups (Advanced Level) + - `07` - Special cash accounting regime + - `08` - Operations subject to a different regime + - `09` - Billing of travel agency services acting as mediators in name and on behalf of others + - `10` - Collection of professional fees or rights on behalf of third parties + - `11` - Business premises rental operations + - `14` - Invoice with pending VAT/IGIC accrual in work certifications for Public Administration + - `15` - Invoice with pending VAT/IGIC accrual in successive tract operations + - `17` - Operation under OSS and IOSS regimes (VAT) / Special regime for retail traders. (IGIC) + - `18` - Equivalence surcharge (VAT) / Special regime for small traders or retailers (IGIC) + - `19` - Operations included in the Special Regime for Agriculture, Livestock and Fisheries + - `20` - Simplified regime (VAT only) + ## Limitations From a0855b5873dbfdb9bb339cec80054c7f303191ab Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 10:28:39 +0000 Subject: [PATCH 39/56] Added Changes --- .github/workflows/lint.yaml | 2 +- cmd/gobl.verifactu/root.go | 18 ++++++++-------- doc/breakdown.go | 4 ++-- doc/cancel.go | 4 ++-- doc/doc.go | 20 ++---------------- doc/document.go | 18 ++++++++-------- doc/errors.go | 2 +- doc/fingerprint.go | 24 ++++++++++----------- doc/fingerprint_test.go | 40 +++++++++++++++++------------------ doc/invoice.go | 10 ++++----- examples_test.go | 26 +++++++++++------------ internal/gateways/gateways.go | 8 +++---- test/example_prev.json | 8 +++---- verifactu.go | 22 +++---------------- 14 files changed, 87 insertions(+), 119 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 63a17b6..1e42771 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -24,4 +24,4 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v6 with: - version: v1.58 + version: v1.61 diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 046ac4a..4d77483 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -55,15 +55,15 @@ func (o *rootOpts) prepareFlags(f *pflag.FlagSet) { func (o *rootOpts) software() *doc.Software { return &doc.Software{ - NIF: o.swNIF, - NombreRazon: o.swNombreRazon, - Version: o.swVersion, - IdSistemaInformatico: o.swIDSistemaInformatico, - NumeroInstalacion: o.swNumeroInstalacion, - NombreSistemaInformatico: o.swName, - TipoUsoPosibleSoloVerifactu: "S", - TipoUsoPosibleMultiOT: "S", - IndicadorMultiplesOT: "N", + TaxID: o.swNIF, + CompanyName: o.swNombreRazon, + Version: o.swVersion, + SoftwareID: o.swIDSistemaInformatico, + InstallationNumber: o.swNumeroInstalacion, + SoftwareName: o.swName, + VerifactuOnlyUsageType: "S", + MultiOTUsageType: "S", + MultiOTIndicator: "N", } } diff --git a/doc/breakdown.go b/doc/breakdown.go index 512b39a..a65143e 100644 --- a/doc/breakdown.go +++ b/doc/breakdown.go @@ -52,7 +52,7 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl } if r.Ext == nil { - return nil, fmt.Errorf("missing tax extensions for rate %s", r.Key) + return nil, ErrValidation.WithMessage(fmt.Sprintf("missing tax extensions for rate %s", r.Key)) } if r.Percent == nil && r.Ext.Has(verifactu.ExtKeyExempt) { @@ -70,7 +70,7 @@ func buildDetalleDesglose(c *tax.CategoryTotal, r *tax.RateTotal) (*DetalleDesgl } if detalle.OperacionExenta == "" && detalle.CalificacionOperacion == "" { - return nil, fmt.Errorf("missing operation classification for rate %s", r.Key) + return nil, ErrValidation.WithMessage(fmt.Sprintf("missing operation classification for rate %s", r.Key)) } if r.Key.Has(es.TaxRateEquivalence) { diff --git a/doc/cancel.go b/doc/cancel.go index c512245..8308488 100644 --- a/doc/cancel.go +++ b/doc/cancel.go @@ -6,8 +6,8 @@ import ( "github.com/invopop/gobl/bill" ) -// NewRegistroAnulacion provides support for cancelling invoices -func NewRegistroAnulacion(inv *bill.Invoice, ts time.Time, s *Software) (*RegistroAnulacion, error) { +// NewCancel provides support for cancelling invoices +func NewCancel(inv *bill.Invoice, ts time.Time, s *Software) (*RegistroAnulacion, error) { reg := &RegistroAnulacion{ IDVersion: CurrentVersion, IDFactura: &IDFacturaAnulada{ diff --git a/doc/doc.go b/doc/doc.go index d5dca73..5f96b59 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -28,22 +28,6 @@ const ( CurrentVersion = "1.0" ) -// ValidationError is a simple wrapper around validation errors -type ValidationError struct { - text string -} - -// Error implements the error interface for ValidationError. -func (e *ValidationError) Error() string { - return e.text -} - -func validationErr(text string, args ...any) error { - return &ValidationError{ - text: fmt.Sprintf(text, args...), - } -} - func init() { var err error location, err = time.LoadLocation("Europe/Madrid") @@ -76,13 +60,13 @@ func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c } if c { - reg, err := NewRegistroAnulacion(inv, ts, s) + reg, err := NewCancel(inv, ts, s) if err != nil { return nil, err } doc.RegistroFactura.RegistroAnulacion = reg } else { - reg, err := NewRegistroAlta(inv, ts, r, s) + reg, err := NewInvoice(inv, ts, r, s) if err != nil { return nil, err } diff --git a/doc/document.go b/doc/document.go index d63e7e4..00ebb37 100644 --- a/doc/document.go +++ b/doc/document.go @@ -201,13 +201,13 @@ type RegistroAnterior struct { // generate VeriFactu documents. These details are included in the final // document. type Software struct { - NombreRazon string `xml:"sum1:NombreRazon"` - NIF string `xml:"sum1:NIF"` - NombreSistemaInformatico string `xml:"sum1:NombreSistemaInformatico"` - IdSistemaInformatico string `xml:"sum1:IdSistemaInformatico"` //nolint:revive - Version string `xml:"sum1:Version"` - NumeroInstalacion string `xml:"sum1:NumeroInstalacion"` - TipoUsoPosibleSoloVerifactu string `xml:"sum1:TipoUsoPosibleSoloVerifactu,omitempty"` - TipoUsoPosibleMultiOT string `xml:"sum1:TipoUsoPosibleMultiOT,omitempty"` - IndicadorMultiplesOT string `xml:"sum1:IndicadorMultiplesOT,omitempty"` + CompanyName string `xml:"sum1:NombreRazon"` + TaxID string `xml:"sum1:NIF"` + SoftwareName string `xml:"sum1:NombreSistemaInformatico"` + SoftwareID string `xml:"sum1:IdSistemaInformatico"` //nolint:revive + Version string `xml:"sum1:Version"` + InstallationNumber string `xml:"sum1:NumeroInstalacion"` + VerifactuOnlyUsageType string `xml:"sum1:TipoUsoPosibleSoloVerifactu,omitempty"` + MultiOTUsageType string `xml:"sum1:TipoUsoPosibleMultiOT,omitempty"` + MultiOTIndicator string `xml:"sum1:IndicadorMultiplesOT,omitempty"` } diff --git a/doc/errors.go b/doc/errors.go index 77120ce..7f1c573 100644 --- a/doc/errors.go +++ b/doc/errors.go @@ -59,7 +59,7 @@ var ErrorCodes = map[string]string{ // Standard gateway error responses var ( ErrConnection = newError("connection") - ErrInvalid = newError("invalid") + ErrValidation = newError("validation") ErrDuplicate = newError("duplicate") ) diff --git a/doc/fingerprint.go b/doc/fingerprint.go index e137373..52085d4 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -15,10 +15,10 @@ const TipoHuella = "01" // required for fingerprinting the next invoice. JSON tags are // provided to help with serialization. type ChainData struct { - IDEmisorFactura string `json:"emisor"` - NumSerieFactura string `json:"serie"` - FechaExpedicionFactura string `json:"fecha"` - Huella string `json:"huella"` + IDIssuer string `json:"issuer"` + NumSeries string `json:"num_series"` + IssueDate string `json:"issue_date"` + Fingerprint string `json:"fingerprint"` } func formatField(key, value string) string { @@ -90,10 +90,10 @@ func (d *VeriFactu) generateHashAlta(prev *ChainData) error { // Concatenate f according to Verifactu specifications d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ RegistroAnterior: &RegistroAnterior{ - IDEmisorFactura: prev.IDEmisorFactura, - NumSerieFactura: prev.NumSerieFactura, - FechaExpedicionFactura: prev.FechaExpedicionFactura, - Huella: prev.Huella, + IDEmisorFactura: prev.IDIssuer, + NumSerieFactura: prev.NumSeries, + FechaExpedicionFactura: prev.IssueDate, + Huella: prev.Fingerprint, }, } if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { @@ -114,10 +114,10 @@ func (d *VeriFactu) generateHashAnulacion(prev *ChainData) error { } d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ RegistroAnterior: &RegistroAnterior{ - IDEmisorFactura: prev.IDEmisorFactura, - NumSerieFactura: prev.NumSerieFactura, - FechaExpedicionFactura: prev.FechaExpedicionFactura, - Huella: prev.Huella, + IDEmisorFactura: prev.IDIssuer, + NumSerieFactura: prev.NumSeries, + FechaExpedicionFactura: prev.IssueDate, + Huella: prev.Fingerprint, }, } if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go index ef3b0bd..6523459 100644 --- a/doc/fingerprint_test.go +++ b/doc/fingerprint_test.go @@ -28,10 +28,10 @@ func TestFingerprintAlta(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-20T19:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "A28083806", - NumSerieFactura: "SAMPLE-000", - FechaExpedicionFactura: "10-11-2024", - Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + IDIssuer: "A28083806", + NumSeries: "SAMPLE-000", + IssueDate: "10-11-2024", + Fingerprint: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, expected: "9F848AF7AECAA4C841654B37FD7119F4530B19141A2C3FF9968B5A229DEE21C2", }, @@ -49,10 +49,10 @@ func TestFingerprintAlta(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "A28083806", - NumSerieFactura: "SAMPLE-001", - FechaExpedicionFactura: "11-11-2024", - Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + IDIssuer: "A28083806", + NumSeries: "SAMPLE-001", + IssueDate: "11-11-2024", + Fingerprint: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, expected: "14543C022CBD197F247F77A88F41E636A3B2569CE5787A8D6C8A781BF1B9D25E", }, @@ -88,10 +88,10 @@ func TestFingerprintAlta(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-21T17:59:41+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "A28083806", - NumSerieFactura: "SAMPLE-002", - FechaExpedicionFactura: "11-11-2024", - Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C", + IDIssuer: "A28083806", + NumSeries: "SAMPLE-002", + IssueDate: "11-11-2024", + Fingerprint: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C", }, expected: "9F44F498EA51C0C50FEB026CCE86BDCCF852C898EE33336EFFE1BD6F132B506E", }, @@ -138,10 +138,10 @@ func TestFingerprintAnulacion(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "A28083806", - NumSerieFactura: "SAMPLE-000", - FechaExpedicionFactura: "10-11-2024", - Huella: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + IDIssuer: "A28083806", + NumSeries: "SAMPLE-000", + IssueDate: "10-11-2024", + Fingerprint: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", }, expected: "F5AB85A94450DF8752F4A7840C72456B753010E5EC1F26D8EAE0D4523E287948", }, @@ -156,10 +156,10 @@ func TestFingerprintAnulacion(t *testing.T) { FechaHoraHusoGenRegistro: "2024-11-21T12:00:55+01:00", }, prev: &doc.ChainData{ - IDEmisorFactura: "A28083806", - NumSerieFactura: "SAMPLE-001", - FechaExpedicionFactura: "11-11-2024", - Huella: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", + IDIssuer: "A28083806", + NumSeries: "SAMPLE-001", + IssueDate: "11-11-2024", + Fingerprint: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", }, expected: "E86A5172477A636958B2F98770FB796BEEDA43F3F1C6A1C601EC3EEDF9C033B1", }, diff --git a/doc/invoice.go b/doc/invoice.go index 5e49e9e..f9d62e7 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -13,12 +13,12 @@ import ( "github.com/invopop/validation" ) -var docTypesCreditDebit = []tax.ExtValue{ // Credit or Debit notes +var rectificative = []tax.ExtValue{ // Credit or Debit notes "R1", "R2", "R3", "R4", "R5", } -// NewRegistroAlta creates a new VeriFactu registration for an invoice. -func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { +// NewInvoice creates a new VeriFactu registration for an invoice. +func NewInvoice(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) (*RegistroAlta, error) { tf, err := getTaxKey(inv, verifactu.ExtKeyDocType) if err != nil { return nil, err @@ -65,7 +65,7 @@ func NewRegistroAlta(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software) reg.FacturaSinIdentifDestinatarioArt61d = "S" } - if inv.Tax.Ext[verifactu.ExtKeyDocType].In(docTypesCreditDebit...) { + if inv.Tax.Ext[verifactu.ExtKeyDocType].In(rectificative...) { // GOBL does not currently have explicit support for Facturas Rectificativas por Sustitución k, err := getTaxKey(inv, verifactu.ExtKeyCorrectionType) if err != nil { @@ -133,7 +133,7 @@ func newDescription(notes []*cbc.Note) (string, error) { return note.Text, nil } } - return "", validationErr(`notes: missing note with key '%s'`, cbc.NoteKeyGeneral) + return "", ErrValidation.WithMessage(fmt.Sprintf("notes: missing note with key '%s'", cbc.NoteKeyGeneral)) } func newImporteTotal(inv *bill.Invoice) float64 { diff --git a/examples_test.go b/examples_test.go index f861fed..1ca89c1 100644 --- a/examples_test.go +++ b/examples_test.go @@ -43,10 +43,10 @@ func TestXMLGeneration(t *testing.T) { // Example Data to Test the Fingerprint. prev := &doc.ChainData{ - IDEmisorFactura: "B12345678", - NumSerieFactura: "SAMPLE-001", - FechaExpedicionFactura: "26-11-2024", - Huella: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", + IDIssuer: "B12345678", + NumSeries: "SAMPLE-001", + IssueDate: "26-11-2024", + Fingerprint: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", } err = c.Fingerprint(td, prev) @@ -109,15 +109,15 @@ func loadClient() (*verifactu.Client, error) { } return verifactu.New(&doc.Software{ - NombreRazon: "My Software", - NIF: "12345678A", - NombreSistemaInformatico: "My Software", - IdSistemaInformatico: "A1", - Version: "1.0", - NumeroInstalacion: "12345678A", - TipoUsoPosibleSoloVerifactu: "S", - TipoUsoPosibleMultiOT: "S", - IndicadorMultiplesOT: "N", + CompanyName: "My Software", + TaxID: "12345678A", + SoftwareName: "My Software", + SoftwareID: "A1", + Version: "1.0", + InstallationNumber: "12345678A", + VerifactuOnlyUsageType: "S", + MultiOTUsageType: "S", + MultiOTIndicator: "N", }, verifactu.WithCurrentTime(ts), verifactu.WithThirdPartyIssuer(), diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 0659eac..f502ee2 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -37,14 +37,14 @@ type Connection struct { func New(env Environment, cert *xmldsig.Certificate) (*Connection, error) { tlsConf, err := cert.TLSAuthConfig() if err != nil { - return nil, fmt.Errorf("preparing TLS config: %w", err) + return nil, doc.ErrValidation.WithMessage(fmt.Errorf("preparing TLS config: %v", err).Error()) } c := new(Connection) c.client = resty.New() switch env { case EnvironmentProduction: - c.client.SetBaseURL(ProductionBaseURL) + return nil, doc.ErrValidation.WithMessage("production environment not available yet") default: c.client.SetBaseURL(TestingBaseURL) } @@ -78,10 +78,10 @@ func (c *Connection) post(ctx context.Context, path string, payload []byte) erro return err } if res.StatusCode() != http.StatusOK { - return doc.ErrInvalid.WithCode(strconv.Itoa(res.StatusCode())) + return doc.ErrValidation.WithCode(strconv.Itoa(res.StatusCode())) } if out.Body.Respuesta.EstadoEnvio != correctStatus { - err := doc.ErrInvalid.WithCode(strconv.Itoa(res.StatusCode())) + err := doc.ErrValidation.WithCode(strconv.Itoa(res.StatusCode())) if len(out.Body.Respuesta.RespuestaLinea) > 0 { e1 := out.Body.Respuesta.RespuestaLinea[0] err = err.WithMessage(e1.DescripcionErrorRegistro).WithCode(e1.CodigoErrorRegistro) diff --git a/test/example_prev.json b/test/example_prev.json index f936530..dcfa6f6 100644 --- a/test/example_prev.json +++ b/test/example_prev.json @@ -1,7 +1,7 @@ { - "emisor": "B123456789", - "serie": "FACT-001", - "fecha": "2024-11-15", - "huella": "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" + "issuer": "B123456789", + "num_series": "FACT-001", + "issue_date": "2024-11-15", + "fingerprint": "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" } diff --git a/verifactu.go b/verifactu.go index fd9f77b..d2db124 100644 --- a/verifactu.go +++ b/verifactu.go @@ -4,7 +4,6 @@ package verifactu import ( "context" "encoding/xml" - "errors" "time" "github.com/invopop/gobl.verifactu/doc" @@ -14,26 +13,11 @@ import ( // Standard error responses. var ( - ErrNotSpanish = newValidationError("only spanish invoices are supported") - ErrAlreadyProcessed = newValidationError("already processed") - ErrOnlyInvoices = newValidationError("only invoices are supported") + ErrNotSpanish = doc.ErrValidation.WithMessage("only spanish invoices are supported") + ErrAlreadyProcessed = doc.ErrValidation.WithMessage("already processed") + ErrOnlyInvoices = doc.ErrValidation.WithMessage("only invoices are supported") ) -// ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed -// to server-side errors (that should be retried). -type ValidationError struct { - err error -} - -// Error implements the error interface for ClientError. -func (e *ValidationError) Error() string { - return e.err.Error() -} - -func newValidationError(text string) error { - return &ValidationError{errors.New(text)} -} - // Client provides the main interface to the VeriFactu package. type Client struct { software *doc.Software From a81851d50bb5db35cd0f8276f0169b4c5efe7d99 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 10:35:43 +0000 Subject: [PATCH 40/56] Fix tax.ExtValue -> cbc.Code --- doc/invoice.go | 3 +-- go.mod | 6 +++--- go.sum | 6 ++++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/doc/invoice.go b/doc/invoice.go index f9d62e7..f45e2ed 100644 --- a/doc/invoice.go +++ b/doc/invoice.go @@ -9,11 +9,10 @@ import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/num" - "github.com/invopop/gobl/tax" "github.com/invopop/validation" ) -var rectificative = []tax.ExtValue{ // Credit or Debit notes +var rectificative = []cbc.Code{ // Credit or Debit notes "R1", "R2", "R3", "R4", "R5", } diff --git a/go.mod b/go.mod index bf8b58f..2405d1d 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ toolchain go1.22.1 require ( github.com/go-resty/resty/v2 v2.15.3 - github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9 + github.com/invopop/gobl v0.207.0 github.com/invopop/validation v0.8.0 github.com/invopop/xmldsig v0.10.0 github.com/joho/godotenv v1.5.1 @@ -33,12 +33,12 @@ require ( github.com/invopop/jsonschema v0.12.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/crypto v0.30.0 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/net v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect software.sslmate.com/src/go-pkcs12 v0.4.0 // indirect diff --git a/go.sum b/go.sum index 9faded1..78ce566 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9 h1:VIIyZspyf68JHHHQ6uk5Z2AufYPzkqCz1gF4WGRh32g= github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= +github.com/invopop/gobl v0.207.0 h1:Gv6aUYKLdwuAw9HOhkk8eUztsYXPbPy6e/DBoBUDXmI= +github.com/invopop/gobl v0.207.0/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.8.0 h1:e5hXHGnONHImgJdonIpNbctg1hlWy1ncaHoVIQ0JWuw= @@ -47,6 +49,8 @@ github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 h1:1okRi+ZpH92VkeIJPJWfoNVXm3F4225PoT1LSO+gFfw= github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80/go.mod h1:990JnYmJZFrx1vI1TALoD6/fCqnWlTx2FrPbYy2wi5I= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -72,6 +76,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= From ab48cb10d4d901acfcd44b05d217573f2795f645 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 10:37:31 +0000 Subject: [PATCH 41/56] Fix --- doc/fingerprint.go | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/fingerprint.go b/doc/fingerprint.go index 52085d4..ab7cc93 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -76,7 +76,6 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { return nil } -// GenerateHash generates the SHA-256 hash for the invoice data. func (d *VeriFactu) generateHashAlta(prev *ChainData) error { if prev == nil { d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ From faf25aca0c69a8c143bfb4fe1328799940bf5de7 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 10:40:01 +0000 Subject: [PATCH 42/56] Remove Custom Err Messages --- README.md | 2 +- doc/errors.go | 51 --------------------------------------------------- 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/README.md b/README.md index d8a12cf..5f61ddf 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Invoice tax tags can be added to invoice documents in order to reflect a special ### Tax Extensions -The following extensions must be added to the document: +The following extensions must be included in the document. Note that the GOBL addon will automatically add these extensions when processing invoices: - `es-verifactu-doc-type` – defines the type of invoice being sent. In most cases this will be set automatically by GOBL, but it must be present. It is taken from list L2 of the VeriFactu Ministerial Order. These are the valid values: - `F1` - Standard invoice. diff --git a/doc/errors.go b/doc/errors.go index 7f1c573..40aca4c 100644 --- a/doc/errors.go +++ b/doc/errors.go @@ -5,57 +5,6 @@ import ( "strings" ) -// ErrorCodes and their descriptions from VeriFactu -var ErrorCodes = map[string]string{ - "4102": "El XML no cumple el esquema. Falta informar campo obligatorio.", - "4103": "Se ha producido un error inesperado al parsear el XML.", - "4104": "Error en la cabecera: el valor del campo NIF del bloque ObligadoEmision no está identificado.", - "4105": "Error en la cabecera: el valor del campo NIF del bloque Representante no está identificado.", - "4106": "El formato de fecha es incorrecto.", - "4107": "El NIF no está identificado en el censo de la AEAT.", - "4108": "Error técnico al obtener el certificado.", - "4109": "El formato del NIF es incorrecto.", - "4110": "Error técnico al comprobar los apoderamientos.", - "4111": "Error técnico al crear el trámite.", - "4112": "El titular del certificado debe ser Obligado Emisión, Colaborador Social, Apoderado o Sucesor.", - "4113": "El XML no cumple con el esquema: se ha superado el límite permitido de registros para el bloque.", - "4114": "El XML no cumple con el esquema: se ha superado el límite máximo permitido de facturas a registrar.", - "4115": "El valor del campo NIF del bloque ObligadoEmision es incorrecto.", - "4116": "Error en la cabecera: el campo NIF del bloque ObligadoEmision tiene un formato incorrecto.", - "4117": "Error en la cabecera: el campo NIF del bloque Representante tiene un formato incorrecto.", - "4118": "Error técnico: la dirección no se corresponde con el fichero de entrada.", - "4119": "Error al informar caracteres cuya codificación no es UTF-8.", - "4120": "Error en la cabecera: el valor del campo FechaFinVeriFactu es incorrecto, debe ser 31-12-20XX, donde XX corresponde con el año actual o el anterior.", - "4121": "Error en la cabecera: el valor del campo Incidencia es incorrecto.", - "4122": "Error en la cabecera: el valor del campo RefRequerimiento es incorrecto.", - "4123": "Error en la cabecera: el valor del campo NIF del bloque Representante no está identificado en el censo de la AEAT.", - "4124": "Error en la cabecera: el valor del campo Nombre del bloque Representante no está identificado en el censo de la AEAT.", - "4125": "Error en la cabecera: el campo RefRequerimiento es obligatorio.", - "4126": "Error en la cabecera: el campo RefRequerimiento solo debe informarse en sistemas No VERIFACTU.", - "4127": "Error en la cabecera: la remisión voluntaria solo debe informarse en sistemas VERIFACTU.", - "4128": "Error técnico en la recuperación del valor del Gestor de Tablas.", - "4129": "Error en la cabecera: el campo FinRequerimiento es obligatorio.", - "4130": "Error en la cabecera: el campo FinRequerimiento solo debe informarse en sistemas No VERIFACTU.", - "4131": "Error en la cabecera: el valor del campo FinRequerimiento es incorrecto.", - "4132": "El titular del certificado debe ser el destinatario que realiza la consulta, un Apoderado o Sucesor", - "3500": "Error técnico de base de datos: error en la integridad de la información.", - "3501": "Error técnico de base de datos.", - "3502": "La factura consultada para el suministro de pagos/cobros/inmuebles no existe.", - "3503": "La factura especificada no pertenece al titular registrado en el sistema.", - "1100": "Valor o tipo incorrecto del campo.", - "1101": "El valor del campo CodigoPais es incorrecto.", - "1102": "El valor del campo IDType es incorrecto.", - "1103": "El valor del campo ID es incorrecto.", - "1104": "El valor del campo NumSerieFactura es incorrecto.", - "1105": "El valor del campo FechaExpedicionFactura es incorrecto.", - "1106": "El valor del campo TipoFactura no está incluido en la lista de valores permitidos.", - "1107": "El valor del campo TipoRectificativa es incorrecto.", - "1108": "El NIF del IDEmisorFactura debe ser el mismo que el NIF del ObligadoEmision.", - "1109": "El NIF no está identificado en el censo de la AEAT.", - "1110": "El NIF no está identificado en el censo de la AEAT.", - "1111": "El campo CodigoPais es obligatorio cuando IDType es distinto de 02.", -} - // Standard gateway error responses var ( ErrConnection = newError("connection") From 738f733b3a57406ba1428897d7d28803b6c5f2a8 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 11:54:40 +0000 Subject: [PATCH 43/56] Update ParseDocument to take Unenveloped entries --- verifactu.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/verifactu.go b/verifactu.go index d2db124..493cda7 100644 --- a/verifactu.go +++ b/verifactu.go @@ -121,6 +121,13 @@ func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { // ParseDocument will parse the XML data into a VeriFactu document. func ParseDocument(data []byte) (*doc.VeriFactu, error) { + // First try to parse as enveloped document + env := new(doc.Envelope) + if err := xml.Unmarshal(data, env); err == nil && env.Body.VeriFactu != nil { + return env.Body.VeriFactu, nil + } + + // If that fails, try parsing as non-enveloped document d := new(doc.VeriFactu) if err := xml.Unmarshal(data, d); err != nil { return nil, err From 3022bea807d87d6ad73b9c30aa810d65bf53d4eb Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 12:15:17 +0000 Subject: [PATCH 44/56] Now ParseDocument takes only unenveloped docs --- verifactu.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/verifactu.go b/verifactu.go index 493cda7..d2db124 100644 --- a/verifactu.go +++ b/verifactu.go @@ -121,13 +121,6 @@ func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { // ParseDocument will parse the XML data into a VeriFactu document. func ParseDocument(data []byte) (*doc.VeriFactu, error) { - // First try to parse as enveloped document - env := new(doc.Envelope) - if err := xml.Unmarshal(data, env); err == nil && env.Body.VeriFactu != nil { - return env.Body.VeriFactu, nil - } - - // If that fails, try parsing as non-enveloped document d := new(doc.VeriFactu) if err := xml.Unmarshal(data, d); err != nil { return nil, err From a952b4c82a6e41210f04365cabc735509d1a8d17 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 17:42:22 +0000 Subject: [PATCH 45/56] Removed unenveloped docs --- doc/document.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/document.go b/doc/document.go index 00ebb37..506d6a6 100644 --- a/doc/document.go +++ b/doc/document.go @@ -25,7 +25,7 @@ type Envelope struct { // VeriFactu represents the root element of a VeriFactu document type VeriFactu struct { - XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"` + // XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"` Cabecera *Cabecera `xml:"sum:Cabecera"` RegistroFactura *RegistroFactura `xml:"sum:RegistroFactura"` } From 14078a44fe0d38836fd52caf491e5c800602f34a Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 19:21:35 +0000 Subject: [PATCH 46/56] Removed Unenveloped Struct --- cancel.go | 4 +- cmd/gobl.verifactu/convert.go | 2 +- doc/breakdown_test.go | 74 +++++++-------- doc/cancel_test.go | 2 +- doc/doc.go | 95 +++++++++++-------- doc/doc_test.go | 12 +-- doc/document.go | 14 +-- doc/fingerprint.go | 28 +++--- doc/fingerprint_test.go | 20 ++-- doc/invoice_test.go | 8 +- doc/party_test.go | 6 +- doc/qr_code.go | 10 +- doc/qr_code_test.go | 86 ++++++++++------- document.go | 6 +- examples_test.go | 129 ++++++++++++++++---------- internal/gateways/gateways.go | 4 +- test/data/out/cred-note-base.xml | 1 + test/data/out/cred-note-exemption.xml | 1 + test/data/out/inv-base.xml | 1 + test/data/out/inv-eqv-sur-b2c.xml | 1 + test/data/out/inv-eqv-sur.xml | 1 + test/data/out/inv-rev-charge.xml | 1 + test/data/out/inv-zero-tax.xml | 1 + verifactu.go | 6 +- 24 files changed, 297 insertions(+), 216 deletions(-) diff --git a/cancel.go b/cancel.go index afde320..b99615c 100644 --- a/cancel.go +++ b/cancel.go @@ -11,7 +11,7 @@ import ( // GenerateCancel creates a new cancellation document from the provided // GOBL Envelope. -func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { +func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.Envelope, error) { // Extract the Invoice inv, ok := env.Extract().(*bill.Invoice) if !ok { @@ -32,6 +32,6 @@ func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.VeriFactu, error) { // FingerprintCancel generates a fingerprint for the cancellation document using the // data provided from the previous chain data. The document is updated in place. -func (c *Client) FingerprintCancel(d *doc.VeriFactu, prev *doc.ChainData) error { +func (c *Client) FingerprintCancel(d *doc.Envelope, prev *doc.ChainData) error { return d.FingerprintCancel(prev) } diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go index e45b03a..c1f1ef8 100644 --- a/cmd/gobl.verifactu/convert.go +++ b/cmd/gobl.verifactu/convert.go @@ -62,7 +62,7 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("converting to verifactu xml: %w", err) } - data, err := doc.Envelop() + data, err := doc.BytesIndent() if err != nil { return fmt.Errorf("generating verifactu xml: %w", err) } diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go index 9591512..41069ca 100644 --- a/doc/breakdown_test.go +++ b/doc/breakdown_test.go @@ -23,11 +23,11 @@ func TestBreakdownConversion(t *testing.T) { d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 1800.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 378.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 1800.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 378.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("exempt-taxes", func(t *testing.T) { @@ -52,9 +52,9 @@ func TestBreakdownConversion(t *testing.T) { _ = inv.Calculate() d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "E1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].OperacionExenta) + assert.Equal(t, 100.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "E1", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].OperacionExenta) }) t.Run("multiple-tax-rates", func(t *testing.T) { @@ -95,15 +95,15 @@ func TestBreakdownConversion(t *testing.T) { _ = inv.Calculate() d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 21.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, 100.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 21.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, 50.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 5.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].CuotaRepercutida) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].Impuesto) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].ClaveRegimen) + assert.Equal(t, 50.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 5.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].CuotaRepercutida) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].Impuesto) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[1].ClaveRegimen) }) t.Run("not-subject-taxes", func(t *testing.T) { @@ -129,11 +129,11 @@ func TestBreakdownConversion(t *testing.T) { _ = inv.Calculate() d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 0.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "N1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 100.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 0.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "N1", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("equivalence-surcharge", func(t *testing.T) { @@ -159,12 +159,12 @@ func TestBreakdownConversion(t *testing.T) { _ = inv.Calculate() d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 21.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, 5.20, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRecargoEquivalencia) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 100.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 21.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, 5.20, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRecargoEquivalencia) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("ipsi-tax", func(t *testing.T) { @@ -190,11 +190,11 @@ func TestBreakdownConversion(t *testing.T) { _ = inv.Calculate() d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 10.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "03", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Empty(t, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 100.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 10.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "03", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Empty(t, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) t.Run("antiques", func(t *testing.T) { @@ -220,10 +220,10 @@ func TestBreakdownConversion(t *testing.T) { _ = inv.Calculate() d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) require.NoError(t, err) - assert.Equal(t, 1000.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) - assert.Equal(t, 100.00, d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) - assert.Equal(t, "01", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) - assert.Equal(t, "04", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) - assert.Equal(t, "S1", d.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) + assert.Equal(t, 1000.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].BaseImponibleOImporteNoSujeto) + assert.Equal(t, 100.00, d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CuotaRepercutida) + assert.Equal(t, "01", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].Impuesto) + assert.Equal(t, "04", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].ClaveRegimen) + assert.Equal(t, "S1", d.Body.VeriFactu.RegistroFactura.RegistroAlta.Desglose.DetalleDesglose[0].CalificacionOperacion) }) } diff --git a/doc/cancel_test.go b/doc/cancel_test.go index 8e001bb..013c0e7 100644 --- a/doc/cancel_test.go +++ b/doc/cancel_test.go @@ -17,7 +17,7 @@ func TestNewRegistroAnulacion(t *testing.T) { d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, true) require.NoError(t, err) - ra := d.RegistroFactura.RegistroAnulacion + ra := d.Body.VeriFactu.RegistroFactura.RegistroAnulacion assert.Equal(t, "B85905495", ra.IDFactura.IDEmisorFactura) assert.Equal(t, "FR-012", ra.IDFactura.NumSerieFactura) assert.Equal(t, "01-02-2022", ra.IDFactura.FechaExpedicionFactura) diff --git a/doc/doc.go b/doc/doc.go index 5f96b59..e65b3b4 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -37,8 +37,18 @@ func init() { } // NewVerifactu creates a new VeriFactu document -func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c bool) (*VeriFactu, error) { - doc := &VeriFactu{ +func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c bool) (*Envelope, error) { + + env := &Envelope{ + XMLNs: EnvNamespace, + SUM: SUM, + SUM1: SUM1, + Body: &Body{ + VeriFactu: &RegFactuSistemaFacturacion{}, + }, + } + + doc := &RegFactuSistemaFacturacion{ Cabecera: &Cabecera{ Obligado: Obligado{ NombreRazon: inv.Supplier.Name, @@ -73,82 +83,89 @@ func NewVerifactu(inv *bill.Invoice, ts time.Time, r IssuerRole, s *Software, c doc.RegistroFactura.RegistroAlta = reg } - return doc, nil + env.Body.VeriFactu = doc + + return env, nil } // QRCodes generates the QR code for the document -func (d *VeriFactu) QRCodes(production bool) string { +func (d *Envelope) QRCodes(production bool) string { return d.generateURL(production) } // ChainData generates the data to be used to link to this one // in the next entry. -func (d *VeriFactu) ChainData() Encadenamiento { +func (d *Envelope) ChainData() Encadenamiento { return Encadenamiento{ RegistroAnterior: &RegistroAnterior{ - IDEmisorFactura: d.Cabecera.Obligado.NIF, - NumSerieFactura: d.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura, - FechaExpedicionFactura: d.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura, - Huella: d.RegistroFactura.RegistroAlta.Huella, + IDEmisorFactura: d.Body.VeriFactu.Cabecera.Obligado.NIF, + NumSerieFactura: d.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura, + FechaExpedicionFactura: d.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura, + Huella: d.Body.VeriFactu.RegistroFactura.RegistroAlta.Huella, }, } } // ChainDataCancel generates the data to be used to link to this one // in the next entry for cancelling invoices. -func (d *VeriFactu) ChainDataCancel() Encadenamiento { +func (d *Envelope) ChainDataCancel() Encadenamiento { return Encadenamiento{ RegistroAnterior: &RegistroAnterior{ - IDEmisorFactura: d.Cabecera.Obligado.NIF, - NumSerieFactura: d.RegistroFactura.RegistroAnulacion.IDFactura.NumSerieFactura, - FechaExpedicionFactura: d.RegistroFactura.RegistroAnulacion.IDFactura.FechaExpedicionFactura, - Huella: d.RegistroFactura.RegistroAnulacion.Huella, + IDEmisorFactura: d.Body.VeriFactu.Cabecera.Obligado.NIF, + NumSerieFactura: d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.IDFactura.NumSerieFactura, + FechaExpedicionFactura: d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.IDFactura.FechaExpedicionFactura, + Huella: d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.Huella, }, } } // Fingerprint generates the SHA-256 fingerprint for the document -func (d *VeriFactu) Fingerprint(prev *ChainData) error { +func (d *Envelope) Fingerprint(prev *ChainData) error { return d.generateHashAlta(prev) } // FingerprintCancel generates the SHA-256 fingerprint for the document -func (d *VeriFactu) FingerprintCancel(prev *ChainData) error { +func (d *Envelope) FingerprintCancel(prev *ChainData) error { return d.generateHashAnulacion(prev) } // Bytes returns the XML document bytes -func (d *VeriFactu) Bytes() ([]byte, error) { +func (d *Envelope) Bytes() ([]byte, error) { + return toBytes(d) +} + +// Bytes returns the XML document bytes +func (d *RegFactuSistemaFacturacion) Bytes() ([]byte, error) { return toBytes(d) } // BytesIndent returns the indented XML document bytes -func (d *VeriFactu) BytesIndent() ([]byte, error) { +func (d *Envelope) BytesIndent() ([]byte, error) { return toBytesIndent(d) } // Envelop wraps the VeriFactu document in a SOAP envelope and includes the expected namespaces -func (d *VeriFactu) Envelop() ([]byte, error) { - // Create and set the envelope with namespaces - env := Envelope{ - XMLNs: EnvNamespace, - SUM: SUM, - SUM1: SUM1, - } - env.Body.VeriFactu = d - - // Marshal the SOAP envelope into an XML byte slice - var result bytes.Buffer - enc := xml.NewEncoder(&result) - enc.Indent("", " ") - err := enc.Encode(env) - if err != nil { - return nil, err - } - - // Return the enveloped XML document - return result.Bytes(), nil -} +// func (d *VeriFactu) Envelop() ([]byte, error) { +// // Create and set the envelope with namespaces +// env := Envelope{ +// XMLNs: EnvNamespace, +// SUM: SUM, +// SUM1: SUM1, +// } +// env.Body.VeriFactu = d + +// // Marshal the SOAP envelope into an XML byte slice +// var result bytes.Buffer +// enc := xml.NewEncoder(&result) +// enc.Indent("", " ") +// err := enc.Encode(env) +// if err != nil { +// return nil, err +// } + +// // Return the enveloped XML document +// return result.Bytes(), nil +// } func toBytes(doc any) ([]byte, error) { buf, err := buffer(doc, xml.Header, false) diff --git a/doc/doc_test.go b/doc/doc_test.go index 39fdfdf..b538b45 100644 --- a/doc/doc_test.go +++ b/doc/doc_test.go @@ -21,11 +21,11 @@ func TestInvoiceConversion(t *testing.T) { doc, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - assert.Equal(t, "Invopop S.L.", doc.Cabecera.Obligado.NombreRazon) - assert.Equal(t, "B85905495", doc.Cabecera.Obligado.NIF) - assert.Equal(t, "1.0", doc.RegistroFactura.RegistroAlta.IDVersion) - assert.Equal(t, "B85905495", doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) - assert.Equal(t, "SAMPLE-004", doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) - assert.Equal(t, "13-11-2024", doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) + assert.Equal(t, "Invopop S.L.", doc.Body.VeriFactu.Cabecera.Obligado.NombreRazon) + assert.Equal(t, "B85905495", doc.Body.VeriFactu.Cabecera.Obligado.NIF) + assert.Equal(t, "1.0", doc.Body.VeriFactu.RegistroFactura.RegistroAlta.IDVersion) + assert.Equal(t, "B85905495", doc.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) + assert.Equal(t, "SAMPLE-004", doc.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) + assert.Equal(t, "13-11-2024", doc.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) }) } diff --git a/doc/document.go b/doc/document.go index 506d6a6..c8f5f67 100644 --- a/doc/document.go +++ b/doc/document.go @@ -17,14 +17,16 @@ type Envelope struct { XMLNs string `xml:"xmlns:soapenv,attr"` SUM string `xml:"xmlns:sum,attr"` SUM1 string `xml:"xmlns:sum1,attr"` - Body struct { - XMLName xml.Name `xml:"soapenv:Body"` - VeriFactu *VeriFactu `xml:"sum:RegFactuSistemaFacturacion"` - } + Body *Body `xml:"soapenv:Body"` } -// VeriFactu represents the root element of a VeriFactu document -type VeriFactu struct { +// Body is the body of the SOAP envelope +type Body struct { + VeriFactu *RegFactuSistemaFacturacion `xml:"sum:RegFactuSistemaFacturacion"` +} + +// RegFactuSistemaFacturacion represents the root element of a RegFactuSistemaFacturacion document +type RegFactuSistemaFacturacion struct { // XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"` Cabecera *Cabecera `xml:"sum:Cabecera"` RegistroFactura *RegistroFactura `xml:"sum:RegistroFactura"` diff --git a/doc/fingerprint.go b/doc/fingerprint.go index ab7cc93..e029c34 100644 --- a/doc/fingerprint.go +++ b/doc/fingerprint.go @@ -29,7 +29,7 @@ func formatField(key, value string) string { return fmt.Sprintf("%s=%s", key, value) } -func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { +func (d *Envelope) fingerprintAlta(inv *RegistroAlta) error { var h string if inv.Encadenamiento.PrimerRegistro == "S" { h = "" @@ -50,11 +50,11 @@ func (d *VeriFactu) fingerprintAlta(inv *RegistroAlta) error { hash := sha256.New() hash.Write([]byte(st)) - d.RegistroFactura.RegistroAlta.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) + d.Body.VeriFactu.RegistroFactura.RegistroAlta.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) return nil } -func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { +func (d *Envelope) fingerprintAnulacion(inv *RegistroAnulacion) error { var h string if inv.Encadenamiento.PrimerRegistro == "S" { h = "" @@ -72,22 +72,22 @@ func (d *VeriFactu) fingerprintAnulacion(inv *RegistroAnulacion) error { hash := sha256.New() hash.Write([]byte(st)) - d.RegistroFactura.RegistroAnulacion.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) + d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) return nil } -func (d *VeriFactu) generateHashAlta(prev *ChainData) error { +func (d *Envelope) generateHashAlta(prev *ChainData) error { if prev == nil { - d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + d.Body.VeriFactu.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ PrimerRegistro: "S", } - if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { + if err := d.fingerprintAlta(d.Body.VeriFactu.RegistroFactura.RegistroAlta); err != nil { return err } return nil } // Concatenate f according to Verifactu specifications - d.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + d.Body.VeriFactu.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ RegistroAnterior: &RegistroAnterior{ IDEmisorFactura: prev.IDIssuer, NumSerieFactura: prev.NumSeries, @@ -95,23 +95,23 @@ func (d *VeriFactu) generateHashAlta(prev *ChainData) error { Huella: prev.Fingerprint, }, } - if err := d.fingerprintAlta(d.RegistroFactura.RegistroAlta); err != nil { + if err := d.fingerprintAlta(d.Body.VeriFactu.RegistroFactura.RegistroAlta); err != nil { return err } return nil } -func (d *VeriFactu) generateHashAnulacion(prev *ChainData) error { +func (d *Envelope) generateHashAnulacion(prev *ChainData) error { if prev == nil { - d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ + d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ PrimerRegistro: "S", } - if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { + if err := d.fingerprintAnulacion(d.Body.VeriFactu.RegistroFactura.RegistroAnulacion); err != nil { return err } return nil } - d.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ + d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ RegistroAnterior: &RegistroAnterior{ IDEmisorFactura: prev.IDIssuer, NumSerieFactura: prev.NumSeries, @@ -119,7 +119,7 @@ func (d *VeriFactu) generateHashAnulacion(prev *ChainData) error { Huella: prev.Fingerprint, }, } - if err := d.fingerprintAnulacion(d.RegistroFactura.RegistroAnulacion); err != nil { + if err := d.fingerprintAnulacion(d.Body.VeriFactu.RegistroFactura.RegistroAnulacion); err != nil { return err } return nil diff --git a/doc/fingerprint_test.go b/doc/fingerprint_test.go index 6523459..a0fecbb 100644 --- a/doc/fingerprint_test.go +++ b/doc/fingerprint_test.go @@ -99,9 +99,13 @@ func TestFingerprintAlta(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - d := &doc.VeriFactu{ - RegistroFactura: &doc.RegistroFactura{ - RegistroAlta: tt.alta, + d := &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: tt.alta, + }, + }, }, } @@ -181,9 +185,13 @@ func TestFingerprintAnulacion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - d := &doc.VeriFactu{ - RegistroFactura: &doc.RegistroFactura{ - RegistroAnulacion: tt.anulacion, + d := &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAnulacion: tt.anulacion, + }, + }, }, } diff --git a/doc/invoice_test.go b/doc/invoice_test.go index 8d114bf..b10d1d8 100644 --- a/doc/invoice_test.go +++ b/doc/invoice_test.go @@ -25,7 +25,7 @@ func TestNewRegistroAlta(t *testing.T) { d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - reg := d.RegistroFactura.RegistroAlta + reg := d.Body.VeriFactu.RegistroFactura.RegistroAlta assert.Equal(t, "1.0", reg.IDVersion) assert.Equal(t, "B85905495", reg.IDFactura.IDEmisorFactura) assert.Equal(t, "SAMPLE-004", reg.IDFactura.NumSerieFactura) @@ -58,7 +58,7 @@ func TestNewRegistroAlta(t *testing.T) { d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - assert.Equal(t, "S", d.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) + assert.Equal(t, "S", d.Body.VeriFactu.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) }) t.Run("should handle rectificative invoices", func(t *testing.T) { @@ -67,7 +67,7 @@ func TestNewRegistroAlta(t *testing.T) { d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - reg := d.RegistroFactura.RegistroAlta + reg := d.Body.VeriFactu.RegistroFactura.RegistroAlta assert.Equal(t, "R1", reg.TipoFactura) assert.Equal(t, "I", reg.TipoRectificativa) require.Len(t, reg.FacturasRectificadas, 1) @@ -96,7 +96,7 @@ func TestNewRegistroAlta(t *testing.T) { d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - reg := d.RegistroFactura.RegistroAlta + reg := d.Body.VeriFactu.RegistroFactura.RegistroAlta require.Len(t, reg.FacturasSustituidas, 1) substituted := reg.FacturasSustituidas[0] diff --git a/doc/party_test.go b/doc/party_test.go index 0a08c22..2ba689b 100644 --- a/doc/party_test.go +++ b/doc/party_test.go @@ -22,7 +22,7 @@ func TestNewParty(t *testing.T) { d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario + p := d.Body.VeriFactu.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario assert.Equal(t, "Sample Consumer", p.NombreRazon) assert.Equal(t, "B63272603", p.NIF) assert.Nil(t, p.IDOtro) @@ -43,7 +43,7 @@ func TestNewParty(t *testing.T) { d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario + p := d.Body.VeriFactu.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario assert.Equal(t, "Mr. Pass Port", p.NombreRazon) assert.Empty(t, p.NIF) assert.NotNil(t, p.IDOtro) @@ -64,7 +64,7 @@ func TestNewParty(t *testing.T) { d, err := doc.NewVerifactu(inv, ts, role, sw, false) require.NoError(t, err) - p := d.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario + p := d.Body.VeriFactu.RegistroFactura.RegistroAlta.Destinatarios[0].IDDestinatario assert.Equal(t, "Foreign Company", p.NombreRazon) assert.Empty(t, p.NIF) diff --git a/doc/qr_code.go b/doc/qr_code.go index 551a1a4..058c45a 100644 --- a/doc/qr_code.go +++ b/doc/qr_code.go @@ -11,11 +11,11 @@ const ( ) // generateURL generates the encoded URL code with parameters. -func (doc *VeriFactu) generateURL(production bool) string { - nif := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) - numSerie := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) - fecha := url.QueryEscape(doc.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) - importe := url.QueryEscape(fmt.Sprintf("%g", doc.RegistroFactura.RegistroAlta.ImporteTotal)) +func (doc *Envelope) generateURL(production bool) string { + nif := url.QueryEscape(doc.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.IDEmisorFactura) + numSerie := url.QueryEscape(doc.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.NumSerieFactura) + fecha := url.QueryEscape(doc.Body.VeriFactu.RegistroFactura.RegistroAlta.IDFactura.FechaExpedicionFactura) + importe := url.QueryEscape(fmt.Sprintf("%g", doc.Body.VeriFactu.RegistroFactura.RegistroAlta.ImporteTotal)) if production { return fmt.Sprintf("%s&nif=%s&numserie=%s&fecha=%s&importe=%s", prodURL, nif, numSerie, fecha, importe) diff --git a/doc/qr_code_test.go b/doc/qr_code_test.go index 2cd3ce9..666e43c 100644 --- a/doc/qr_code_test.go +++ b/doc/qr_code_test.go @@ -9,20 +9,24 @@ import ( func TestGenerateCodes(t *testing.T) { tests := []struct { name string - doc *doc.VeriFactu + doc *doc.Envelope expected string }{ { name: "valid codes generation", - doc: &doc.VeriFactu{ - RegistroFactura: &doc.RegistroFactura{ - RegistroAlta: &doc.RegistroAlta{ - IDFactura: &doc.IDFactura{ - IDEmisorFactura: "89890001K", - NumSerieFactura: "12345678-G33", - FechaExpedicionFactura: "01-09-2024", + doc: &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "89890001K", + NumSerieFactura: "12345678-G33", + FechaExpedicionFactura: "01-09-2024", + }, + ImporteTotal: 241.4, + }, }, - ImporteTotal: 241.4, }, }, }, @@ -30,15 +34,19 @@ func TestGenerateCodes(t *testing.T) { }, { name: "empty fields", - doc: &doc.VeriFactu{ - RegistroFactura: &doc.RegistroFactura{ - RegistroAlta: &doc.RegistroAlta{ - IDFactura: &doc.IDFactura{ - IDEmisorFactura: "", - NumSerieFactura: "", - FechaExpedicionFactura: "", + doc: &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "", + NumSerieFactura: "", + FechaExpedicionFactura: "", + }, + ImporteTotal: 0, + }, }, - ImporteTotal: 0, }, }, }, @@ -59,20 +67,24 @@ func TestGenerateCodes(t *testing.T) { func TestGenerateURLCodeAlta(t *testing.T) { tests := []struct { name string - doc *doc.VeriFactu + doc *doc.Envelope expected string }{ { name: "valid URL generation", - doc: &doc.VeriFactu{ - RegistroFactura: &doc.RegistroFactura{ - RegistroAlta: &doc.RegistroAlta{ - IDFactura: &doc.IDFactura{ - IDEmisorFactura: "89890001K", - NumSerieFactura: "12345678-G33", - FechaExpedicionFactura: "01-09-2024", + doc: &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "89890001K", + NumSerieFactura: "12345678-G33", + FechaExpedicionFactura: "01-09-2024", + }, + ImporteTotal: 241.4, + }, }, - ImporteTotal: 241.4, }, }, }, @@ -80,15 +92,19 @@ func TestGenerateURLCodeAlta(t *testing.T) { }, { name: "URL with special characters", - doc: &doc.VeriFactu{ - RegistroFactura: &doc.RegistroFactura{ - RegistroAlta: &doc.RegistroAlta{ - IDFactura: &doc.IDFactura{ - IDEmisorFactura: "A12 345&67", - NumSerieFactura: "SERIE/2023", - FechaExpedicionFactura: "01-09-2024", + doc: &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "A12 345&67", + NumSerieFactura: "SERIE/2023", + FechaExpedicionFactura: "01-09-2024", + }, + ImporteTotal: 1234.56, + }, }, - ImporteTotal: 1234.56, }, }, }, @@ -98,7 +114,7 @@ func TestGenerateURLCodeAlta(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.doc.RegistroFactura.RegistroAlta.Encadenamiento = &doc.Encadenamiento{ + tt.doc.Body.VeriFactu.RegistroFactura.RegistroAlta.Encadenamiento = &doc.Encadenamiento{ PrimerRegistro: "S", } got := tt.doc.QRCodes(false) diff --git a/document.go b/document.go index 484cbd9..5391e04 100644 --- a/document.go +++ b/document.go @@ -14,7 +14,7 @@ import ( // Convert creates a new document from the provided GOBL Envelope. // The envelope must contain a valid Invoice. -func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { +func (c *Client) Convert(env *gobl.Envelope) (*doc.Envelope, error) { // Extract the Invoice inv, ok := env.Extract().(*bill.Invoice) if !ok { @@ -40,12 +40,12 @@ func (c *Client) Convert(env *gobl.Envelope) (*doc.VeriFactu, error) { // data provided from the previous chain data. If there was no previous // document in the chain, the parameter should be nil. The document is updated // in place. -func (c *Client) Fingerprint(d *doc.VeriFactu, prev *doc.ChainData) error { +func (c *Client) Fingerprint(d *doc.Envelope, prev *doc.ChainData) error { return d.Fingerprint(prev) } // AddQR adds the QR code stamp to the envelope. -func (c *Client) AddQR(d *doc.VeriFactu, env *gobl.Envelope) error { +func (c *Client) AddQR(d *doc.Envelope, env *gobl.Envelope) error { // now generate the QR codes and add them to the envelope code := d.QRCodes(c.env == gateways.EnvironmentProduction) env.Head.AddStamp( diff --git a/examples_test.go b/examples_test.go index 1ca89c1..4bdac9d 100644 --- a/examples_test.go +++ b/examples_test.go @@ -8,13 +8,15 @@ import ( "testing" "time" + // "github.com/nbio/xml" + verifactu "github.com/invopop/gobl.verifactu" "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" - "github.com/lestrrat-go/libxml2" - "github.com/lestrrat-go/libxml2/xsd" - "github.com/stretchr/testify/assert" + // "github.com/lestrrat-go/libxml2" + // "github.com/lestrrat-go/libxml2/xsd" + // "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,8 +26,8 @@ const ( ) func TestXMLGeneration(t *testing.T) { - schema, err := loadSchema() - require.NoError(t, err) + // schema, err := loadSchema() + // require.NoError(t, err) examples, err := lookupExamples() require.NoError(t, err) @@ -56,23 +58,23 @@ func TestXMLGeneration(t *testing.T) { strings.TrimSuffix(example, ".json")+".xml", ) - valData, err := td.Bytes() + valData, err := td.BytesIndent() require.NoError(t, err) - valData, err = addNamespaces(valData) - require.NoError(t, err) + // valData, err = addNamespaces(valData) + // require.NoError(t, err) - errs := validateDoc(schema, valData) - for _, e := range errs { - assert.NoError(t, e) - } - if len(errs) > 0 { - assert.Fail(t, "Invalid XML:\n"+string(valData)) - return - } + // errs := validateDoc(schema, valData) + // for _, e := range errs { + // assert.NoError(t, e) + // } + // if len(errs) > 0 { + // assert.Fail(t, "Invalid XML:\n"+string(valData)) + // return + // } if *test.UpdateOut { - data, err := td.Envelop() + data, err := td.Bytes() require.NoError(t, err) err = os.WriteFile(outPath, data, 0644) @@ -85,22 +87,20 @@ func TestXMLGeneration(t *testing.T) { require.False(t, os.IsNotExist(err), msgMissingOutFile, filepath.Base(outPath)) require.NoError(t, err) - outData, err := td.Envelop() - require.NoError(t, err) - require.Equal(t, string(expected), string(outData), msgUnmatchingOutFile, filepath.Base(outPath)) + require.Equal(t, string(expected), string(valData), msgUnmatchingOutFile, filepath.Base(outPath)) }) } } -func loadSchema() (*xsd.Schema, error) { - schemaPath := test.Path("test", "schema", "SuministroLR.xsd") - schema, err := xsd.ParseFromFile(schemaPath) - if err != nil { - return nil, err - } +// func loadSchema() (*xsd.Schema, error) { +// schemaPath := test.Path("test", "schema", "SuministroLR.xsd") +// schema, err := xsd.ParseFromFile(schemaPath) +// if err != nil { +// return nil, err +// } - return schema, nil -} +// return schema, nil +// } func loadClient() (*verifactu.Client, error) { ts, err := time.Parse(time.RFC3339, "2024-11-26T04:00:00Z") @@ -137,29 +137,60 @@ func lookupExamples() ([]string, error) { return examples, nil } -func validateDoc(schema *xsd.Schema, doc []byte) []error { - xmlDoc, err := libxml2.ParseString(string(doc)) - if err != nil { - return []error{err} - } +// func validateDoc(schema *xsd.Schema, doc []byte) []error { +// vf, err := Unenvelop(doc) +// if err != nil { +// return []error{err} +// } - err = schema.Validate(xmlDoc) - if err != nil { - return err.(xsd.SchemaValidationError).Errors() - } +// ns, err := addNamespaces(vf) +// if err != nil { +// return []error{err} +// } - return nil -} +// xmlDoc, err := libxml2.ParseString(string(ns)) +// if err != nil { +// return []error{err} +// } + +// err = schema.Validate(xmlDoc) +// if err != nil { +// return err.(xsd.SchemaValidationError).Errors() +// } + +// return nil +// } // Helper function to inject namespaces into XML without using Envelop() // Just for xsd validation purposes -func addNamespaces(data []byte) ([]byte, error) { - xmlString := string(data) - xmlNamespaces := ` xmlns:sum="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd"` - if !strings.Contains(xmlString, "") { - return nil, fmt.Errorf("could not find RegFactuSistemaFacturacion tag in XML") - } - xmlString = strings.Replace(xmlString, "", "", 1) - finalXMLBytes := []byte(xmlString) - return finalXMLBytes, nil -} +// func addNamespaces(data []byte) ([]byte, error) { +// xmlString := string(data) +// xmlNamespaces := ` xmlns:sum="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd"` +// if !strings.Contains(xmlString, "") { +// return nil, fmt.Errorf("could not find RegFactuSistemaFacturacion tag in XML") +// } +// xmlString = strings.Replace(xmlString, "", "", 1) +// finalXMLBytes := []byte(xmlString) +// return finalXMLBytes, nil +// } + +// Unenvelop extracts the VeriFactu document from a SOAP envelope +// func Unenvelop(data []byte) ([]byte, error) { +// var env doc.Envelope +// if err := xml.Unmarshal(data, &env); err != nil { +// return nil, err +// } + +// // Check if the VeriFactu document is present +// if env.Body.VeriFactu == nil { +// return nil, fmt.Errorf("missing RegFactuSistemaFacturacion in envelope") +// } + +// // Marshal the VeriFactu back to XML +// xmlData, err := xml.Marshal(env.Body.VeriFactu) +// if err != nil { +// return nil, err +// } + +// return xmlData, nil +// } diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index f502ee2..d42cc66 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -55,8 +55,8 @@ func New(env Environment, cert *xmldsig.Certificate) (*Connection, error) { } // Post sends the VeriFactu document to the gateway -func (c *Connection) Post(ctx context.Context, doc doc.VeriFactu) error { - pyl, err := doc.Envelop() +func (c *Connection) Post(ctx context.Context, doc doc.Envelope) error { + pyl, err := doc.Bytes() if err != nil { return fmt.Errorf("generating payload: %w", err) } diff --git a/test/data/out/cred-note-base.xml b/test/data/out/cred-note-base.xml index 107465c..9a4d251 100755 --- a/test/data/out/cred-note-base.xml +++ b/test/data/out/cred-note-base.xml @@ -1,3 +1,4 @@ + diff --git a/test/data/out/cred-note-exemption.xml b/test/data/out/cred-note-exemption.xml index 46f78fb..57511e6 100644 --- a/test/data/out/cred-note-exemption.xml +++ b/test/data/out/cred-note-exemption.xml @@ -1,3 +1,4 @@ + diff --git a/test/data/out/inv-base.xml b/test/data/out/inv-base.xml index ae35a95..a84b81b 100755 --- a/test/data/out/inv-base.xml +++ b/test/data/out/inv-base.xml @@ -1,3 +1,4 @@ + diff --git a/test/data/out/inv-eqv-sur-b2c.xml b/test/data/out/inv-eqv-sur-b2c.xml index 5fef434..8d290d0 100644 --- a/test/data/out/inv-eqv-sur-b2c.xml +++ b/test/data/out/inv-eqv-sur-b2c.xml @@ -1,3 +1,4 @@ + diff --git a/test/data/out/inv-eqv-sur.xml b/test/data/out/inv-eqv-sur.xml index 86b7b6b..abade59 100755 --- a/test/data/out/inv-eqv-sur.xml +++ b/test/data/out/inv-eqv-sur.xml @@ -1,3 +1,4 @@ + diff --git a/test/data/out/inv-rev-charge.xml b/test/data/out/inv-rev-charge.xml index 0ee84c6..4e5e225 100644 --- a/test/data/out/inv-rev-charge.xml +++ b/test/data/out/inv-rev-charge.xml @@ -1,3 +1,4 @@ + diff --git a/test/data/out/inv-zero-tax.xml b/test/data/out/inv-zero-tax.xml index e94b36f..7bc0938 100644 --- a/test/data/out/inv-zero-tax.xml +++ b/test/data/out/inv-zero-tax.xml @@ -1,3 +1,4 @@ + diff --git a/verifactu.go b/verifactu.go index d2db124..3364462 100644 --- a/verifactu.go +++ b/verifactu.go @@ -112,7 +112,7 @@ func InSandbox() Option { } // Post will send the document to the VeriFactu gateway. -func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { +func (c *Client) Post(ctx context.Context, d *doc.Envelope) error { if err := c.gw.Post(ctx, *d); err != nil { return doc.NewErrorFrom(err) } @@ -120,8 +120,8 @@ func (c *Client) Post(ctx context.Context, d *doc.VeriFactu) error { } // ParseDocument will parse the XML data into a VeriFactu document. -func ParseDocument(data []byte) (*doc.VeriFactu, error) { - d := new(doc.VeriFactu) +func ParseDocument(data []byte) (*doc.Envelope, error) { + d := new(doc.Envelope) if err := xml.Unmarshal(data, d); err != nil { return nil, err } From 22951d8142f43e2ca8f37aab7ec45664a359ad95 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 19:53:24 +0000 Subject: [PATCH 47/56] change parseDocument encoder --- verifactu.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/verifactu.go b/verifactu.go index 3364462..0bbfebe 100644 --- a/verifactu.go +++ b/verifactu.go @@ -3,9 +3,10 @@ package verifactu import ( "context" - "encoding/xml" "time" + "github.com/nbio/xml" + "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/internal/gateways" "github.com/invopop/xmldsig" From 36bdf0ddc4c4adf9d5a9cfd125a0299857a65e4f Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 19:53:55 +0000 Subject: [PATCH 48/56] mod tidy --- go.mod | 2 -- go.sum | 13 ------------- 2 files changed, 15 deletions(-) diff --git a/go.mod b/go.mod index 2405d1d..8311cdc 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/invopop/validation v0.8.0 github.com/invopop/xmldsig v0.10.0 github.com/joho/godotenv v1.5.1 - github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3 github.com/magefile/mage v1.15.0 github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 github.com/spf13/cobra v1.8.1 @@ -34,7 +33,6 @@ require ( github.com/invopop/yaml v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/go.sum b/go.sum index 78ce566..38a6746 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9 h1:VIIyZspyf68JHHHQ6uk5Z2AufYPzkqCz1gF4WGRh32g= -github.com/invopop/gobl v0.206.2-0.20241205110633-34e317a013d9/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/gobl v0.207.0 h1:Gv6aUYKLdwuAw9HOhkk8eUztsYXPbPy6e/DBoBUDXmI= github.com/invopop/gobl v0.207.0/go.mod h1:DmPohPel8b3ta4nDKnXRNzWQlB89cN74e0/WwPUEZUU= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= @@ -38,23 +36,16 @@ github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3 h1:ZIYZ0+TEddrxA2dEx4ITTBCdRqRP8Zh+8nb4tSx0nOw= -github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3/go.mod h1:/0MMipmS+5SMXCSkulsvJwYmddKI4IL5tVy6AZMo9n0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80 h1:1okRi+ZpH92VkeIJPJWfoNVXm3F4225PoT1LSO+gFfw= github.com/nbio/xml v0.0.0-20241028124227-eac89c735a80/go.mod h1:990JnYmJZFrx1vI1TALoD6/fCqnWlTx2FrPbYy2wi5I= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 h1:igWZJluD8KtEtAgRyF4x6lqcxDry1ULztksMJh2mnQE= @@ -74,8 +65,6 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= -golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -90,8 +79,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/xmlpath.v1 v1.0.0-20140413065638-a146725ea6e7 h1:zibSPXbkfB1Dwl76rJgLa68xcdHu42qmFTe6vAnU4wA= -gopkg.in/xmlpath.v1 v1.0.0-20140413065638-a146725ea6e7/go.mod h1:wo0SW5T6XqIKCCAge330Cd5sm+7VI6v85OrQHIk50KM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From dc2d4ac762b6024d7639d612030a52274908de45 Mon Sep 17 00:00:00 2001 From: apardods Date: Mon, 16 Dec 2024 23:43:56 +0000 Subject: [PATCH 49/56] Add tests for ParseDocument --- verifactu_test.go | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 verifactu_test.go diff --git a/verifactu_test.go b/verifactu_test.go new file mode 100644 index 0000000..fdfb8f2 --- /dev/null +++ b/verifactu_test.go @@ -0,0 +1,110 @@ +package verifactu_test + +import ( + "reflect" + "testing" + "time" + + vf "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl.verifactu/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDocument(t *testing.T) { + tests := []struct { + name string + data []byte + want *doc.Envelope + wantErr bool + }{ + { + name: "valid document", + data: []byte(` + + + + + + Test Company + B12345678 + + + + +`), + want: &doc.Envelope{ + XMLNs: doc.EnvNamespace, + SUM: doc.SUM, + SUM1: doc.SUM1, + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + Cabecera: &doc.Cabecera{ + Obligado: doc.Obligado{ + NombreRazon: "Test Company", + NIF: "B12345678", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid XML", + data: []byte(`invalid xml`), + want: nil, + wantErr: true, + }, + { + name: "empty document", + data: []byte{}, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := vf.ParseDocument(tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("ParseDocument() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseDocument() = %v, want %v", got, tt.want) + } + }) + } + + t.Run("should preserve RegistroAlta when parsing complete document", func(t *testing.T) { + // Load and generate the reference XML + inv := test.LoadInvoice("inv-base.json") + ts := time.Date(2024, 11, 26, 4, 0, 0, 0, time.UTC) + sw := &doc.Software{ + CompanyName: "My Software", + TaxID: "12345678A", + SoftwareName: "My Software", + SoftwareID: "A1", + Version: "1.0", + InstallationNumber: "12345678A", + } + want, err := doc.NewVerifactu(inv, ts, doc.IssuerRoleSupplier, sw, false) + require.NoError(t, err) + + // Get the XML bytes from the reference document + xmlData, err := want.Bytes() + require.NoError(t, err) + + // Parse the XML back into a document + got, err := vf.ParseDocument(xmlData) + require.NoError(t, err) + + // Check that RegistroAlta is present and correctly structured + require.NotNil(t, got.Body.VeriFactu.RegistroFactura) + require.NotNil(t, got.Body.VeriFactu.RegistroFactura.RegistroAlta) + assert.Equal(t, want.Body.VeriFactu.RegistroFactura.RegistroAlta.IDVersion, + got.Body.VeriFactu.RegistroFactura.RegistroAlta.IDVersion) + }) +} From 064db5fd2ea17323bcce8e3fbca15878167ad59f Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 17 Dec 2024 00:30:09 +0000 Subject: [PATCH 50/56] Tests work --- test/schema/SuministroLR.xsd | 2 +- verifactu_test.go | 31 +++++++++++++++---------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/test/schema/SuministroLR.xsd b/test/schema/SuministroLR.xsd index 0800e24..bde17b7 100644 --- a/test/schema/SuministroLR.xsd +++ b/test/schema/SuministroLR.xsd @@ -22,4 +22,4 @@ - + \ No newline at end of file diff --git a/verifactu_test.go b/verifactu_test.go index fdfb8f2..f731a6f 100644 --- a/verifactu_test.go +++ b/verifactu_test.go @@ -22,18 +22,18 @@ func TestParseDocument(t *testing.T) { { name: "valid document", data: []byte(` - - - - - - Test Company - B12345678 - - - - -`), + + + + + + Test Company + B12345678 + + + + + `), want: &doc.Envelope{ XMLNs: doc.EnvNamespace, SUM: doc.SUM, @@ -78,10 +78,8 @@ func TestParseDocument(t *testing.T) { }) } - t.Run("should preserve RegistroAlta when parsing complete document", func(t *testing.T) { - // Load and generate the reference XML + t.Run("should preserve parse whole doc", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") - ts := time.Date(2024, 11, 26, 4, 0, 0, 0, time.UTC) sw := &doc.Software{ CompanyName: "My Software", TaxID: "12345678A", @@ -90,7 +88,7 @@ func TestParseDocument(t *testing.T) { Version: "1.0", InstallationNumber: "12345678A", } - want, err := doc.NewVerifactu(inv, ts, doc.IssuerRoleSupplier, sw, false) + want, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, sw, false) require.NoError(t, err) // Get the XML bytes from the reference document @@ -106,5 +104,6 @@ func TestParseDocument(t *testing.T) { require.NotNil(t, got.Body.VeriFactu.RegistroFactura.RegistroAlta) assert.Equal(t, want.Body.VeriFactu.RegistroFactura.RegistroAlta.IDVersion, got.Body.VeriFactu.RegistroFactura.RegistroAlta.IDVersion) + }) } From 57955624006bcd7528a12b503cdf7d6b8628eb9f Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 17 Dec 2024 11:59:15 +0000 Subject: [PATCH 51/56] Post now takes bytes --- cmd/gobl.verifactu/cancel.go | 7 ++++++- cmd/gobl.verifactu/send.go | 7 ++++++- internal/gateways/gateways.go | 10 +++++----- verifactu.go | 4 ++-- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/cmd/gobl.verifactu/cancel.go b/cmd/gobl.verifactu/cancel.go index cec769e..d163b60 100644 --- a/cmd/gobl.verifactu/cancel.go +++ b/cmd/gobl.verifactu/cancel.go @@ -93,7 +93,12 @@ func (c *cancelOpts) runE(cmd *cobra.Command, args []string) error { return err } - err = tc.Post(cmd.Context(), td) + tdBytes, err := td.Bytes() + if err != nil { + return err + } + + err = tc.Post(cmd.Context(), tdBytes) if err != nil { return err } diff --git a/cmd/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go index 19d874f..9d61bbe 100644 --- a/cmd/gobl.verifactu/send.go +++ b/cmd/gobl.verifactu/send.go @@ -96,7 +96,12 @@ func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { return err } - err = tc.Post(cmd.Context(), td) + tdBytes, err := td.Bytes() + if err != nil { + return err + } + + err = tc.Post(cmd.Context(), tdBytes) if err != nil { return err } diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index d42cc66..4b3affc 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -55,11 +55,11 @@ func New(env Environment, cert *xmldsig.Certificate) (*Connection, error) { } // Post sends the VeriFactu document to the gateway -func (c *Connection) Post(ctx context.Context, doc doc.Envelope) error { - pyl, err := doc.Bytes() - if err != nil { - return fmt.Errorf("generating payload: %w", err) - } +func (c *Connection) Post(ctx context.Context, pyl []byte) error { + // pyl, err := doc.Bytes() + // if err != nil { + // return fmt.Errorf("generating payload: %w", err) + // } return c.post(ctx, TestingBaseURL, pyl) } diff --git a/verifactu.go b/verifactu.go index 0bbfebe..4b086a5 100644 --- a/verifactu.go +++ b/verifactu.go @@ -113,8 +113,8 @@ func InSandbox() Option { } // Post will send the document to the VeriFactu gateway. -func (c *Client) Post(ctx context.Context, d *doc.Envelope) error { - if err := c.gw.Post(ctx, *d); err != nil { +func (c *Client) Post(ctx context.Context, d []byte) error { + if err := c.gw.Post(ctx, d); err != nil { return doc.NewErrorFrom(err) } return nil From bf28ebd2dba89f97287fa58d7a0623be2a1ea69a Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 17 Dec 2024 12:23:10 +0000 Subject: [PATCH 52/56] Revert Software naming translation --- cmd/gobl.verifactu/root.go | 18 +++++++++--------- doc/document.go | 18 +++++++++--------- examples_test.go | 18 +++++++++--------- verifactu_test.go | 12 ++++++------ 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go index 4d77483..046ac4a 100644 --- a/cmd/gobl.verifactu/root.go +++ b/cmd/gobl.verifactu/root.go @@ -55,15 +55,15 @@ func (o *rootOpts) prepareFlags(f *pflag.FlagSet) { func (o *rootOpts) software() *doc.Software { return &doc.Software{ - TaxID: o.swNIF, - CompanyName: o.swNombreRazon, - Version: o.swVersion, - SoftwareID: o.swIDSistemaInformatico, - InstallationNumber: o.swNumeroInstalacion, - SoftwareName: o.swName, - VerifactuOnlyUsageType: "S", - MultiOTUsageType: "S", - MultiOTIndicator: "N", + NIF: o.swNIF, + NombreRazon: o.swNombreRazon, + Version: o.swVersion, + IdSistemaInformatico: o.swIDSistemaInformatico, + NumeroInstalacion: o.swNumeroInstalacion, + NombreSistemaInformatico: o.swName, + TipoUsoPosibleSoloVerifactu: "S", + TipoUsoPosibleMultiOT: "S", + IndicadorMultiplesOT: "N", } } diff --git a/doc/document.go b/doc/document.go index c8f5f67..475e94d 100644 --- a/doc/document.go +++ b/doc/document.go @@ -203,13 +203,13 @@ type RegistroAnterior struct { // generate VeriFactu documents. These details are included in the final // document. type Software struct { - CompanyName string `xml:"sum1:NombreRazon"` - TaxID string `xml:"sum1:NIF"` - SoftwareName string `xml:"sum1:NombreSistemaInformatico"` - SoftwareID string `xml:"sum1:IdSistemaInformatico"` //nolint:revive - Version string `xml:"sum1:Version"` - InstallationNumber string `xml:"sum1:NumeroInstalacion"` - VerifactuOnlyUsageType string `xml:"sum1:TipoUsoPosibleSoloVerifactu,omitempty"` - MultiOTUsageType string `xml:"sum1:TipoUsoPosibleMultiOT,omitempty"` - MultiOTIndicator string `xml:"sum1:IndicadorMultiplesOT,omitempty"` + NombreRazon string `xml:"sum1:NombreRazon"` + NIF string `xml:"sum1:NIF"` + NombreSistemaInformatico string `xml:"sum1:NombreSistemaInformatico"` + IdSistemaInformatico string `xml:"sum1:IdSistemaInformatico"` //nolint:revive + Version string `xml:"sum1:Version"` + NumeroInstalacion string `xml:"sum1:NumeroInstalacion"` + TipoUsoPosibleSoloVerifactu string `xml:"sum1:TipoUsoPosibleSoloVerifactu,omitempty"` + TipoUsoPosibleMultiOT string `xml:"sum1:TipoUsoPosibleMultiOT,omitempty"` + IndicadorMultiplesOT string `xml:"sum1:IndicadorMultiplesOT,omitempty"` } diff --git a/examples_test.go b/examples_test.go index 4bdac9d..cf67d7d 100644 --- a/examples_test.go +++ b/examples_test.go @@ -109,15 +109,15 @@ func loadClient() (*verifactu.Client, error) { } return verifactu.New(&doc.Software{ - CompanyName: "My Software", - TaxID: "12345678A", - SoftwareName: "My Software", - SoftwareID: "A1", - Version: "1.0", - InstallationNumber: "12345678A", - VerifactuOnlyUsageType: "S", - MultiOTUsageType: "S", - MultiOTIndicator: "N", + NombreRazon: "My Software", + NIF: "12345678A", + NombreSistemaInformatico: "My Software", + IdSistemaInformatico: "A1", + Version: "1.0", + NumeroInstalacion: "12345678A", + TipoUsoPosibleSoloVerifactu: "S", + TipoUsoPosibleMultiOT: "S", + IndicadorMultiplesOT: "N", }, verifactu.WithCurrentTime(ts), verifactu.WithThirdPartyIssuer(), diff --git a/verifactu_test.go b/verifactu_test.go index f731a6f..074d877 100644 --- a/verifactu_test.go +++ b/verifactu_test.go @@ -81,12 +81,12 @@ func TestParseDocument(t *testing.T) { t.Run("should preserve parse whole doc", func(t *testing.T) { inv := test.LoadInvoice("inv-base.json") sw := &doc.Software{ - CompanyName: "My Software", - TaxID: "12345678A", - SoftwareName: "My Software", - SoftwareID: "A1", - Version: "1.0", - InstallationNumber: "12345678A", + NombreRazon: "My Software", + NIF: "12345678A", + NombreSistemaInformatico: "My Software", + IdSistemaInformatico: "A1", + Version: "1.0", + NumeroInstalacion: "12345678A", } want, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, sw, false) require.NoError(t, err) From f105a0a1cf346aba267d685da205c229f01a3d69 Mon Sep 17 00:00:00 2001 From: apardods Date: Tue, 17 Dec 2024 16:20:33 +0000 Subject: [PATCH 53/56] Remove unnecesary comments --- doc/doc.go | 23 ---------- examples_test.go | 85 ----------------------------------- internal/gateways/gateways.go | 4 -- 3 files changed, 112 deletions(-) diff --git a/doc/doc.go b/doc/doc.go index e65b3b4..9b89f08 100644 --- a/doc/doc.go +++ b/doc/doc.go @@ -144,29 +144,6 @@ func (d *Envelope) BytesIndent() ([]byte, error) { return toBytesIndent(d) } -// Envelop wraps the VeriFactu document in a SOAP envelope and includes the expected namespaces -// func (d *VeriFactu) Envelop() ([]byte, error) { -// // Create and set the envelope with namespaces -// env := Envelope{ -// XMLNs: EnvNamespace, -// SUM: SUM, -// SUM1: SUM1, -// } -// env.Body.VeriFactu = d - -// // Marshal the SOAP envelope into an XML byte slice -// var result bytes.Buffer -// enc := xml.NewEncoder(&result) -// enc.Indent("", " ") -// err := enc.Encode(env) -// if err != nil { -// return nil, err -// } - -// // Return the enveloped XML document -// return result.Bytes(), nil -// } - func toBytes(doc any) ([]byte, error) { buf, err := buffer(doc, xml.Header, false) if err != nil { diff --git a/examples_test.go b/examples_test.go index cf67d7d..dde9848 100644 --- a/examples_test.go +++ b/examples_test.go @@ -8,15 +8,10 @@ import ( "testing" "time" - // "github.com/nbio/xml" - verifactu "github.com/invopop/gobl.verifactu" "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/test" - // "github.com/lestrrat-go/libxml2" - // "github.com/lestrrat-go/libxml2/xsd" - // "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -61,18 +56,6 @@ func TestXMLGeneration(t *testing.T) { valData, err := td.BytesIndent() require.NoError(t, err) - // valData, err = addNamespaces(valData) - // require.NoError(t, err) - - // errs := validateDoc(schema, valData) - // for _, e := range errs { - // assert.NoError(t, e) - // } - // if len(errs) > 0 { - // assert.Fail(t, "Invalid XML:\n"+string(valData)) - // return - // } - if *test.UpdateOut { data, err := td.Bytes() require.NoError(t, err) @@ -92,16 +75,6 @@ func TestXMLGeneration(t *testing.T) { } } -// func loadSchema() (*xsd.Schema, error) { -// schemaPath := test.Path("test", "schema", "SuministroLR.xsd") -// schema, err := xsd.ParseFromFile(schemaPath) -// if err != nil { -// return nil, err -// } - -// return schema, nil -// } - func loadClient() (*verifactu.Client, error) { ts, err := time.Parse(time.RFC3339, "2024-11-26T04:00:00Z") if err != nil { @@ -136,61 +109,3 @@ func lookupExamples() ([]string, error) { return examples, nil } - -// func validateDoc(schema *xsd.Schema, doc []byte) []error { -// vf, err := Unenvelop(doc) -// if err != nil { -// return []error{err} -// } - -// ns, err := addNamespaces(vf) -// if err != nil { -// return []error{err} -// } - -// xmlDoc, err := libxml2.ParseString(string(ns)) -// if err != nil { -// return []error{err} -// } - -// err = schema.Validate(xmlDoc) -// if err != nil { -// return err.(xsd.SchemaValidationError).Errors() -// } - -// return nil -// } - -// Helper function to inject namespaces into XML without using Envelop() -// Just for xsd validation purposes -// func addNamespaces(data []byte) ([]byte, error) { -// xmlString := string(data) -// xmlNamespaces := ` xmlns:sum="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd" xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd"` -// if !strings.Contains(xmlString, "") { -// return nil, fmt.Errorf("could not find RegFactuSistemaFacturacion tag in XML") -// } -// xmlString = strings.Replace(xmlString, "", "", 1) -// finalXMLBytes := []byte(xmlString) -// return finalXMLBytes, nil -// } - -// Unenvelop extracts the VeriFactu document from a SOAP envelope -// func Unenvelop(data []byte) ([]byte, error) { -// var env doc.Envelope -// if err := xml.Unmarshal(data, &env); err != nil { -// return nil, err -// } - -// // Check if the VeriFactu document is present -// if env.Body.VeriFactu == nil { -// return nil, fmt.Errorf("missing RegFactuSistemaFacturacion in envelope") -// } - -// // Marshal the VeriFactu back to XML -// xmlData, err := xml.Marshal(env.Body.VeriFactu) -// if err != nil { -// return nil, err -// } - -// return xmlData, nil -// } diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 4b3affc..a9c8d19 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -56,10 +56,6 @@ func New(env Environment, cert *xmldsig.Certificate) (*Connection, error) { // Post sends the VeriFactu document to the gateway func (c *Connection) Post(ctx context.Context, pyl []byte) error { - // pyl, err := doc.Bytes() - // if err != nil { - // return fmt.Errorf("generating payload: %w", err) - // } return c.post(ctx, TestingBaseURL, pyl) } From 1fe867fbe25194ce54d740235bc22e167b74636b Mon Sep 17 00:00:00 2001 From: apardods Date: Wed, 18 Dec 2024 11:37:25 +0000 Subject: [PATCH 54/56] Remove ParseDocument --- verifactu.go | 11 ----- verifactu_test.go | 109 ---------------------------------------------- 2 files changed, 120 deletions(-) delete mode 100644 verifactu_test.go diff --git a/verifactu.go b/verifactu.go index 4b086a5..14c1b15 100644 --- a/verifactu.go +++ b/verifactu.go @@ -5,8 +5,6 @@ import ( "context" "time" - "github.com/nbio/xml" - "github.com/invopop/gobl.verifactu/doc" "github.com/invopop/gobl.verifactu/internal/gateways" "github.com/invopop/xmldsig" @@ -120,15 +118,6 @@ func (c *Client) Post(ctx context.Context, d []byte) error { return nil } -// ParseDocument will parse the XML data into a VeriFactu document. -func ParseDocument(data []byte) (*doc.Envelope, error) { - d := new(doc.Envelope) - if err := xml.Unmarshal(data, d); err != nil { - return nil, err - } - return d, nil -} - // CurrentTime returns the current time to use when generating // the VeriFactu document. func (c *Client) CurrentTime() time.Time { diff --git a/verifactu_test.go b/verifactu_test.go deleted file mode 100644 index 074d877..0000000 --- a/verifactu_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package verifactu_test - -import ( - "reflect" - "testing" - "time" - - vf "github.com/invopop/gobl.verifactu" - "github.com/invopop/gobl.verifactu/doc" - "github.com/invopop/gobl.verifactu/test" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseDocument(t *testing.T) { - tests := []struct { - name string - data []byte - want *doc.Envelope - wantErr bool - }{ - { - name: "valid document", - data: []byte(` - - - - - - Test Company - B12345678 - - - - - `), - want: &doc.Envelope{ - XMLNs: doc.EnvNamespace, - SUM: doc.SUM, - SUM1: doc.SUM1, - Body: &doc.Body{ - VeriFactu: &doc.RegFactuSistemaFacturacion{ - Cabecera: &doc.Cabecera{ - Obligado: doc.Obligado{ - NombreRazon: "Test Company", - NIF: "B12345678", - }, - }, - }, - }, - }, - wantErr: false, - }, - { - name: "invalid XML", - data: []byte(`invalid xml`), - want: nil, - wantErr: true, - }, - { - name: "empty document", - data: []byte{}, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := vf.ParseDocument(tt.data) - if (err != nil) != tt.wantErr { - t.Errorf("ParseDocument() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { - t.Errorf("ParseDocument() = %v, want %v", got, tt.want) - } - }) - } - - t.Run("should preserve parse whole doc", func(t *testing.T) { - inv := test.LoadInvoice("inv-base.json") - sw := &doc.Software{ - NombreRazon: "My Software", - NIF: "12345678A", - NombreSistemaInformatico: "My Software", - IdSistemaInformatico: "A1", - Version: "1.0", - NumeroInstalacion: "12345678A", - } - want, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, sw, false) - require.NoError(t, err) - - // Get the XML bytes from the reference document - xmlData, err := want.Bytes() - require.NoError(t, err) - - // Parse the XML back into a document - got, err := vf.ParseDocument(xmlData) - require.NoError(t, err) - - // Check that RegistroAlta is present and correctly structured - require.NotNil(t, got.Body.VeriFactu.RegistroFactura) - require.NotNil(t, got.Body.VeriFactu.RegistroFactura.RegistroAlta) - assert.Equal(t, want.Body.VeriFactu.RegistroFactura.RegistroAlta.IDVersion, - got.Body.VeriFactu.RegistroFactura.RegistroAlta.IDVersion) - - }) -} From ce42f046817eb664501fcf51045b562c4d141305 Mon Sep 17 00:00:00 2001 From: apardods Date: Wed, 18 Dec 2024 11:45:19 +0000 Subject: [PATCH 55/56] Move Errors to Doc --- doc/errors.go | 7 +++++++ verifactu.go | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/errors.go b/doc/errors.go index 40aca4c..89374eb 100644 --- a/doc/errors.go +++ b/doc/errors.go @@ -12,6 +12,13 @@ var ( ErrDuplicate = newError("duplicate") ) +// Standard error responses. +var ( + ErrNotSpanish = ErrValidation.WithMessage("only spanish invoices are supported") + ErrAlreadyProcessed = ErrValidation.WithMessage("already processed") + ErrOnlyInvoices = ErrValidation.WithMessage("only invoices are supported") +) + // Error allows for structured responses from the gateway to be able to // response codes and messages. type Error struct { diff --git a/verifactu.go b/verifactu.go index 14c1b15..04006af 100644 --- a/verifactu.go +++ b/verifactu.go @@ -10,13 +10,6 @@ import ( "github.com/invopop/xmldsig" ) -// Standard error responses. -var ( - ErrNotSpanish = doc.ErrValidation.WithMessage("only spanish invoices are supported") - ErrAlreadyProcessed = doc.ErrValidation.WithMessage("already processed") - ErrOnlyInvoices = doc.ErrValidation.WithMessage("only invoices are supported") -) - // Client provides the main interface to the VeriFactu package. type Client struct { software *doc.Software From cc1182f0d082dcd032b9088c24c872be4b3e2df3 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Thu, 19 Dec 2024 14:50:23 +0100 Subject: [PATCH 56/56] Update README.md Touch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f61ddf..ac2688e 100644 --- a/README.md +++ b/README.md @@ -249,4 +249,4 @@ Some sample test data is available in the `./test` directory. To update the JSON ```bash go test --update -``` \ No newline at end of file +```