diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..1e42771 --- /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.61 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..290dd30 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,47 @@ +name: Release + +on: + push: + branches: + - main + tags: + - "*" + +jobs: + tag-release: + name: Tag and Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + 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@v5 + with: + go-version-file: "go.mod" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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/.gitignore b/.gitignore index 6f72f89..25739d3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,13 @@ go.work.sum # env file .env +*.password +*.txt + +# certs +*.cer +*.p12 +*.key +*.pem + +./test/certs \ No newline at end of file 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/.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 f4d6adf..ac2688e 100644 --- a/README.md +++ b/README.md @@ -1 +1,252 @@ -# gobl.verifactu \ No newline at end of file +# GOBL to VeriFactu + +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. + +## Source + +The main resources used in this module include: + +- [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 + +### 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 VeriFactu package could be used: + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/invopop/gobl" + verifactu "github.com/invopop/gobl.verifactu" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/xmldsig" +) + +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) + } + + // 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 + IdSistemaInformatico: "A1", // Software ID + Version: "1.0", // Software version + NumeroInstalacion: "00001", // Software installation number + } + + // Load the certificate + cert, err := xmldsig.LoadCertificate( + "./path/to/certificate.p12", + "password", + ) + if err != nil { + 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 customer or a third party + verifactu.InTesting(), // Use the testing environment, as the production endpoint is not yet published + } + + tc, err := verifactu.New(software, opts...) + if err != nil { + panic(err) + } + + // Convert the GOBL envelope to a VeriFactu document + td, err := tc.Convert(env) + if err != nil { + panic(err) + } + + // 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(previous), prev); err != nil { + panic(err) + } + + // Create the document fingerprint based on the previous document chain + err = tc.Fingerprint(td, prev) + if err != nil { + panic(err) + } + + // Add the QR code to the document + if err := tc.AddQR(td, env); err != nil { + panic(err) + } + + // Send the document to the tax agency + err = tc.Post(ctx, td) + if err != nil { + panic(err) + } + + // 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 { + panic(err) + } + fmt.Printf("Generated document with fingerprint: \n%s\n", string(cd)) + +} +``` + +### Command Line + +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 +``` + +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=gobl.verifactu +SOFTWARE_VERSION=1.0 +SOFTWARE_ID_SISTEMA_INFORMATICO=A1 +SOFTWARE_NUMERO_INSTALACION=00001 + +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 ./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" +} +``` + +## Tags and Extensions + +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). 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 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. + - `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. + - `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-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 + - `E4` - Exempt pursuant to Articles 23 and 24 of the VAT Law + - `E5` - Exempt pursuant to Article 25 of the VAT Law + - `E6` - Exempt for other reasons + +- `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 + +- 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 + +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: + +```bash +go test --update +``` diff --git a/cancel.go b/cancel.go new file mode 100644 index 0000000..b99615c --- /dev/null +++ b/cancel.go @@ -0,0 +1,37 @@ +package verifactu + +import ( + "errors" + + "github.com/invopop/gobl" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/l10n" +) + +// GenerateCancel creates a new cancellation document from the provided +// GOBL Envelope. +func (c *Client) GenerateCancel(env *gobl.Envelope) (*doc.Envelope, 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") + } + + // Create the document + cd, err := doc.NewVerifactu(inv, c.CurrentTime(), c.issuerRole, c.software, true) + if err != nil { + return nil, err + } + + return cd, nil +} + +// 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.Envelope, prev *doc.ChainData) error { + return d.FingerprintCancel(prev) +} diff --git a/cmd/gobl.verifactu/cancel.go b/cmd/gobl.verifactu/cancel.go new file mode 100644 index 0000000..d163b60 --- /dev/null +++ b/cmd/gobl.verifactu/cancel.go @@ -0,0 +1,113 @@ +// 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.InSandbox()) + } + + 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 + } + + tdBytes, err := td.Bytes() + if err != nil { + return err + } + + err = tc.Post(cmd.Context(), tdBytes) + if err != nil { + return err + } + + data, err := json.Marshal(td.ChainDataCancel()) + if err != nil { + return err + } + fmt.Printf("Generated document with fingerprint: \n%s\n", string(data)) + + return nil +} diff --git a/cmd/gobl.verifactu/convert.go b/cmd/gobl.verifactu/convert.go new file mode 100644 index 0000000..c1f1ef8 --- /dev/null +++ b/cmd/gobl.verifactu/convert.go @@ -0,0 +1,75 @@ +// 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 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 VeriFactu 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) + } + + vf, err := verifactu.New(c.software()) + if err != nil { + return fmt.Errorf("creating verifactu client: %w", err) + } + + doc, err := vf.Convert(env) + if err != nil { + return fmt.Errorf("converting to verifactu 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 verifactu xml: %w", err) + } + + return nil +} diff --git a/cmd/gobl.verifactu/main.go b/cmd/gobl.verifactu/main.go new file mode 100644 index 0000000..3b5e84f --- /dev/null +++ b/cmd/gobl.verifactu/main.go @@ -0,0 +1,47 @@ +// 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 = "" +) + +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() + + 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) +} + +func inputFilename(args []string) string { + if len(args) > 0 && args[0] != "-" { + return args[0] + } + return "" +} diff --git a/cmd/gobl.verifactu/root.go b/cmd/gobl.verifactu/root.go new file mode 100644 index 0000000..046ac4a --- /dev/null +++ b/cmd/gobl.verifactu/root.go @@ -0,0 +1,96 @@ +package main + +import ( + "io" + "os" + + "github.com/invopop/gobl.verifactu/doc" + _ "github.com/joho/godotenv/autoload" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type rootOpts struct { + cert string + password string + swNombreRazon string + swNIF string + swName string + swIDSistemaInformatico string + swVersion string + swNumeroInstalacion string + production bool +} + +func root() *rootOpts { + return &rootOpts{} +} + +func (o *rootOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: name, + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand(versionCmd()) + cmd.AddCommand(send(o).cmd()) + cmd.AddCommand(convert(o).cmd()) + cmd.AddCommand(cancel(o).cmd()) + + return cmd +} + +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.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") + f.BoolVarP(&o.production, "production", "p", false, "Production environment") +} + +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", + } +} + +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/gobl.verifactu/send.go b/cmd/gobl.verifactu/send.go new file mode 100644 index 0000000..9d61bbe --- /dev/null +++ b/cmd/gobl.verifactu/send.go @@ -0,0 +1,116 @@ +// 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 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() + c.prepareFlags(f) + + f.StringVar(&c.previous, "prev", "", "Previous document fingerprint to chain with") + + 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) + } + + cert, err := xmldsig.LoadCertificate(c.cert, c.password) + if err != nil { + return err + } + + opts := []verifactu.Option{ + verifactu.WithCertificate(cert), + } + + if c.production { + opts = append(opts, verifactu.InProduction()) + } else { + opts = append(opts, verifactu.InSandbox()) + } + + tc, err := verifactu.New(c.software(), opts...) + if err != nil { + return err + } + + td, err := tc.Convert(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.Fingerprint(td, prev) + if err != nil { + return err + } + + if err := tc.AddQR(td, env); err != nil { + return err + } + + tdBytes, err := td.Bytes() + if err != nil { + return err + } + + err = tc.Post(cmd.Context(), tdBytes) + 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/version.go b/cmd/gobl.verifactu/version.go new file mode 100644 index 0000000..31d11ab --- /dev/null +++ b/cmd/gobl.verifactu/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/doc/breakdown.go b/doc/breakdown.go new file mode 100644 index 0000000..a65143e --- /dev/null +++ b/doc/breakdown.go @@ -0,0 +1,82 @@ +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" +) + +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, err := buildDetalleDesglose(c, r) + if err != nil { + return nil, err + } + desglose.DetalleDesglose = append(desglose.DetalleDesglose, detalleDesglose) + } + } + + return desglose, nil +} + +// 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.Float64(), + CuotaRepercutida: r.Amount.Float64(), + } + + cat, ok := taxCategoryCodeMap[c.Code] + if !ok { + detalle.Impuesto = "05" + } else { + detalle.Impuesto = cat + } + + if c.Code == tax.CategoryVAT || c.Code == es.TaxCategoryIGIC { + detalle.ClaveRegimen = r.Ext.Get(verifactu.ExtKeyRegime).String() + } + + if r.Ext == nil { + return nil, ErrValidation.WithMessage(fmt.Sprintf("missing tax extensions for rate %s", r.Key)) + } + + 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" { + detalle.BaseImponibleACoste = r.Base.Float64() + } + + if r.Percent != nil { + detalle.TipoImpositivo = r.Percent.Amount().Float64() + } + + if detalle.OperacionExenta == "" && detalle.CalificacionOperacion == "" { + return nil, ErrValidation.WithMessage(fmt.Sprintf("missing operation classification for rate %s", r.Key)) + } + + if r.Key.Has(es.TaxRateEquivalence) { + detalle.TipoRecargoEquivalencia = r.Surcharge.Percent.Amount().Float64() + detalle.CuotaRecargoEquivalencia = r.Surcharge.Amount.Float64() + } + + return detalle, nil +} diff --git a/doc/breakdown_test.go b/doc/breakdown_test.go new file mode 100644 index 0000000..41069ca --- /dev/null +++ b/doc/breakdown_test.go @@ -0,0 +1,229 @@ +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" + "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("basic-invoice", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + _ = inv.Calculate() + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + require.NoError(t, err) + + 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) { + 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", + Ext: tax.Extensions{ + verifactu.ExtKeyExempt: "E1", + verifactu.ExtKeyRegime: "01", + }, + }, + }, + }, + } + _ = inv.Calculate() + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + require.NoError(t, err) + 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) { + 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", + Ext: tax.Extensions{ + verifactu.ExtKeyOpClass: "S1", + verifactu.ExtKeyRegime: "01", + }, + }, + }, + }, + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Price: num.MakeAmount(50, 0), + }, + Taxes: tax.Set{ + &tax.Combo{ + Category: "VAT", + Rate: "reduced", + Ext: tax.Extensions{ + verifactu.ExtKeyOpClass: "S1", + }, + }, + }, + }, + } + _ = inv.Calculate() + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + require.NoError(t, err) + 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.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) { + 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.ExtKeyOpClass: "N1", + verifactu.ExtKeyRegime: "01", + }, + }, + }, + }, + } + _ = inv.Calculate() + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + require.NoError(t, err) + 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) { + 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.ExtKeyOpClass: "S1", + verifactu.ExtKeyRegime: "01", + }, + }, + }, + }, + } + _ = inv.Calculate() + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + require.NoError(t, err) + 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) { + 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.ExtKeyOpClass: "S1", + }, + }, + }, + }, + } + _ = inv.Calculate() + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + require.NoError(t, err) + 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) { + inv := test.LoadInvoice("inv-base.json") + 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.ExtKeyOpClass: "S1", + verifactu.ExtKeyRegime: "04", + }, + }, + }, + }, + } + _ = inv.Calculate() + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, false) + require.NoError(t, err) + 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.go b/doc/cancel.go new file mode 100644 index 0000000..8308488 --- /dev/null +++ b/doc/cancel.go @@ -0,0 +1,25 @@ +package doc + +import ( + "time" + + "github.com/invopop/gobl/bill" +) + +// NewCancel provides support for cancelling invoices +func NewCancel(inv *bill.Invoice, ts time.Time, s *Software) (*RegistroAnulacion, error) { + reg := &RegistroAnulacion{ + IDVersion: CurrentVersion, + IDFactura: &IDFacturaAnulada{ + IDEmisorFactura: inv.Supplier.TaxID.Code.String(), + NumSerieFactura: invoiceNumber(inv.Series, inv.Code), + FechaExpedicionFactura: inv.IssueDate.Time().Format("02-01-2006"), + }, + SistemaInformatico: s, + FechaHoraHusoGenRegistro: formatDateTimeZone(ts), + TipoHuella: TipoHuella, + } + + return reg, nil + +} diff --git a/doc/cancel_test.go b/doc/cancel_test.go new file mode 100644 index 0000000..013c0e7 --- /dev/null +++ b/doc/cancel_test.go @@ -0,0 +1,26 @@ +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" +) + +func TestNewRegistroAnulacion(t *testing.T) { + t.Run("basic", func(t *testing.T) { + inv := test.LoadInvoice("cred-note-base.json") + + d, err := doc.NewVerifactu(inv, time.Now(), doc.IssuerRoleSupplier, nil, true) + require.NoError(t, err) + + 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) + assert.Equal(t, "01", ra.TipoHuella) + }) +} diff --git a/doc/doc.go b/doc/doc.go new file mode 100644 index 0000000..9b89f08 --- /dev/null +++ b/doc/doc.go @@ -0,0 +1,186 @@ +// Package doc provides the VeriFactu document mappings from GOBL +package doc + +import ( + "bytes" + "encoding/xml" + "fmt" + "time" + + "github.com/invopop/gobl/bill" +) + +// for needed for timezones +var location *time.Location + +// 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" +) + +const ( + // CurrentVersion is the current version of the VeriFactu document + CurrentVersion = "1.0" +) + +func init() { + var err error + location, err = time.LoadLocation("Europe/Madrid") + if err != nil { + panic(err) + } +} + +// NewVerifactu creates a new VeriFactu document +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, + NIF: inv.Supplier.TaxID.Code.String(), + }, + }, + RegistroFactura: &RegistroFactura{}, + } + + if inv.Type == bill.InvoiceTypeCreditNote { + // 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 + // 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 := NewCancel(inv, ts, s) + if err != nil { + return nil, err + } + doc.RegistroFactura.RegistroAnulacion = reg + } else { + reg, err := NewInvoice(inv, ts, r, s) + if err != nil { + return nil, err + } + doc.RegistroFactura.RegistroAlta = reg + } + + env.Body.VeriFactu = doc + + return env, nil +} + +// QRCodes generates the QR code for the document +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 *Envelope) ChainData() Encadenamiento { + return Encadenamiento{ + RegistroAnterior: &RegistroAnterior{ + 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 *Envelope) ChainDataCancel() Encadenamiento { + return Encadenamiento{ + RegistroAnterior: &RegistroAnterior{ + 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 *Envelope) Fingerprint(prev *ChainData) error { + return d.generateHashAlta(prev) +} + +// FingerprintCancel generates the SHA-256 fingerprint for the document +func (d *Envelope) FingerprintCancel(prev *ChainData) error { + return d.generateHashAnulacion(prev) +} + +// Bytes returns the XML document bytes +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 *Envelope) BytesIndent() ([]byte, error) { + return toBytesIndent(d) +} + +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 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 +} + +func formatDateTimeZone(ts timeLocationable) string { + return ts.In(location).Format("2006-01-02T15:04:05-07:00") +} diff --git a/doc/doc_test.go b/doc/doc_test.go new file mode 100644 index 0000000..b538b45 --- /dev/null +++ b/doc/doc_test.go @@ -0,0 +1,31 @@ +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" +) + +func TestInvoiceConversion(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("should contain basic document info", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + doc, err := doc.NewVerifactu(inv, ts, role, sw, false) + + require.NoError(t, err) + 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 new file mode 100644 index 0000000..475e94d --- /dev/null +++ b/doc/document.go @@ -0,0 +1,215 @@ +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" + EnvNamespace = "http://schemas.xmlsoap.org/soap/envelope/" +) + +// 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 *Body `xml:"soapenv:Body"` +} + +// 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"` +} + +// 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:ObligadoEmision"` + Representante *Obligado `xml:"sum1:Representante,omitempty"` + RemisionVoluntaria *RemisionVoluntaria `xml:"sum1:RemisionVoluntaria,omitempty"` + 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,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,omitempty"` +} + +// RegistroAlta contains the details of an invoice registration +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,omitempty"` + FacturasSustituidas []*FacturaSustituida `xml:"sum1:FacturasSustituidas,omitempty"` + ImporteRectificacion *ImporteRectificacion `xml:"sum1:ImporteRectificacion,omitempty"` + FechaOperacion string `xml:"sum1:FechaOperacion,omitempty"` + 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 *Party `xml:"sum1:Tercero,omitempty"` + Destinatarios []*Destinatario `xml:"sum1:Destinatarios,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"` //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:"sum1:IDVersion"` + IDFactura *IDFacturaAnulada `xml:"sum1:IDFactura"` + RefExterna string `xml:"sum1:RefExterna,omitempty"` + SinRegistroPrevio string `xml:"sum1:SinRegistroPrevio,omitempty"` + RechazoPrevio string `xml:"sum1:RechazoPrevio,omitempty"` + 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 *xmldsig.Signature `xml:"sum1:Signature"` +} + +// 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"` +} + +// IDFacturaAnulada 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:IDFacturaRectificada"` +} + +// FacturaSustituida represents a substituted invoice +type FacturaSustituida struct { + IDFactura IDFactura `xml:"sum1:IDFacturaSustituida"` +} + +// ImporteRectificacion contains rectification amounts +type ImporteRectificacion struct { + BaseRectificada string `xml:"sum1:BaseRectificada"` + CuotaRectificada string `xml:"sum1:CuotaRectificada"` + CuotaRecargoRectificado string `xml:"sum1:CuotaRecargoRectificado"` +} + +// Party represents a in the document, covering fields Generador, Tercero and IDDestinatario +type Party struct { + NombreRazon string `xml:"sum1:NombreRazon"` + NIF string `xml:"sum1:NIF,omitempty"` + IDOtro *IDOtro `xml:"sum1:IDOtro,omitempty"` +} + +// Destinatario represents a recipient in the document +type Destinatario struct { + 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,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 +type Encadenamiento struct { + 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"` + 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 `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/doc/errors.go b/doc/errors.go new file mode 100644 index 0000000..89374eb --- /dev/null +++ b/doc/errors.go @@ -0,0 +1,112 @@ +package doc + +import ( + "errors" + "strings" +) + +// Standard gateway error responses +var ( + ErrConnection = newError("connection") + ErrValidation = newError("validation") + 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 { + key string + code string + message string + 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} + 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) 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/doc/fingerprint.go b/doc/fingerprint.go new file mode 100644 index 0000000..e029c34 --- /dev/null +++ b/doc/fingerprint.go @@ -0,0 +1,126 @@ +package doc + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// 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 +// required for fingerprinting the next invoice. JSON tags are +// provided to help with serialization. +type ChainData struct { + IDIssuer string `json:"issuer"` + NumSeries string `json:"num_series"` + IssueDate string `json:"issue_date"` + Fingerprint string `json:"fingerprint"` +} + +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) +} + +func (d *Envelope) 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), + 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() + hash.Write([]byte(st)) + + d.Body.VeriFactu.RegistroFactura.RegistroAlta.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) + return nil +} + +func (d *Envelope) fingerprintAnulacion(inv *RegistroAnulacion) error { + var h string + if inv.Encadenamiento.PrimerRegistro == "S" { + h = "" + } else { + 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), + } + st := strings.Join(f, "&") + hash := sha256.New() + hash.Write([]byte(st)) + + d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.Huella = strings.ToUpper(hex.EncodeToString(hash.Sum(nil))) + return nil +} + +func (d *Envelope) generateHashAlta(prev *ChainData) error { + if prev == nil { + d.Body.VeriFactu.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + PrimerRegistro: "S", + } + if err := d.fingerprintAlta(d.Body.VeriFactu.RegistroFactura.RegistroAlta); err != nil { + return err + } + return nil + } + // Concatenate f according to Verifactu specifications + d.Body.VeriFactu.RegistroFactura.RegistroAlta.Encadenamiento = &Encadenamiento{ + RegistroAnterior: &RegistroAnterior{ + IDEmisorFactura: prev.IDIssuer, + NumSerieFactura: prev.NumSeries, + FechaExpedicionFactura: prev.IssueDate, + Huella: prev.Fingerprint, + }, + } + if err := d.fingerprintAlta(d.Body.VeriFactu.RegistroFactura.RegistroAlta); err != nil { + return err + } + return nil +} + +func (d *Envelope) generateHashAnulacion(prev *ChainData) error { + if prev == nil { + d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ + PrimerRegistro: "S", + } + if err := d.fingerprintAnulacion(d.Body.VeriFactu.RegistroFactura.RegistroAnulacion); err != nil { + return err + } + return nil + } + d.Body.VeriFactu.RegistroFactura.RegistroAnulacion.Encadenamiento = &Encadenamiento{ + RegistroAnterior: &RegistroAnterior{ + IDEmisorFactura: prev.IDIssuer, + NumSerieFactura: prev.NumSeries, + FechaExpedicionFactura: prev.IssueDate, + Huella: prev.Fingerprint, + }, + } + 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 new file mode 100644 index 0000000..a0fecbb --- /dev/null +++ b/doc/fingerprint_test.go @@ -0,0 +1,210 @@ +package doc_test + +import ( + "testing" + + "github.com/invopop/gobl.verifactu/doc" +) + +func TestFingerprintAlta(t *testing.T) { + t.Run("Alta", func(t *testing.T) { + tests := []struct { + name string + alta *doc.RegistroAlta + prev *doc.ChainData + expected string + }{ + { + name: "Basic 1", + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", + }, + TipoFactura: "F1", + CuotaTotal: 378.0, + ImporteTotal: 2178.0, + FechaHoraHusoGenRegistro: "2024-11-20T19:00:55+01:00", + }, + prev: &doc.ChainData{ + IDIssuer: "A28083806", + NumSeries: "SAMPLE-000", + IssueDate: "10-11-2024", + Fingerprint: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + }, + expected: "9F848AF7AECAA4C841654B37FD7119F4530B19141A2C3FF9968B5A229DEE21C2", + }, + { + name: "Basic 2", + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-002", + FechaExpedicionFactura: "12-11-2024", + }, + TipoFactura: "R3", + CuotaTotal: 500.50, + ImporteTotal: 2502.55, + FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", + }, + prev: &doc.ChainData{ + IDIssuer: "A28083806", + NumSeries: "SAMPLE-001", + IssueDate: "11-11-2024", + Fingerprint: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + }, + expected: "14543C022CBD197F247F77A88F41E636A3B2569CE5787A8D6C8A781BF1B9D25E", + }, + { + name: "No Previous", + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "B08194359", + NumSerieFactura: "SAMPLE-003", + FechaExpedicionFactura: "12-11-2024", + }, + TipoFactura: "F1", + CuotaTotal: 500.0, + ImporteTotal: 2500.0, + Encadenamiento: &doc.Encadenamiento{PrimerRegistro: "S"}, + FechaHoraHusoGenRegistro: "2024-11-20T20:00:55+01:00", + }, + prev: nil, + expected: "95619096010E699BB4B88AD2B42DC30BBD809A4B1ED2AE2904DFF86D064FCF29", + }, + { + name: "No Taxes", + alta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "B85905495", + NumSerieFactura: "SAMPLE-003", + FechaExpedicionFactura: "15-11-2024", + }, + TipoFactura: "F1", + CuotaTotal: 0.0, + ImporteTotal: 1800.0, + Encadenamiento: &doc.Encadenamiento{RegistroAnterior: &doc.RegistroAnterior{Huella: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C"}}, + FechaHoraHusoGenRegistro: "2024-11-21T17:59:41+01:00", + }, + prev: &doc.ChainData{ + IDIssuer: "A28083806", + NumSeries: "SAMPLE-002", + IssueDate: "11-11-2024", + Fingerprint: "13EC0696104D1E529667184C6CDFC67D08036BCA4CD1B7887DE9C6F8F7EEC69C", + }, + expected: "9F44F498EA51C0C50FEB026CCE86BDCCF852C898EE33336EFFE1BD6F132B506E", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: tt.alta, + }, + }, + }, + } + + err := d.Fingerprint(tt.prev) + 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 *doc.RegistroAnulacion + prev *doc.ChainData + expected string + }{ + { + name: "Basic 1", + anulacion: &doc.RegistroAnulacion{ + IDFactura: &doc.IDFacturaAnulada{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", + }, + FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", + }, + prev: &doc.ChainData{ + IDIssuer: "A28083806", + NumSeries: "SAMPLE-000", + IssueDate: "10-11-2024", + Fingerprint: "4B0A5C1D3F28E6A79B8C2D1E0F3A4B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9E0F1", + }, + expected: "F5AB85A94450DF8752F4A7840C72456B753010E5EC1F26D8EAE0D4523E287948", + }, + { + name: "Basic 2", + anulacion: &doc.RegistroAnulacion{ + IDFactura: &doc.IDFacturaAnulada{ + IDEmisorFactura: "B08194359", + NumSerieFactura: "SAMPLE-002", + FechaExpedicionFactura: "12-11-2024", + }, + FechaHoraHusoGenRegistro: "2024-11-21T12:00:55+01:00", + }, + prev: &doc.ChainData{ + IDIssuer: "A28083806", + NumSeries: "SAMPLE-001", + IssueDate: "11-11-2024", + Fingerprint: "CBA051CBF59488B6978FA66E95ED4D0A84A97F5C0700EA952B923BD6E7C3FD7A", + }, + expected: "E86A5172477A636958B2F98770FB796BEEDA43F3F1C6A1C601EC3EEDF9C033B1", + }, + { + name: "No Previous", + anulacion: &doc.RegistroAnulacion{ + IDFactura: &doc.IDFacturaAnulada{ + IDEmisorFactura: "A28083806", + NumSerieFactura: "SAMPLE-001", + FechaExpedicionFactura: "11-11-2024", + }, + FechaHoraHusoGenRegistro: "2024-11-21T10:00:55+01:00", + Encadenamiento: &doc.Encadenamiento{PrimerRegistro: "S"}, + }, + prev: nil, + expected: "A166B0391BCE34DA3A5B022837D0C426F7A4E2F795EBB4581B7BD79E74BCAA95", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAnulacion: tt.anulacion, + }, + }, + }, + } + + err := d.FingerprintCancel(tt.prev) + 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 new file mode 100644 index 0000000..f45e2ed --- /dev/null +++ b/doc/invoice.go @@ -0,0 +1,173 @@ +package doc + +import ( + "errors" + "fmt" + "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/validation" +) + +var rectificative = []cbc.Code{ // Credit or Debit notes + "R1", "R2", "R3", "R4", "R5", +} + +// 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 + } + + desc, err := newDescription(inv.Notes) + if err != nil { + return nil, err + } + + dg, err := newDesglose(inv) + if err != nil { + return nil, err + } + + reg := &RegistroAlta{ + IDVersion: CurrentVersion, + 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, + TipoFactura: tf, + DescripcionOperacion: desc, + Desglose: dg, + CuotaTotal: newTotalTaxes(inv), + ImporteTotal: newImporteTotal(inv), + SistemaInformatico: s, + FechaHoraHusoGenRegistro: formatDateTimeZone(ts), + TipoHuella: TipoHuella, + } + + if inv.Customer != nil { + d, err := newParty(inv.Customer) + if err != nil { + return nil, err + } + ds := &Destinatario{ + IDDestinatario: d, + } + reg.Destinatarios = []*Destinatario{ds} + } else { + reg.FacturaSinIdentifDestinatarioArt61d = "S" + } + + 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 { + return nil, err + } + reg.TipoRectificativa = k + 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 reg.TipoFactura == "F3" { + 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) + if err != nil { + return nil, err + } + reg.Tercero = t + } + + // 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 +} + +func invoiceNumber(series cbc.Code, code cbc.Code) string { + if series == "" { + return code.String() + } + return fmt.Sprintf("%s-%s", series, code) +} + +func newDescription(notes []*cbc.Note) (string, error) { + for _, note := range notes { + if note.Key == cbc.NoteKeyGeneral { + return note.Text, nil + } + } + return "", ErrValidation.WithMessage(fmt.Sprintf("notes: missing note with key '%s'", cbc.NoteKeyGeneral)) +} + +func newImporteTotal(inv *bill.Invoice) float64 { + 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).Float64() +} + +func newTotalTaxes(inv *bill.Invoice) float64 { + totalTaxes := num.MakeAmount(0, 2) + for _, category := range inv.Totals.Taxes.Categories { + if !category.Retained { + totalTaxes = totalTaxes.Add(category.Amount) + } + } + + 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 new file mode 100644 index 0000000..b10d1d8 --- /dev/null +++ b/doc/invoice_test.go @@ -0,0 +1,107 @@ +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" +) + +func TestNewRegistroAlta(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("should contain basic document info", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + d, err := doc.NewVerifactu(inv, ts, role, sw, false) + require.NoError(t, err) + + 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) + 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 with a standard tax", 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 + + d, err := doc.NewVerifactu(inv, ts, role, sw, false) + require.NoError(t, err) + + assert.Equal(t, "S", d.Body.VeriFactu.RegistroFactura.RegistroAlta.FacturaSinIdentifDestinatarioArt61d) + }) + + t.Run("should handle rectificative invoices", func(t *testing.T) { + inv := test.LoadInvoice("cred-note-base.json") + + d, err := doc.NewVerifactu(inv, ts, role, sw, false) + require.NoError(t, err) + + reg := d.Body.VeriFactu.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, "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) { + inv := test.LoadInvoice("inv-base.json") + inv.Preceding = []*org.DocumentRef{ + { + Series: "SAMPLE", + Code: "002", + 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) + + reg := d.Body.VeriFactu.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.go b/doc/party.go new file mode 100644 index 0000000..9fe9460 --- /dev/null +++ b/doc/party.go @@ -0,0 +1,54 @@ +package doc + +import ( + "fmt" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" +) + +var idTypeCodeMap = map[cbc.Key]string{ + org.IdentityKeyPassport: "03", + org.IdentityKeyForeign: "04", + org.IdentityKeyResident: "05", + org.IdentityKeyOther: "06", +} + +func newParty(p *org.Party) (*Party, error) { + pty := &Party{ + NombreRazon: p.Name, + } + if p.TaxID != nil && p.TaxID.Code.String() != "" && p.TaxID.Country.String() == "ES" { + pty.NIF = p.TaxID.Code.String() + } else { + 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 = idTypeCodeMap[org.IdentityKeyForeign] + 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 nil +} diff --git a/doc/party_test.go b/doc/party_test.go new file mode 100644 index 0000000..2ba689b --- /dev/null +++ b/doc/party_test.go @@ -0,0 +1,85 @@ +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" + "github.com/stretchr/testify/require" +) + +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) { + inv := test.LoadInvoice("inv-base.json") + d, err := doc.NewVerifactu(inv, ts, role, sw, false) + require.NoError(t, err) + + 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) + }) + + t.Run("with passport", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.Customer = &org.Party{ + Name: "Mr. Pass Port", + Identities: []*org.Identity{ + { + Key: org.IdentityKeyPassport, + Code: "12345", + }, + }, + } + + d, err := doc.NewVerifactu(inv, ts, role, sw, false) + require.NoError(t, err) + + 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) + assert.Equal(t, "03", p.IDOtro.IDType) + assert.Equal(t, "12345", p.IDOtro.ID) + }) + + t.Run("with foreign identity", func(t *testing.T) { + inv := test.LoadInvoice("inv-base.json") + inv.Customer = &org.Party{ + Name: "Foreign Company", + TaxID: &tax.Identity{ + Country: "DE", + Code: "111111125", + }, + } + + d, err := doc.NewVerifactu(inv, ts, role, sw, false) + require.NoError(t, err) + + p := d.Body.VeriFactu.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) { + inv := test.LoadInvoice("inv-base.json") + inv.Customer = &org.Party{ + Name: "Simple Company", + } + + _, err := doc.NewVerifactu(inv, ts, role, sw, false) + require.Error(t, err) + }) +} diff --git a/doc/qr_code.go b/doc/qr_code.go new file mode 100644 index 0000000..058c45a --- /dev/null +++ b/doc/qr_code.go @@ -0,0 +1,24 @@ +package doc + +import ( + "fmt" + "net/url" +) + +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" +) + +// generateURL generates the encoded URL code with parameters. +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) + } + 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 new file mode 100644 index 0000000..666e43c --- /dev/null +++ b/doc/qr_code_test.go @@ -0,0 +1,126 @@ +package doc_test + +import ( + "testing" + + "github.com/invopop/gobl.verifactu/doc" +) + +func TestGenerateCodes(t *testing.T) { + tests := []struct { + name string + doc *doc.Envelope + expected string + }{ + { + name: "valid codes generation", + 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, + }, + }, + }, + }, + }, + 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: &doc.Envelope{ + Body: &doc.Body{ + VeriFactu: &doc.RegFactuSistemaFacturacion{ + RegistroFactura: &doc.RegistroFactura{ + RegistroAlta: &doc.RegistroAlta{ + IDFactura: &doc.IDFactura{ + IDEmisorFactura: "", + NumSerieFactura: "", + FechaExpedicionFactura: "", + }, + ImporteTotal: 0, + }, + }, + }, + }, + }, + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=&numserie=&fecha=&importe=0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.doc.QRCodes(false) + if got != tt.expected { + t.Errorf("got %v, want %v", got, tt.expected) + } + }) + } +} + +func TestGenerateURLCodeAlta(t *testing.T) { + tests := []struct { + name string + doc *doc.Envelope + expected string + }{ + { + name: "valid URL generation", + 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, + }, + }, + }, + }, + }, + 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: &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, + }, + }, + }, + }, + }, + expected: "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=A12+345%2667&numserie=SERIE%2F2023&fecha=01-09-2024&importe=1234.56", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.doc.Body.VeriFactu.RegistroFactura.RegistroAlta.Encadenamiento = &doc.Encadenamiento{ + PrimerRegistro: "S", + } + got := tt.doc.QRCodes(false) + if got != tt.expected { + t.Errorf("got %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/document.go b/document.go new file mode 100644 index 0000000..5391e04 --- /dev/null +++ b/document.go @@ -0,0 +1,67 @@ +package verifactu + +import ( + "errors" + + "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" + "github.com/invopop/gobl/l10n" +) + +// 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.Envelope, 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.NewVerifactu(inv, c.CurrentTime(), c.issuerRole, c.software, false) + if err != nil { + return nil, err + } + + return out, 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) 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.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( + &head.Stamp{ + Provider: verifactu.StampQR, + Value: code, + }, + ) + return nil +} + +func hasExistingStamps(env *gobl.Envelope) bool { + for _, stamp := range env.Head.Stamps { + if stamp.Provider.In(verifactu.StampQR) { + return true + } + } + return false +} diff --git a/examples_test.go b/examples_test.go new file mode 100644 index 0000000..dde9848 --- /dev/null +++ b/examples_test.go @@ -0,0 +1,111 @@ +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/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) + + 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) { + env := test.LoadEnvelope(example) + td, err := c.Convert(env) + require.NoError(t, err) + + // Example Data to Test the Fingerprint. + prev := &doc.ChainData{ + IDIssuer: "B12345678", + NumSeries: "SAMPLE-001", + IssueDate: "26-11-2024", + Fingerprint: "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", + } + + err = c.Fingerprint(td, prev) + require.NoError(t, err) + + outPath := test.Path("test", "data", "out", + strings.TrimSuffix(example, ".json")+".xml", + ) + + valData, err := td.BytesIndent() + require.NoError(t, err) + + if *test.UpdateOut { + data, err := td.Bytes() + require.NoError(t, err) + + 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(valData), msgUnmatchingOutFile, filepath.Base(outPath)) + }) + } +} + +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: "A1", + Version: "1.0", + NumeroInstalacion: "12345678A", + TipoUsoPosibleSoloVerifactu: "S", + TipoUsoPosibleMultiOT: "S", + IndicadorMultiplesOT: "N", + }, + 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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8311cdc --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/invopop/gobl.verifactu + +go 1.22 + +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.207.0 + github.com/invopop/validation v0.8.0 + 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 + github.com/stretchr/testify v1.8.4 +) + +require ( + 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 + github.com/buger/jsonparser v1.1.1 // 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/yaml v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mailru/easyjson v0.9.0 // 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.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 new file mode 100644 index 0000000..38a6746 --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +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= +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/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= +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.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= +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= +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/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.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/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.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= +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= +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/internal/gateways/gateways.go b/internal/gateways/gateways.go new file mode 100644 index 0000000..a9c8d19 --- /dev/null +++ b/internal/gateways/gateways.go @@ -0,0 +1,89 @@ +// Package gateways provides the VeriFactu gateway +package gateways + +import ( + "context" + "fmt" + "net/http" + "os" + "strconv" + + "github.com/go-resty/resty/v2" + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/xmldsig" +) + +// Environment defines the environment to use for connections +type Environment string + +// Environment to use for connections +const ( + EnvironmentProduction Environment = "production" + EnvironmentSandbox Environment = "sandbox" + + // Production environment not published yet + ProductionBaseURL = "xxxxxxxx" + TestingBaseURL = "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP" + + correctStatus = "Correcto" +) + +// Connection defines what is expected from a connection to a gateway. +type Connection struct { + client *resty.Client +} + +// 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 { + return nil, doc.ErrValidation.WithMessage(fmt.Errorf("preparing TLS config: %v", err).Error()) + } + c := new(Connection) + c.client = resty.New() + + switch env { + case EnvironmentProduction: + return nil, doc.ErrValidation.WithMessage("production environment not available yet") + default: + c.client.SetBaseURL(TestingBaseURL) + } + tlsConf.InsecureSkipVerify = true + c.client.SetTLSClientConfig(tlsConf) + c.client.SetDebug(os.Getenv("DEBUG") == "true") + return c, nil +} + +// Post sends the VeriFactu document to the gateway +func (c *Connection) Post(ctx context.Context, pyl []byte) error { + return c.post(ctx, TestingBaseURL, pyl) +} + +func (c *Connection) post(ctx context.Context, path string, payload []byte) error { + out := new(Envelope) + req := c.client.R(). + SetContext(ctx). + SetDebug(true). + SetHeader("Content-Type", "application/xml"). + SetContentLength(true). + SetBody(payload). + SetResult(out) + + res, err := req.Post(path) + if err != nil { + return err + } + if res.StatusCode() != http.StatusOK { + return doc.ErrValidation.WithCode(strconv.Itoa(res.StatusCode())) + } + if out.Body.Respuesta.EstadoEnvio != correctStatus { + 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) + } + return err + } + + return nil +} 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/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/data/cred-note-base.json b/test/data/cred-note-base.json new file mode 100644 index 0000000..3598f9c --- /dev/null +++ b/test/data/cred-note-base.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "9c40da45b9c066e38ce938250b723dd1a443e2ab00508d8cfe74c16827b42e48" + } + }, + "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-correction-type": "I", + "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-op-class": "S1", + "es-verifactu-regime": "01" + } + } + ], + "total": "1620.00" + } + ], + "totals": { + "sum": "1620.00", + "total": "1620.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + }, + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + } + ], + "amount": "340.20" + } + ], + "sum": "340.20" + }, + "tax": "340.20", + "total_with_tax": "1960.20", + "payable": "1960.20" + }, + "notes": [ + { + "key": "general", + "text": "This is a credit note with a standard tax" + } + ] + } +} \ No newline at end of file diff --git a/test/data/cred-note-exemption.json b/test/data/cred-note-exemption.json new file mode 100644 index 0000000..c76afc3 --- /dev/null +++ b/test/data/cred-note-exemption.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "6fdaba86c331bfb20fcfcad5d92aa3b62ceddd822647ab0c83b8d74c43d53d10" + } + }, + "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-correction-type": "I", + "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-op-class": "S1", + "es-verifactu-regime": "01" + } + } + ], + "total": "1620.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "ext": { + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" + } + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + }, + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + }, + { + "ext": { + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" + }, + "base": "10.00", + "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 credit note with an exemption" + } + ] + } +} \ No newline at end of file diff --git a/test/data/inv-base.json b/test/data/inv-base.json new file mode 100644 index 0000000..aa85f4a --- /dev/null +++ b/test/data/inv-base.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "6d05f8571cef28298a289b5c0ca4ba91f2bc65c442fcaeadd483f7383be3d86d" + } + }, + "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": "004", + "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-op-class": "S1", + "es-verifactu-regime": "01" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + }, + "base": "1800.00", + "percent": "21.0%", + "amount": "378.00" + } + ], + "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 with a standard tax" + } + ] + } +} \ No newline at end of file diff --git a/test/data/inv-eqv-sur-b2c.json b/test/data/inv-eqv-sur-b2c.json new file mode 100644 index 0000000..2c6cbc1 --- /dev/null +++ b/test/data/inv-eqv-sur-b2c.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", + "dig": { + "alg": "sha256", + "val": "c42197ee05d5a9698fcc8637284ea0917c35bd4f7f87102d5c88fe148b67f0b9" + } + }, + "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": "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" + } + ] + }, + "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%", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard+eqs", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + }, + "base": "1800.00", + "percent": "21.0%", + "surcharge": { + "percent": "5.2%", + "amount": "93.60" + }, + "amount": "378.00" + } + ], + "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 B2C invoice with a standard tax and an equivalence surcharge" + } + ] + } +} \ No newline at end of file diff --git a/test/data/inv-eqv-sur.json b/test/data/inv-eqv-sur.json new file mode 100644 index 0000000..a6abc05 --- /dev/null +++ b/test/data/inv-eqv-sur.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120728", + "dig": { + "alg": "sha256", + "val": "f1f558d9398d298d5921952b5ec7fadb8c7556c939481be5fc2d0be04a39daf0" + } + }, + "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": "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%", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard+eqs", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + }, + "base": "1800.00", + "percent": "21.0%", + "surcharge": { + "percent": "5.2%", + "amount": "93.60" + }, + "amount": "378.00" + } + ], + "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 with a standard tax and an equivalence surcharge" + } + ] + } +} \ 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..b07163d --- /dev/null +++ b/test/data/inv-rev-charge.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "76bd0b5eaf3ffb5619271d2bcd0054b051202db7a350a0818f1a0a150a3287a1" + } + }, + "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", + "ext": { + "es-verifactu-op-class": "S2", + "es-verifactu-regime": "01" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "ext": { + "es-verifactu-op-class": "S2", + "es-verifactu-regime": "01" + }, + "base": "1800.00", + "amount": "0.00" + } + ], + "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 with a reverse charge" + } + ] + } +} \ No newline at end of file diff --git a/test/data/inv-zero-tax.json b/test/data/inv-zero-tax.json new file mode 100644 index 0000000..f1874a4 --- /dev/null +++ b/test/data/inv-zero-tax.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "d554de33c67a8646180b661d8600fe7d7ba1047d95643e6a3f49d2e9fceaa7a1" + } + }, + "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", + "ext": { + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "ext": { + "es-verifactu-exempt": "E1", + "es-verifactu-regime": "01" + }, + "base": "1800.00", + "amount": "0.00" + } + ], + "amount": "0.00" + } + ], + "sum": "0.00" + }, + "tax": "0.00", + "total_with_tax": "1800.00", + "payable": "1800.00" + }, + "notes": [ + { + "key": "general", + "text": "This is an invoice exempt from tax" + } + ] + } +} \ 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..9a4d251 --- /dev/null +++ b/test/data/out/cred-note-base.xml @@ -0,0 +1,79 @@ + + + + + + + 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 + + + This is a credit note with a standard tax + T + + Provide One S.L. + B85905495 + + + + Sample Customer + 54387763P + + + + + 01 + 01 + S1 + 21 + -1620 + -340.2 + + + -340.2 + -1960.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 + B03877764C7C609DE6C81BCDDA862D686F7EB96855EC88EF89FCEDFB6C9008F1 + + + + + \ No newline at end of file diff --git a/test/data/out/cred-note-exemption.xml b/test/data/out/cred-note-exemption.xml new file mode 100644 index 0000000..57511e6 --- /dev/null +++ b/test/data/out/cred-note-exemption.xml @@ -0,0 +1,85 @@ + + + + + + + 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 + + + This is a credit note with an exemption + 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 diff --git a/test/data/out/inv-base.xml b/test/data/out/inv-base.xml new file mode 100755 index 0000000..a84b81b --- /dev/null +++ b/test/data/out/inv-base.xml @@ -0,0 +1,71 @@ + + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-004 + 13-11-2024 + + Invopop S.L. + F1 + This is a sample invoice with a standard tax + T + + Invopop S.L. + B85905495 + + + + Sample Consumer + B63272603 + + + + + 01 + 01 + S1 + 21 + 1800 + 378 + + + 378 + 2178 + + + 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 + E3BCF6A0DA47A13EB99A7F3C64D7F701AF9EBDDF9D4498D5805E59F8A2BBF3C9 + + + + + \ 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..8d290d0 --- /dev/null +++ b/test/data/out/inv-eqv-sur-b2c.xml @@ -0,0 +1,68 @@ + + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-432 + 11-11-2024 + + Invopop S.L. + F1 + This is a sample B2C invoice with a standard tax and an equivalence surcharge + S + T + + Invopop S.L. + B85905495 + + + + 01 + 01 + S1 + 21 + 1800 + 378 + 5.2 + 93.6 + + + 378 + 2178 + + + 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 + 8C446C65AD424BBFB8A8E7DC8B3DC331B7FFBBDD08C87D8DEAEAB82E6EBA921F + + + + + \ No newline at end of file diff --git a/test/data/out/inv-eqv-sur.xml b/test/data/out/inv-eqv-sur.xml new file mode 100755 index 0000000..abade59 --- /dev/null +++ b/test/data/out/inv-eqv-sur.xml @@ -0,0 +1,73 @@ + + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-432 + 11-11-2024 + + Invopop S.L. + F1 + This is a sample invoice with a standard tax and an equivalence surcharge + T + + Invopop S.L. + B85905495 + + + + Sample Consumer + B63272603 + + + + + 01 + 01 + S1 + 21 + 1800 + 378 + 5.2 + 93.6 + + + 378 + 2178 + + + 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 + 8C446C65AD424BBFB8A8E7DC8B3DC331B7FFBBDD08C87D8DEAEAB82E6EBA921F + + + + + \ 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..4e5e225 --- /dev/null +++ b/test/data/out/inv-rev-charge.xml @@ -0,0 +1,69 @@ + + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-003 + 13-11-2024 + + Invopop S.L. + F1 + This is a sample invoice with a reverse charge + T + + Invopop S.L. + B85905495 + + + + Sample Consumer + B63272603 + + + + + 01 + 01 + S2 + 1800 + + + 0 + 1800 + + + 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 + E843054910E72C301EB910265BC4903BBE523F342F93341A8704F830A747F333 + + + + + \ 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..7bc0938 --- /dev/null +++ b/test/data/out/inv-zero-tax.xml @@ -0,0 +1,69 @@ + + + + + + + Invopop S.L. + B85905495 + + + + + 1.0 + + B85905495 + SAMPLE-003 + 15-11-2024 + + Invopop S.L. + F1 + This is an invoice exempt from tax + T + + Invopop S.L. + B85905495 + + + + Sample Consumer + B63272603 + + + + + 01 + 01 + E1 + 1800 + + + 0 + 1800 + + + 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 + 332139FCB5DF23D65ABB3FB9B7B76D3E3C260DD5D4863B2E6A4E64FF10EE459E + + + + + \ No newline at end of file diff --git a/test/example_prev.json b/test/example_prev.json new file mode 100644 index 0000000..dcfa6f6 --- /dev/null +++ b/test/example_prev.json @@ -0,0 +1,7 @@ +{ + "issuer": "B123456789", + "num_series": "FACT-001", + "issue_date": "2024-11-15", + "fingerprint": "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" +} + 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/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/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/RespuestaSuministro.xsd b/test/schema/RespuestaSuministro.xsd new file mode 100644 index 0000000..d4902b9 --- /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 b/test/schema/SistemaFacturacion.wsdl new file mode 100644 index 0000000..cdf6e52 --- /dev/null +++ b/test/schema/SistemaFacturacion.wsdl @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..bde17b7 --- /dev/null +++ b/test/schema/SuministroLR.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + Datos correspondientes a los registros de facturacion + + + + + + + + + \ No newline at end of file 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/test/test.go b/test/test.go new file mode 100644 index 0000000..cb63689 --- /dev/null +++ b/test/test.go @@ -0,0 +1,118 @@ +// Package test provides common functions for testing. +package test + +import ( + "bytes" + "encoding/json" + "flag" + "os" + "path" + "path/filepath" + "strings" + + "github.com/invopop/gobl" + "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 { + 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) + } + + 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 +} + +// 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] +} diff --git a/verifactu.go b/verifactu.go new file mode 100644 index 0000000..04006af --- /dev/null +++ b/verifactu.go @@ -0,0 +1,126 @@ +// Package verifactu provides the VeriFactu client +package verifactu + +import ( + "context" + "time" + + "github.com/invopop/gobl.verifactu/doc" + "github.com/invopop/gobl.verifactu/internal/gateways" + "github.com/invopop/xmldsig" +) + +// Client provides the main interface to the VeriFactu package. +type Client struct { + software *doc.Software + 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 { + return func(c *Client) { + c.curTime = curTime + } +} + +// 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) { + c := new(Client) + c.software = software + + // Set default values that can be overwritten by the options + c.env = gateways.EnvironmentSandbox + c.issuerRole = doc.IssuerRoleSupplier + + for _, opt := range opts { + opt(c) + } + + 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 +} + +// 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 + } +} + +// InSandbox defines the connection to use the testing environment. +func InSandbox() Option { + return func(c *Client) { + c.env = gateways.EnvironmentSandbox + } +} + +// Post will send the document to the VeriFactu gateway. +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 +} + +// CurrentTime returns the current time to use when generating +// the VeriFactu document. +func (c *Client) CurrentTime() time.Time { + if !c.curTime.IsZero() { + return c.curTime + } + return time.Now() +} + +// Sandbox returns true if the client is using the sandbox environment. +func (c *Client) Sandbox() bool { + return c.env == gateways.EnvironmentSandbox +}