From 4ab7670232086f715ad3375343f2d859be1b4aca Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 17 Apr 2026 13:03:06 +0200 Subject: [PATCH] Implement VeriFactu API with certificate management, invoice submission and local fallback - Add API handlers for facturas (alta/anulacion) - Implement certificate storage with temp/permanent flow - Add token generation for authenticated sessions - Add fallback to local storage when AEAT unavailable - Update config with certificate path/password - Add client certificate conversion for TLS - Add comprehensive documentation --- api/handler.go | 184 +++++++++++++++++++-- api/router.go | 10 -- config.yml | 6 +- documentacion/README.md | 92 +++++++++++ documentacion/api.md | 152 +++++++++++++++++ documentacion/arqui.md | 81 ++++++++++ documentacion/certificado_pruebas.md | 139 ++++++++++++++++ documentacion/config.md | 90 +++++++++++ documentacion/formato_datos.md | 94 +++++++++++ documentacion/seguridad.md | 92 +++++++++++ documentacion/testing.md | 102 ++++++++++++ documentacion/tokens.md | 82 ++++++++++ documentacion/verifactu.md | 103 ++++++++++++ go.mod | 2 +- internal/cert/storage.go | 170 ++++++++++++++++--- internal/cert/validate_p12.py | 42 +++++ internal/cert/validator.go | 95 +++++++++++ internal/config/config.go | 10 +- internal/crypto/crypto.go | 4 + internal/factura.go | 234 +++++++++++++++++++++++++++ internal/hash.go | 61 +++++++ internal/models.go | 7 +- internal/transformer.go | 7 +- main.go | 40 ++++- verifactu/client.go | 201 +++++++++++++++++------ verifactu/soap.go | 182 +++++++++++++++++++++ verifactu/xml.go | 164 ++++++++++++++++++- 27 files changed, 2334 insertions(+), 112 deletions(-) create mode 100644 documentacion/README.md create mode 100644 documentacion/api.md create mode 100644 documentacion/arqui.md create mode 100644 documentacion/certificado_pruebas.md create mode 100644 documentacion/config.md create mode 100644 documentacion/formato_datos.md create mode 100644 documentacion/seguridad.md create mode 100644 documentacion/testing.md create mode 100644 documentacion/tokens.md create mode 100644 documentacion/verifactu.md create mode 100644 internal/cert/validate_p12.py create mode 100644 internal/cert/validator.go create mode 100644 internal/factura.go create mode 100644 verifactu/soap.go diff --git a/api/handler.go b/api/handler.go index 0d5662b..24cdd54 100644 --- a/api/handler.go +++ b/api/handler.go @@ -2,26 +2,37 @@ package api import ( "encoding/base64" + "encoding/json" "fmt" "io" + "log" "net/http" + "VerifactuMidAPI/internal" "VerifactuMidAPI/internal/cert" "VerifactuMidAPI/internal/config" "VerifactuMidAPI/internal/crypto" ) -type Handler struct { - cfg *config.Config - cert *cert.Storage - crypto *crypto.KeyPair +type RegisterInput struct { + CertName string `json:"cert_name"` + CertPath string `json:"cert_path"` + PasswordEncrypted string `json:"password_encrypted"` } -func New(cfg *config.Config, certStorage *cert.Storage, keyPair *crypto.KeyPair) *Handler { +type Handler struct { + cfg *config.Config + cert *cert.Storage + crypto *crypto.KeyPair + facturaSvc *internal.FacturaService +} + +func New(cfg *config.Config, certStorage *cert.Storage, keyPair *crypto.KeyPair, facturaSvc *internal.FacturaService) *Handler { return &Handler{ - cfg: cfg, - cert: certStorage, - crypto: keyPair, + cfg: cfg, + cert: certStorage, + crypto: keyPair, + facturaSvc: facturaSvc, } } @@ -53,6 +64,161 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } + var input RegisterInput + if err := json.Unmarshal(body, &input); err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"invalid_json"}`)) + return + } + + if input.CertName == "" || input.CertPath == "" || input.PasswordEncrypted == "" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"missing_fields"}`)) + return + } + + decodedPass, err := base64.StdEncoding.DecodeString(input.PasswordEncrypted) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"invalid_password_encrypted"}`)) + return + } + + plainPassBytes, err := h.crypto.Decrypt(decodedPass) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"decrypt_failed"}`)) + return + } + + plainPass := string(plainPassBytes) + + validation := cert.ValidateP12(input.CertPath, plainPass) + if !validation.Valid { + resp, _ := json.Marshal(map[string]interface{}{ + "success": false, + "error": validation.Error, + "cert": validation.CertInfo, + }) + w.Header().Set("Content-Type", "application/json") + w.Write(resp) + return + } + + tempPath, err := h.cert.StoreTemp(input.CertName, input.CertPath, plainPass) + if err != nil { + h.cert.DeleteTemp(tempPath) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"temp_storage_failed"}`)) + return + } + + if len(validation.Warnings) > 0 { + storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass) + if err != nil { + h.cert.DeleteTemp(tempPath) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"storage_failed"}`)) + return + } + + tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) + return + } + + resp, _ := json.Marshal(map[string]interface{}{ + "success": true, + "cert": validation.CertInfo, + "token": tokenData.Token, + "warnings": validation.Warnings, + }) + w.Header().Set("Content-Type", "application/json") + w.Write(resp) + return + } + + storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass) + if err != nil { + h.cert.DeleteTemp(tempPath) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"storage_failed"}`)) + return + } + + tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) + return + } + + resp, _ := json.Marshal(map[string]interface{}{ + "success": true, + "cert": validation.CertInfo, + "token": tokenData.Token, + }) w.Header().Set("Content-Type", "application/json") - w.Write(body) + w.Write(resp) +} + +func (h *Handler) HandleFacturas(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + log.Printf("facturas request: %s", string(body)) + + var input internal.AltaInput + if err := json.Unmarshal(body, &input); err != nil { + log.Printf("json unmarshal error: %v", err) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"invalid_json"}`)) + return + } + + log.Printf("facturas input:%+v", input) + + output, _ := h.facturaSvc.ProcessAlta(input) + + log.Printf("facturas output:%+v", output) + + resp, _ := json.Marshal(output) + w.Header().Set("Content-Type", "application/json") + w.Write(resp) +} + +func (h *Handler) HandleFacturasAnular(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var input internal.AnulacionInput + if err := json.Unmarshal(body, &input); err != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"success":false,"error":"invalid_json"}`)) + return + } + + output, _ := h.facturaSvc.ProcessAnulacion(input) + + resp, _ := json.Marshal(output) + w.Header().Set("Content-Type", "application/json") + w.Write(resp) } diff --git a/api/router.go b/api/router.go index 848e2db..8f480a6 100644 --- a/api/router.go +++ b/api/router.go @@ -16,13 +16,3 @@ func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) } - -func (h *Handler) HandleFacturas(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"endpoint":"facturas","status":"not implemented"}`)) -} - -func (h *Handler) HandleFacturasAnular(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"endpoint":"facturas/anular","status":"not implemented"}`)) -} diff --git a/config.yml b/config.yml index 1b00c40..dc21649 100644 --- a/config.yml +++ b/config.yml @@ -2,10 +2,12 @@ server: port: 6789 verifactu: - environment: test + production: false certificates: - storage_path: ./certs/ + storage_path: ./data/certs/ + cert_file: ./data/certs/personal.p12 + cert_password: Mecedora12 crypto: keys_path: ./keys/ diff --git a/documentacion/README.md b/documentacion/README.md new file mode 100644 index 0000000..1028fa4 --- /dev/null +++ b/documentacion/README.md @@ -0,0 +1,92 @@ +# VeriFactu MidAPI + +API intermediaria para la comunicación con el sistema de facturación VeriFactu de la AEAT (Agencia Estatal de Administración Tributaria) de España. + +## Propósito + +Esta API actúa como intermediaria entre aplicaciones empresariales y el sistema VeriFactu de Hacienda, permitiendo: + +- **Alta de facturas**: Registro de facturas emitidas en el sistema de Hacienda +- **Anulación de facturas**: Cancelación de facturas previamente registradas +- **Gestión de certificados**: Registro y validación de certificados digitales cualificados +- **Sistema de tokens**: Autenticación mediante tokens para operaciones con facturas + +## Características + +- Implementación en **Go 1.26+** +- Sin dependencias externas +- **Endpoints REST** para integración +- Criptografía **RSA** para cifrado de contraseñas +- certificados almacenados temporalmente para validación, luego de forma permanente +- Tokens de acceso similares a APIs como OpenAI + +## Estructura del Proyecto + +``` +VerifactuMidAPI/ +├── api/ → Handlers HTTP, rutas +├── internal/ → Lógica de negocio +│ ├── cert/ → Gestión de certificados +│ ├── config/ → Configuración +│ └── crypto/ → Criptografía RSA +├── verifactu/ → Cliente SOAP para AEAT +├── documentacion/ → Documentación técnica +└── test/ → Pruebas +``` + +## Inicio Rápido + +```bash +# Compilar +go build -o verifactu-api.exe ./main.go + +# Ejecutar +./verifactu-api.exe +``` + +## Configuración + +Editar `config.yml`: + +```yaml +server: + port: 6789 + +verifactu: + production: false # true para producción + +certificates: + storage_path: ./data/certs/ + +crypto: + keys_path: ./keys/ +``` + +## Estado + +- [x] Alta de facturas +- [x] Fallback local (cuando AEAT no disponible) +- [x] Tokens para certificados +- [x] Registro de certificados +- [x] Cifrado RSA de contraseñas +- [x] Fallback a local cuando AEAT devuelve error +- [ ] Anulación de facturas (básico) +- [ ] Consultas +- [ ] Subsanación +- [ ] Conexión real con AEAT (certificado necesario en servidor con Python cryptography) + +## Pruebas + +```bash +# Tests de certificados +python test/run_tests.py + +# Test de factura +python test_invoice.py +``` + +## Notas + +- AEAT devuelve 403 Forbidden (necesita certificado cliente) +- Fallback local guarda facturas cuando AEAT no disponible +- Certificado se convierte usando Python cryptography \ No newline at end of file diff --git a/documentacion/api.md b/documentacion/api.md new file mode 100644 index 0000000..a7f057d --- /dev/null +++ b/documentacion/api.md @@ -0,0 +1,152 @@ +# API Reference + +## Endpoints + +### Health +``` +GET /api/v1/health +``` +Verifica que la API está funcionando. + +**Response:** +```json +{"status": "ok"} +``` + +--- + +### Obtener Clave Pública +``` +GET /api/v1/auth/public-key +``` +Obtiene la clave pública RSA para cifrar contraseñas. + +**Response:** +```json +{"public_key": "base64_encoded_key"} +``` + +--- + +### Registrar Certificado +``` +POST /api/v1/auth/register +``` +Registra y valida un certificado digital. + +**Request:** +```json +{ + "cert_name": "mi_certificado", + "cert_path": "C:/ruta/al/certificado.p12", + "password_encrypted": "base64_encoded_password" +} +``` + +**Response (éxito):** +```json +{ + "success": true, + "cert": { + "subject": "...", + "issuer": "...", + "expired": false, + "expiring_soon": false, + "days_until_expiry": 365 + }, + "token": "A1B2C3D4..." +} +``` + +**Response (error):** +```json +{ + "success": false, + "error": "certificate_expired", + "cert": {...} +} +``` + +--- + +### Alta de Factura +``` +POST /api/v1/facturas +``` +Registra una factura en VeriFactu. No requiere token (el certificado se selecciona internamente o usa el primero disponible). + +**Request:** +```json +{ + "tipo": "alta", + "factura": { + "emisor_nif": "53950250R", + "num_serie": "FV2026/001", + "fecha_expedicion": "17-04-2026", + "tipo_factura": "F1", + "descripcion": "Factura de prueba", + "iva": [ + {"base": 100.00, "cuota": 21.00, "tipo": 21.0} + ], + "importe_total": 121.00 + }, + "sistema": { + "nombre": "Mi Sistema", + "nif_proveedor": "53950250R", + "version": "1.0" + } +} +``` + +**Response (AEAT disponible):** +```json +{ + "success": true, + "csv": "CSV1234567ABC", + "estado": "Correcto" +} +``` + +**Response (fallback local):** +```json +{ + "success": true, + "csv": "0CE5F940CEA...", + "estado": "Correcto (local)" +} +``` + +--- + +### Anular Factura +``` +POST /api/v1/facturas/anular +``` +Anula una factura previamente registrada. + +**Request:** +```json +{ + "tipo": "anulacion", + "factura": { + "emisor_nif": "53950250R", + "num_serie": "FV2026/001", + "fecha_expedicion": "17-04-2026" + } +} +``` + +## Códigos de Error + +| Código | Descripción | +|--------|------------| +| `certificate_expired` | El certificado ha expirado | +| `certificate_not_yet_valid` | El certificado aún no es válido | +| `certificate_expiring_soon` | El certificado caduca en menos de 30 días | +| `invalid_password_or_format` | Contraseña incorrecta o formato inválido | +| `file_not_found` | El archivo de certificado no existe | +| `validation_failed` | Los datos de la factura no son válidos | +| `aeat_error` | Error comunicando con la AEAT | +| `aeat_fault` | Error SOAP de la AEAT | +| `hash_save_error` | Error guardando el hash local | +| `hash_storage_error` | Error leyendo el hash anterior | \ No newline at end of file diff --git a/documentacion/arqui.md b/documentacion/arqui.md new file mode 100644 index 0000000..ca289cc --- /dev/null +++ b/documentacion/arqui.md @@ -0,0 +1,81 @@ +# Arquitectura + +## Visión General + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Cliente │────▶│ API REST │────▶│ AEAT │ +│ (App) │ │ (this) │ │ VeriFactu │ +└─────────────┘ └──────────────┘ └─────────────┘ +``` + +## Capas + +### 1. API Layer (`api/`) +- **handler.go**: Handlers HTTP para endpoints +- **router.go**: Registro de rutas + +### 2. Business Logic (`internal/`) +- **factura.go**: Servicio de facturas (alta, anulación) +- **transformer.go**: Transformación JSON → datos de factura +- **hash.go**: Cálculo de hash encadenado +- **models.go**: Modelos y validación de entrada +- **config/**: Carga de configuración +- **cert/**: Gestión de certificados +- **crypto/**: Criptografía RSA + +### 3. External Communication (`verifactu/`) +- **client.go**: Cliente SOAP para AEAT +- **soap.go**: Construcción de mensajes SOAP +- **xml.go**: Generación XML VeriFactu + +## Flujo de Datos + +``` +Request HTTP + │ + ▼ +JSON Input + │ + ▼ +ValidateInput() ──▶ ValidationError[] + │ + ▼ +TransformToInvoiceData() + │ + ▼ +CalculateHash(prevHash) + │ + ▼ +BuildSOAPRequest() + │ + ▼ +SendToAEAT() + │ + ▼ +Response +``` + +## Cifrado de Contraseñas + +El cliente envía la contraseña del certificado cifrada con la clave pública RSA de la API: + +``` +1. GET /api/v1/auth/public-key → Obtener clave pública +2. RSA encrypt(password) → Cifrar contraseña +3. POST /api/v1/auth/register → Registrar cert con password cifrada +``` + +## Tokens + +Al registrar un certificado se genera un token que identifica la sesión: + +```json +{ + "success": true, + "cert": {...}, + "token": "A1B2C3D4E5F6..." +} +``` + +Este token se usa en requests de facturas para identificar el certificado a usar. \ No newline at end of file diff --git a/documentacion/certificado_pruebas.md b/documentacion/certificado_pruebas.md new file mode 100644 index 0000000..c7b14d7 --- /dev/null +++ b/documentacion/certificado_pruebas.md @@ -0,0 +1,139 @@ +# Obtener Certificado de Pruebas AEAT + +## Introducción + +Para enviar facturas al entorno de pruebas de VeriFactu (no en producción), necesitas un certificado digital de pruebas registrado en el sistema de la AEAT. + +## Opciones de Certificados + +### Opción 1: Certificado FNMT (Fábrica Nacional de Moneda y Timbre) + +Es el más común para empresas y autónomos en España. + +**Pasos:** +1. Ir a https://www.fnmt.es/consultas-y-resolucion-de-incidentes/centros-de-emision-y-oficinas +2. Buscar una oficina cercana FNMT +3. Acudir presencialmente con DNI +4. Solicitar certificado de persona física +5. Te lo emiten en el acto + +### Opción 2: Certificado de la AEAT (solo pruebas) + +La AEAT ofrece certificados de pruebas: + +**Pasos:** +1. Ir a https://preportal.aeat.es +2. Registro como usuario +3. Solicitar certificado de prueba +4. Descargar archivo .p12 + +> **Nota**: Los certificados de prueba de la AEAT suelen tener validez limitada. + +### Opción 3: Certificado personal (prod) + +Si ya tienes tu certificado personal (el que usaste antes), puede que funcione en producción. + +**Verificar:** +1. ¿Está vigente? (no expired) +2. ¿Es cualificado? (firmado por autoridad reconocida) + +## Registrar Nuevo Certificado + +Una vez tengas el archivo .p12: + +### 1. Copiar a una ubicación segura + +```bash +cp micertificado.p12 ./data/certs/ +``` + +### 2. Actualizar config.yml + +```yaml +certificates: + storage_path: ./data/certs/ + cert_file: ./data/certs/nuevo_cert.p12 + cert_password: TU_CONTRASEÑA +``` + +### 3. Convertir certificado + +La API necesita el certificado en formato PEM: + +```bash +python convert_cert.py ./data/certs/nuevo_cert.p12 TU_CONTRASEÑA +``` + +Esto genera: +- `cert_key.pem` (clave privada) +- `cert_cert.pem` (certificado público) + +### 4. Reiniciar API + +```bash +# Detener API +taskkill /F /IM main.exe + +# Iniciar +go run main.go +``` + +### 5. Probar + +```bash +python test_invoice.py +``` + +## Estructura de Directorios + +``` +VerifactuMidAPI/ +├── data/ +│ └── certs/ +│ ├── personal.p12 # Tu certificado +│ ├── cert_key.pem # Generado automáticamente +│ └── cert_cert.pem # Generado automáticamente +└── config.yml # Configuración +``` + +## Verificar Certificado + +Para verificar que el certificado es válido: + +```powershell +openssl pkcs12 -in .\data\certs\personal.p12 -passin pass:TU_PASS -nokeys -clcerts | openssl x509 -noout -dates +``` + +Muestra: +- Not Before: fecha de inicio de validez +- Not After: fecha de caducidad + +## Problemas Comunes + +### "403 Forbidden" +- El certificado no está autorizado en el entorno de pruebas +- O el certificado es de producción y estás en test + +### "Certificate expired" +- El certificado ha caducado +- Solicitar uno nuevo + +### "Invalid password" +- La contraseña del .p12 es incorrecta + +### "tls: failed to find any PEM data" +- Error al convertir el certificado +- Ejecutar `python convert_cert.py` manualmente para ver el error + +## Siguientes Pasos + +1. Obtener certificado de pruebas FNMT +2. Registrar en la API +3. Probar con `python test_invoice.py` +4. Verificar que devuelve estado "Correcto" (no "Correcto (local)") + +## Recursos + +- FNMT: https://www.fnmt.es +- Portal AEAT Pruebas: https://preportal.aeat.es +- Sede AEAT: https://sede.agenciatributaria.gob.es \ No newline at end of file diff --git a/documentacion/config.md b/documentacion/config.md new file mode 100644 index 0000000..e3ec870 --- /dev/null +++ b/documentacion/config.md @@ -0,0 +1,90 @@ +# Configuración + +## Fichero config.yml + +```yaml +server: + port: 6789 + +verifactu: + production: false + +certificates: + storage_path: ./data/certs/ + +crypto: + keys_path: ./keys/ + name: "VeriFactu API" + email: "admin@ejemplo.com" +``` + +## Explicación de Parametros + +### server.port +Puerto TCP donde escucha la API. + +### verifactu.production +- `false`: Entorno de testing (prewww2.aeat.es) +- `true`: Entorno de producción (www2.agenciatributaria.gob.es) + +### certificates.storage_path +Directorio donde se almacenan los certificados registrados. + +### crypto.keys_path +Directorio donde se almacenan las claves RSA. + +### crypto.name +Nombre para generar nuevas claves RSA (si no existen). + +### crypto.email +Email para generar nuevas claves RSA. + +## Variables de Entorno + +```bash +# Sobrescribir configuración +VERIFACTU_PORT=8080 +VERIFACTU_PRODUCTION=true +``` + +## Estructura de Directorios + +``` +proyecto/ +├── api/ +├── internal/ +├── verifactu/ +├── documentacion/ +├── test/ +├── data/ +│ └── certs/ # Certificados registrados +├── keys/ # Claves RSA +├── config.yml # Configuración +├── validate_cert.ps1 # Script de validación +└── main.go +``` + +## Primera Ejecución + +1. Compilar: `go build -o verifactu-api.exe ./main.go` +2. Ejecutar: `./verifactu-api.exe` +3. La API genera claves RSA automaticamente +4. Crea estructura de directorios + +## Cambio de Entorno + +Para cambiar de test a producción: + +1. Editar `config.yml`: +```yaml +verifactu: + production: true +``` + +2. Reiniciar la API + +## Puertos + +- 6789: Puerto por defecto +- 443: HTTPS producción +- 80: HTTP (no recomendado) \ No newline at end of file diff --git a/documentacion/formato_datos.md b/documentacion/formato_datos.md new file mode 100644 index 0000000..4e0ac36 --- /dev/null +++ b/documentacion/formato_datos.md @@ -0,0 +1,94 @@ +# Formatos de Datos + +## NIF (Número de Identificación Fiscal) + +### Personas Físicas +- 8 dígitos + 1 letra final +- Ejemplo: `53950250R` + +``` +^[A-Z0-9]\d{7}[A-Z]$ +``` + +### CIF (Identificación Fiscal Empresas) +- 1 letra + 8 dígitos + 1 letra +- Ejemplo: `A12345678` + +## Fechas + +Formato: `dd-mm-yyyy` + +Ejemplo: `17-04-2026` + +## Tipos de Factura + +| Código | Descripción | +|--------|------------| +| F1 | Factura completa | +| F2 | Factura simplificada (ticket) | +| R1 | Rectificativa por diferencial | +| R2 | Rectificativa por sustitución | +| R3 | Rectificativa por descuento | +| R4 | Rectificativa por devolución | +| R5 | Rectificativa por的其他原因 | + +## Sistema Informático + +| Campo | Descripción | Ejemplo | +|-------|------------|--------| +| Nombre | Nombre del sistema | Mi ERP | +| NIFProveedor | NIF del proveedor | 53950250R | +| Version | Versión del software | 1.0 | +| NombreSistema | Nombre técnico | Mi-ERP-v1 | +| NumeroInstalacion | Número de instalación | 1 | +| TipoUsoVerifactu | Tipo de uso VeriFactu | S | + +## IVA + +Cada entrada de IVA: + +| Campo | Descripción | +|-------|------------| +| Base | Base imponible | +| Cuota | Cuota IVA | +| Tipo | Porcentaje (21.0, 10.0, 4.0) | +| ClaveRegimen | Clave de régimen (01=general) | +| Calificacion | Calificación (S1=sin inversa) | + +## Ejemplo Completo + +```json +{ + "tipo": "alta", + "factura": { + "emisor_nif": "53950250R", + "num_serie": "FV2026/001", + "fecha_expedicion": "17-04-2026", + "tipo_factura": "F1", + "descripcion": "Factura de prueba", + "iva": [ + { + "base": 100.00, + "cuota": 21.00, + "tipo": 21.0 + } + ], + "importe_total": 121.00 + }, + "sistema": { + "nombre": "Mi ERP", + "nif_proveedor": "53950250R", + "version": "1.0" + } +} +``` + +## Hash Encadenado + +Cada factura incluye el hash SHA-256 de la anterior: + +```go +hashactual = SHA256(datos_factura + hash_anterior) +``` + +Esto crea una cadena inmutable de facturas. \ No newline at end of file diff --git a/documentacion/seguridad.md b/documentacion/seguridad.md new file mode 100644 index 0000000..2386607 --- /dev/null +++ b/documentacion/seguridad.md @@ -0,0 +1,92 @@ +# Seguridad y Certificados + +## Certificados Digitales + +### Requisitos + +- Certificado cualificado de firma electrónica (eIDAS) +- Formato **.p12** o **.pfx** +- Contraseña válida + +### Validación + +La API valida: +1. **Existencia del archivo** +2. **Contraseña correcta** +3. **Fechas de validez** (no expirado, no futuror) +4. **Días hasta expiración** + +### Almacenamiento + +``` +Flujo temporal: +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Original │───▶│ /tmp/ │───▶│ /certs/ │ +│ (user) │ │ (validado)│ │ (permanente) +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +1. El usuario envía el certificado original +2. Se guarda temporalmente en `data/certs/tmp/` +3. Se valida +4. Si es válido, se mueve a `data/certs/` +5. Si falla, se borra el temporal + +## Cifrado RSA + +### Porqué RSA + +- Las contraseñas de certificados no se envían en texto plano +- El cliente cifra la contraseña con la clave pública de la API +- Solo la API puede descifrarla (tiene la clave privada) + +### Proceso + +``` +1. API genera par de claves RSA al inicio +2. Cliente pide /api/v1/auth/public-key +3. Cliente cifra password con clave pública +4. Cliente envía cifrada +5. API descifra con clave privada +``` + +### Generación de Claves + +Las claves RSA se generan automáticamente en `./keys/`: +- `private.pem`: Clave privada (nunca expuesta) +- `public.pem`: Clave pública + +## HTTPS + +**Importante**: En producción, la API debe usar HTTPS. + +```yaml +# config.yml +server: + port: 443 + cert: ./ssl/server.crt + key: ./ssl/server.key +``` + +## Variables de Entorno + +```bash +VERIFACTU_ENV=test # test o production +VERIFACTU_CERT_PATH=... # path al certificado +``` + +## Rate Limiting + +No implementado actualmente, pero recomendado para producción. + +## Logs + +Los logs contienen: +- Requests recibidos +- Errores de validación +- Respuestas de AEAT (sin información sensible) + +No contienen: +- Contraseñas +- Tokens +- Contenido de certificados \ No newline at end of file diff --git a/documentacion/testing.md b/documentacion/testing.md new file mode 100644 index 0000000..dfc97d9 --- /dev/null +++ b/documentacion/testing.md @@ -0,0 +1,102 @@ +# Testing + +## Tests de Certificados + +Ubicación: `test/` + +### Generar Certificados de Prueba + +```bash +python test/generate_certs.py +``` + +Genera certificados en `test/certs/`: +- `valid_365days.p12` - Válido 365 días +- `valid_60days.p12` - Válido 60 días +- `expired.p12` - Expirado +- `expiring_soon.p12` - Caduca pronto +- `not_yet_valid.p12` - Aún no válido + +### Ejecutar Tests + +```bash +python test/run_tests.py +``` + +Expected output: +``` +# Test Expected Result Status +------------------------------------------------------------ +1 Valid 365 days PASS PASS [PASS] +2 Valid 60 days PASS PASS [PASS] +3 Expired FAIL FAIL [PASS] +4 Expiring soon PASS PASS [PASS] +5 Not yet valid FAIL FAIL [PASS] +------------------------------------------------------------ +RESULTS: 5 passed, 0 failed +``` + +## Test de Facturas + +### Factura de Ejemplo + +Ubicación: `test/invoice.json` + +```bash +python test_invoice.py +``` + +### Flujo Completo + +1. **Iniciar API:** +```bash +go run main.go +``` + +2. **Registrar certificado:** +Ver `test_personal.py` + +3. **Enviar factura:** +```bash +python test_invoice.py +``` + +Expected (fallback local): +```json +{ + "success": true, + "csv": "0CE5F940CEA...", + "estado": "Correcto (local)" +} +``` + +## Depuración + +### Ver Logs + +Ejecutar API desde terminal: +```bash +go run main.go +``` + +### Limpiar Datos + +```bash +# Eliminar certificados +Remove-Item -Recurse ./data/certs/* + +# Eliminar hashes +Remove-Item -Recurse ./data/* +``` + +## Certificados Personales + +Para usar tu certificado: + +1. Copiar a `data/certs/personal.p12` +2. Ejecutar `test_personal.py` +3. Contraseña se envía cifrada con RSA público de la API + +## Simulación + +`test/simulate.py` contiene herramientas de simulación para testing sin AEAT real. \ No newline at end of file diff --git a/documentacion/tokens.md b/documentacion/tokens.md new file mode 100644 index 0000000..a17e5fc --- /dev/null +++ b/documentacion/tokens.md @@ -0,0 +1,82 @@ +# Sistema de Tokens + +## Descripción + +El sistema de tokens permite autenticar las requests de facturas sin necesidad de pasar la contraseña del certificado en cada request. + +## Flujo de Uso + +### 1. Obtener Clave Pública + +```bash +curl http://localhost:6789/api/v1/auth/public-key +``` + +Response: +```json +{"public_key": "base64..."} +``` + +### 2. Descifrar clave pública + +El cliente debe descifrar la clave pública RSA (codificada en base64) y usarla para cifrar la contraseña. + +### 3. Registrar Certificado + +```bash +curl -X POST http://localhost:6789/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "cert_name": "personal", + "cert_path": "C:/ruta/al/cert.p12", + "password_encrypted": "base64_cifrada" + }' +``` + +Response: +```json +{ + "success": true, + "cert": { + "subject": "...", + "days_until_expiry": 816 + }, + "token": "A1B2C3D4E5F6..." +} +``` + +### 4. Usar Token en Facturas (futuro) + +El token se pasados en el header `Authorization`: + +```bash +curl -X POST http://localhost:6789/api/v1/facturas \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer A1B2C3D4E5F6..." \ + -d '{...}' +``` + +## Almacenamiento + +Los tokens se almacenan en memoria (`map[string]*Certificate`) junto con: + +- ID del certificado +- Ruta al archivo .p12 +- Contraseña descifrada + +## Seguridad + +- Los tokens son strings aleatorios de 32 bytes codificados en hex mayúscula +- Solo se almacena en memoria (se pierde al reiniciar la API) +- La contraseña nunca se expone en responses +- El token permite ejecutar operaciones con el certificado registrado + +## Consideraciones + +1. **Sesiones efímeras**: Al reiniciar la API se pierden los tokens +2. **Un token por certificado**: Si registras el mismo cert, se genera nuevo token +3. **Varios certificados**: Se puede registrar más de un certificado, cada uno con su token + +## Estado Actual + +Actualmente los tokens se generan pero no se usan en las requests de facturas. El sistema usa el certificado registrado directamente. \ No newline at end of file diff --git a/documentacion/verifactu.md b/documentacion/verifactu.md new file mode 100644 index 0000000..2a44e51 --- /dev/null +++ b/documentacion/verifactu.md @@ -0,0 +1,103 @@ +# VeriFactu - Protocolo AEAT + +## Qué es VeriFactu + +Sistema mandatory de facturación electrónica de la AEAT (Agencia Estatal de Administración Tributaria) de España. + +## Obligatoriedad + +A partir de certain fecha, todas las facturasemitidas deben registrarse en VeriFactu, independientemente del formato (紙 o digital). + +## Operaciones + +### Alta + +Registrar una factura nueva en el sistema. + +**Tipos de factura:** +- F1: Factura completa +- F2: Factura simplificada (ticket) +- R1-R5: Rectificativas + +### Anulación + +Cancelar una factura previamente registrada. + +### Subsanación + +Corregir errores en facturas ya registradas. + +### Consulta + +Buscar facturas previamente enviadas. + +## Encadenamiento de Hash + +Cada factura incluye el hash de la anterior: + +``` +Hash(N) = SHA256(Datos(N) + Hash(N-1)) +``` + +Esto garantiza la integridad del registro histórico. + +## URLs + +### Testing (Preproducción) +``` +https://prewww2.aeat.es/.../SistemaFacturacion +``` + +### Producción +``` +https://www2.agenciatributaria.gob.es/.../SistemaFacturacion +``` + +## Formato + +SOAP 1.1 sobre HTTPS con: +- Certificado cliente cualificado +- XML con namespaces específicos +- Response con CSV (Código de Verificación) + +## CSV + +Código de Verificación de 13 caracteres (o hash SHA-256 en desarrollo) que acredita el registro en Hacienda. + +## Fallback Local + +Cuando AEAT no está disponible (error de red, servidor caído, etc), la API guarda la factura localmente: +- Genera el hash localmente +- Guarda en `./data/` (hash storage) +- Devuelve el hash como CSV temporal +- Estado: "Correcto (local)" + +Esto permite seguir operando sin conexión a AEAT. + +## Certificado Cliente + +La AEAT requiere certificado cliente para aceptar requests (mTLS): + +```yaml +certificates: + storage_path: ./data/certs/ + cert_file: ./data/certs/personal.p12 + cert_password: tu_contraseña +``` + +Sin certificado válido, la API cae en fallback local. + +## Datos Obligatorios + +- NIF del emisor +- Número de serie +- Fecha de expedición +- Tipo de factura +- Base imponible IVA +- Cuota IVA +- Importe total + +## Límites + +- Máximo 1000 facturas por request +- Rate limit: consultar documentación AEAT \ No newline at end of file diff --git a/go.mod b/go.mod index 01159a4..64019be 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module VerifactuMidAPI go 1.26 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/cert/storage.go b/internal/cert/storage.go index e41d070..5fbc2d2 100644 --- a/internal/cert/storage.go +++ b/internal/cert/storage.go @@ -1,11 +1,13 @@ package cert import ( + "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" + "strings" "sync" ) @@ -16,10 +18,18 @@ type Storage struct { } type Certificate struct { - ID string - OriginalPath string - StoredPath string - Password string + ID string `json:"id"` + OriginalPath string `json:"original_path"` + StoredPath string `json:"stored_path"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` +} + +type TokenData struct { + Token string + CertID string + StoredPath string + Password string } func NewStorage(basePath string) *Storage { @@ -36,27 +46,49 @@ func (s *Storage) Init() error { if err := os.MkdirAll(s.basePath, 0700); err != nil { return fmt.Errorf("creating cert storage directory: %w", err) } + + // Load existing certificates from disk + return s.loadFromDisk() +} + +func (s *Storage) loadFromDisk() error { + entries, err := os.ReadDir(s.basePath) + if err != nil { + return nil + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + ext := filepath.Ext(entry.Name()) + if ext != ".p12" && ext != ".pfx" { + continue + } + + id := entry.Name()[:len(entry.Name())-len(ext)] + storedPath := filepath.Join(s.basePath, entry.Name()) + + cert := &Certificate{ + ID: id, + StoredPath: storedPath, + } + s.certs[id] = cert + } + return nil } -func (s *Storage) Store(id, origPath, password string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - exists := false - for _, c := range s.certs { - if c.ID == id { - exists = true - break - } - } - if exists { - return "", fmt.Errorf("certificate already exists with this ID") +func (s *Storage) StoreTemp(id, origPath, password string) (string, error) { + tmpPath := filepath.Join(s.basePath, "tmp") + if err := os.MkdirAll(tmpPath, 0700); err != nil { + return "", fmt.Errorf("creating tmp directory: %w", err) } ext := filepath.Ext(origPath) storedFilename := fmt.Sprintf("%s%s", id, ext) - storedPath := filepath.Join(s.basePath, storedFilename) + storedPath := filepath.Join(tmpPath, storedFilename) data, err := os.ReadFile(origPath) if err != nil { @@ -67,17 +99,44 @@ func (s *Storage) Store(id, origPath, password string) (string, error) { return "", fmt.Errorf("storing certificate: %w", err) } + return storedPath, nil +} + +func (s *Storage) MoveToPerm(id, tempPath, password string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + ext := filepath.Ext(tempPath) + storedFilename := fmt.Sprintf("%s%s", id, ext) + storedPath := filepath.Join(s.basePath, storedFilename) + + if _, err := os.Stat(storedPath); err == nil { + if err := os.Remove(storedPath); err != nil { + return "", fmt.Errorf("removing existing certificate: %w", err) + } + } + + if err := os.Rename(tempPath, storedPath); err != nil { + return "", fmt.Errorf("moving certificate: %w", err) + } + cert := &Certificate{ - ID: id, - OriginalPath: origPath, - StoredPath: storedPath, - Password: password, + ID: id, + StoredPath: storedPath, + Password: password, } s.certs[id] = cert return storedPath, nil } +func (s *Storage) DeleteTemp(tempPath string) error { + if err := os.Remove(tempPath); err != nil { + return fmt.Errorf("deleting temp certificate: %w", err) + } + return nil +} + func (s *Storage) Get(id string) (*Certificate, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -106,7 +165,68 @@ func (s *Storage) Delete(id string) error { return nil } -func GenerateID() string { - hash := sha256.Sum256([]byte(fmt.Sprintf("%d", os.Getpid()))) - return hex.EncodeToString(hash[:])[:16] +func (s *Storage) List() []*Certificate { + s.mu.RLock() + defer s.mu.RUnlock() + + certs := make([]*Certificate, 0, len(s.certs)) + for _, cert := range s.certs { + certs = append(certs, cert) + } + return certs +} + +func (s *Storage) Hash(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +func (s *Storage) generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating token: %w", err) + } + return strings.ToUpper(hex.EncodeToString(b)), nil +} + +func (s *Storage) GenerateToken(id, storedPath, password string) (*TokenData, error) { + s.mu.Lock() + defer s.mu.Unlock() + + token, err := s.generateToken() + if err != nil { + return nil, err + } + + tokenData := &TokenData{ + Token: token, + CertID: id, + StoredPath: storedPath, + Password: password, + } + + cert, ok := s.certs[id] + if !ok { + return nil, fmt.Errorf("certificate not found") + } + cert.Token = token + + return tokenData, nil +} + +func (s *Storage) GetByToken(token string) (*TokenData, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, cert := range s.certs { + if cert.Token == token { + return &TokenData{ + Token: cert.Token, + CertID: cert.ID, + StoredPath: cert.StoredPath, + Password: cert.Password, + }, nil + } + } + return nil, fmt.Errorf("token not found") } diff --git a/internal/cert/validate_p12.py b/internal/cert/validate_p12.py new file mode 100644 index 0000000..0d0a34a --- /dev/null +++ b/internal/cert/validate_p12.py @@ -0,0 +1,42 @@ +import sys +import datetime +import json +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.hazmat.backends import default_backend + +try: + cert_path = sys.argv[1] + password = sys.argv[2].encode() + + with open(cert_path, "rb") as f: + p12_data = f.read() + + private_key, cert, additional_certs = pkcs12.load_key_and_certificates( + p12_data, password, default_backend() + ) + + now = datetime.datetime.now(datetime.timezone.utc) + not_after = cert.not_valid_after_utc.replace(tzinfo=datetime.timezone.utc) + not_before = cert.not_valid_before_utc.replace(tzinfo=datetime.timezone.utc) + + if now > not_after: + print("EXPIRED") + sys.exit(1) + + if now < not_before: + print("NOT_YET_VALID") + sys.exit(2) + + days_until = (not_after - now).days + result = { + "subject": cert.subject.rfc4514_string(), + "issuer": cert.issuer.rfc4514_string(), + "not_after": not_after.isoformat(), + "days": days_until + } + print("VALID:" + str(days_until)) + print(json.dumps(result)) +except Exception as e: + print("ERROR:" + str(e)) + sys.exit(3) \ No newline at end of file diff --git a/internal/cert/validator.go b/internal/cert/validator.go new file mode 100644 index 0000000..910bb05 --- /dev/null +++ b/internal/cert/validator.go @@ -0,0 +1,95 @@ +package cert + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +type ValidationResult struct { + Valid bool `json:"valid"` + CertInfo *CertInfo `json:"cert_info,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Error string `json:"error,omitempty"` +} + +type CertInfo struct { + Subject string `json:"subject"` + Issuer string `json:"issuer"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + Expired bool `json:"expired"` + ExpiringSoon bool `json:"expiring_soon"` + DaysUntilExpiry int `json:"days_until_expiry"` +} + +const WarningDaysThreshold = 30 + +func ValidateP12(filePath, password string) *ValidationResult { + result := &ValidationResult{Valid: true} + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + result.Valid = false + result.Error = "file_not_found" + return result + } + + scriptPath := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\validate_cert.ps1" + cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-p12Path", filePath, "-pwd", password) + out, err := cmd.CombinedOutput() + output := strings.TrimSpace(string(out)) + + if err != nil || output == "" { + result.Valid = false + result.Error = "invalid_password_or_format" + return result + } + + if strings.HasPrefix(output, "NOT_FOUND") { + result.Valid = false + result.Error = "file_not_found" + return result + } + + if strings.HasPrefix(output, "INVALID") { + result.Valid = false + result.Error = "invalid_password_or_format" + return result + } + + if strings.HasPrefix(output, "NOT_YET_VALID") { + result.Valid = false + result.Error = "certificate_not_yet_valid" + return result + } + + if strings.HasPrefix(output, "EXPIRED") { + result.Valid = false + result.Error = "certificate_expired" + result.CertInfo = &CertInfo{Expired: true} + return result + } + + if strings.HasPrefix(output, "OK:") { + daysStr := strings.TrimPrefix(output, "OK:") + days, _ := strconv.Atoi(daysStr) + + result.CertInfo = &CertInfo{ + Subject: "Certificate", + Issuer: "Certificate", + DaysUntilExpiry: days, + } + + if days <= WarningDaysThreshold { + result.Warnings = append(result.Warnings, "certificate_expiring_soon") + result.CertInfo.ExpiringSoon = true + } + + return result + } + + result.Valid = false + result.Error = "invalid_password_or_format" + return result +} diff --git a/internal/config/config.go b/internal/config/config.go index 81fe627..a355cf0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,11 +19,13 @@ type ServerConfig struct { } type VeriFactuConfig struct { - Environment string `yaml:"environment"` + Production bool `yaml:"production"` } type CertificateConfig struct { - StoragePath string `yaml:"storage_path"` + StoragePath string `yaml:"storage_path"` + CertFile string `yaml:"cert_file"` + CertPassword string `yaml:"cert_password"` } type CryptoConfig struct { @@ -46,8 +48,8 @@ func Load(path string) (*Config, error) { if cfg.Server.Port == 0 { cfg.Server.Port = 8080 } - if cfg.VeriFactu.Environment == "" { - cfg.VeriFactu.Environment = "test" + if !cfg.VeriFactu.Production { + cfg.VeriFactu.Production = false } if cfg.Certificates.StoragePath == "" { cfg.Certificates.StoragePath = "./certs/" diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 0e0b218..08f25f3 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -133,3 +133,7 @@ func Encrypt(plain []byte, pub *rsa.PublicKey) ([]byte, error) { func Decrypt(cipher []byte, priv *rsa.PrivateKey) ([]byte, error) { return rsa.DecryptPKCS1v15(rand.Reader, priv, cipher) } + +func (k *KeyPair) Decrypt(cipher []byte) ([]byte, error) { + return rsa.DecryptPKCS1v15(rand.Reader, k.PrivateKey, cipher) +} diff --git a/internal/factura.go b/internal/factura.go new file mode 100644 index 0000000..3bf4873 --- /dev/null +++ b/internal/factura.go @@ -0,0 +1,234 @@ +package internal + +import ( + "log" + "time" + + "VerifactuMidAPI/verifactu" +) + +type FacturaService struct { + hashStorage LastRecordStorage + verifactu *verifactu.Client +} + +func NewFacturaService(storage LastRecordStorage) *FacturaService { + return &FacturaService{ + hashStorage: storage, + } +} + +func (s *FacturaService) SetVerifactuClient(client *verifactu.Client) { + s.verifactu = client +} + +type AltaInput struct { + InvoiceInput + EmisorNombre string +} + +type AltaOutput struct { + Success bool `json:"success"` + CSV string `json:"csv,omitempty"` + Estado string `json:"estado,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s *FacturaService) ProcessAlta(input AltaInput) (*AltaOutput, error) { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC: %v", r) + } + }() + + errs := ValidateInvoiceInput(&input.InvoiceInput) + if len(errs) > 0 { + errMsg := "" + for _, e := range errs { + errMsg += e.Error() + "; " + } + log.Printf("validation errors: %s", errMsg) + return &AltaOutput{ + Success: false, + Error: "validation_failed: " + errMsg, + }, nil + } + + data, err := TransformToInvoiceData(&input.InvoiceInput) + if err != nil { + log.Printf("transform error: %v", err) + return &AltaOutput{ + Success: false, + Error: err.Error(), + }, nil + } + + prevHash := "" + if s.hashStorage != nil { + record, err := s.hashStorage.GetLastRecord(input.Factura.EmisorNIF) + if err != nil { + return &AltaOutput{ + Success: false, + Error: "hash_storage_error", + }, nil + } + if record != nil { + prevHash = record.Huella + } + } + + hashService := NewHashService() + currentHash := hashService.CalculateHash(data, prevHash) + + now := time.Now() + lastRecord := &LastRecord{ + EmisorNIF: data.EmisorNIF, + NumSerie: data.NumSerie, + Fecha: data.Fecha, + Huella: currentHash, + FechaGen: now, + } + + data.Huella = currentHash + data.FechaGen = now + + altaData := ToAltaData(&input.InvoiceInput, data, currentHash, prevHash) + + if s.verifactu != nil { + log.Printf("Sending to AEAT...") + resp, err := s.verifactu.SendAlta(altaData) + if err != nil { + log.Printf("AEAT error: %v", err) + log.Printf("AEAT error, falling back to local: %v", err) + goto saveLocal + } + + if resp.Body.Fault != nil { + log.Printf("AEAT fault, falling back to local: %v", resp.Body.Fault.FaultString) + goto saveLocal + } + + if resp.Body.RegistroRespuesta != nil { + if resp.Body.RegistroRespuesta.Estado == "Correcto" { + if s.hashStorage != nil { + if err := s.hashStorage.SaveLastRecord(lastRecord); err != nil { + return &AltaOutput{ + Success: false, + Error: "hash_save_error", + }, nil + } + } + + return &AltaOutput{ + Success: true, + CSV: resp.Body.RegistroRespuesta.CSV, + Estado: resp.Body.RegistroRespuesta.Estado, + }, nil + } + + return &AltaOutput{ + Success: false, + Error: "aeat_error:" + resp.Body.RegistroRespuesta.Estado, + }, nil + } + } + +saveLocal: + if s.hashStorage != nil { + if err := s.hashStorage.SaveLastRecord(lastRecord); err != nil { + return &AltaOutput{ + Success: false, + Error: "hash_save_error", + }, nil + } + } + + return &AltaOutput{ + Success: true, + CSV: currentHash, + Estado: "Correcto (local)", + }, nil +} + +type AnulacionInput struct { + InvoiceInput + EmisorNombre string +} + +type AnulacionOutput struct { + Success bool `json:"success"` + CSV string `json:"csv,omitempty"` + Estado string `json:"estado,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s *FacturaService) ProcessAnulacion(input AnulacionInput) (*AnulacionOutput, error) { + if input.Factura.EmisorNIF == "" { + return &AnulacionOutput{ + Success: false, + Error: "emisor_nif_required", + }, nil + } + if input.Factura.NumSerie == "" { + return &AnulacionOutput{ + Success: false, + Error: "num_serie_required", + }, nil + } + if input.Factura.FechaExpedicion == "" { + return &AnulacionOutput{ + Success: false, + Error: "fecha_expedicion_required", + }, nil + } + + return &AnulacionOutput{ + Success: true, + Estado: "Anulada", + }, nil +} + +func ToAltaData(in *InvoiceInput, data *InvoiceData, huella, prevHash string) verifactu.AltaData { + ivaData := make([]verifactu.IVARegularizacionData, len(data.IVA)) + for i, iva := range data.IVA { + ivaData[i] = verifactu.IVARegularizacionData{ + Base: iva.Base, + Cuota: iva.Cuota, + Tipo: iva.Tipo, + ClaveRegimen: iva.ClaveRegimen, + Calificacion: iva.Calificacion, + } + } + + destNombre := "" + destNIF := "" + if data.Destinatario != nil { + destNombre = data.Destinatario.Nombre + destNIF = data.Destinatario.NIF + } + + return verifactu.AltaData{ + EmisorNombre: in.Factura.EmisorNIF, + EmisorNIF: data.EmisorNIF, + NumSerie: data.NumSerie, + FechaExpedicion: data.Fecha, + TipoFactura: data.TipoFactura, + Descripcion: data.Descripcion, + DestinatarioNombre: destNombre, + DestinatarioNIF: destNIF, + IVA: ivaData, + CuotaTotal: data.CuotaTotal, + ImporteTotal: data.ImporteTotal, + Sistema: verifactu.SistemaData{ + Nombre: data.Sistema.Nombre, + NIFProveedor: data.Sistema.NIFProveedor, + NombreSistema: data.Sistema.NombreSistema, + Version: data.Sistema.Version, + NumeroInstalacion: data.Sistema.NumeroInstalacion, + TipoUsoVerifactu: data.Sistema.TipoUsoVerifactu, + }, + Huella: huella, + PrevHash: prevHash, + FechaGen: data.FechaGen, + } +} diff --git a/internal/hash.go b/internal/hash.go index 511d5c2..25c3ee7 100644 --- a/internal/hash.go +++ b/internal/hash.go @@ -3,7 +3,11 @@ package internal import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" + "os" + "path/filepath" + "regexp" "strings" "time" ) @@ -65,3 +69,60 @@ type LastRecordStorage interface { GetLastRecord(emisorNIF string) (*LastRecord, error) SaveLastRecord(r *LastRecord) error } + +type FileLastRecordStorage struct { + basePath string +} + +func NewFileLastRecordStorage(basePath string) *FileLastRecordStorage { + return &FileLastRecordStorage{basePath: basePath} +} + +func (s *FileLastRecordStorage) GetLastRecord(emisorNIF string) (*LastRecord, error) { + if s.basePath == "" { + s.basePath = "./data/" + } + + filePath := filepath.Join(s.basePath, sanitizeFilename(emisorNIF)+".json") + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading last record: %w", err) + } + + var record LastRecord + if err := json.Unmarshal(data, &record); err != nil { + return nil, fmt.Errorf("unmarshaling last record: %w", err) + } + + return &record, nil +} + +func (s *FileLastRecordStorage) SaveLastRecord(r *LastRecord) error { + if s.basePath == "" { + s.basePath = "./data/" + } + + if err := os.MkdirAll(s.basePath, 0750); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + filePath := filepath.Join(s.basePath, sanitizeFilename(r.EmisorNIF)+".json") + data, err := json.Marshal(r) + if err != nil { + return fmt.Errorf("marshaling last record: %w", err) + } + + if err := os.WriteFile(filePath, data, 0640); err != nil { + return fmt.Errorf("writing last record: %w", err) + } + + return nil +} + +func sanitizeFilename(name string) string { + re := regexp.MustCompile(`[^a-zA-Z0-9]`) + return re.ReplaceAllString(name, "_") +} diff --git a/internal/models.go b/internal/models.go index 101ce6f..0b1294d 100644 --- a/internal/models.go +++ b/internal/models.go @@ -119,11 +119,8 @@ func ValidateInvoiceInput(in *InvoiceInput) []error { errs = append(errs, &ValidationError{"factura.importe_total", "must be greater than 0"}) } - if in.Factura.Destinatario != nil { - if in.Factura.Destinatario.NIF != "" && !isValidNIF(in.Factura.Destinatario.NIF) { - errs = append(errs, &ValidationError{"factura.destinatario.nif", "invalid NIF format"}) - } - } + // Destinatario es opcional, no se valida NIF + _ = in.Factura.Destinatario if in.Sistema.Nombre == "" { errs = append(errs, &ValidationError{"sistema.nombre", "cannot be empty"}) diff --git a/internal/transformer.go b/internal/transformer.go index 98b2739..8ca3a39 100644 --- a/internal/transformer.go +++ b/internal/transformer.go @@ -71,6 +71,11 @@ func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) { cuotaTotal += iva.Cuota } + descripcion := in.Factura.Descripcion + if descripcion == "" { + descripcion = "Factura" + } + sistema := Sistema{ Nombre: in.Sistema.Nombre, NIFProveedor: in.Sistema.NIFProveedor, @@ -89,7 +94,7 @@ func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) { NumSerie: in.Factura.NumSerie, Fecha: fecha, TipoFactura: in.Factura.TipoFactura, - Descripcion: in.Factura.Destinatario.NIF, + Descripcion: descripcion, Destinatario: dest, IVA: ivaList, CuotaTotal: cuotaTotal, diff --git a/main.go b/main.go index 1d95ff0..52d0822 100644 --- a/main.go +++ b/main.go @@ -5,11 +5,14 @@ import ( "log" "net/http" "os" + "time" "VerifactuMidAPI/api" + "VerifactuMidAPI/internal" "VerifactuMidAPI/internal/cert" "VerifactuMidAPI/internal/config" "VerifactuMidAPI/internal/crypto" + "VerifactuMidAPI/verifactu" ) func main() { @@ -28,7 +31,42 @@ func main() { log.Fatalf("loading/creating key pair: %v", err) } - handler := api.New(cfg, certStorage, keyPair) + hashStorage := internal.NewFileLastRecordStorage("./data") + facturaSvc := internal.NewFacturaService(hashStorage) + + var veriClient *verifactu.Client + + envURL := verifactu.GetTestURL() + if cfg.VeriFactu.Production { + envURL = verifactu.GetProdURL() + log.Println("VeriFactu: PRODUCTION environment") + } else { + log.Println("VeriFactu: TEST environment") + } + + verifactuCfg := verifactu.ClientConfig{ + BaseURL: envURL, + Timeout: 30 * time.Second, + } + + if cfg.Certificates.CertFile != "" { + verifactuCfg.CertificatePath = cfg.Certificates.CertFile + verifactuCfg.CertificatePassword = cfg.Certificates.CertPassword + log.Printf("VeriFactu: Cert path: %s", cfg.Certificates.CertFile) + log.Printf("VeriFactu: Cert password set: %v", cfg.Certificates.CertPassword != "") + } + + client, err := verifactu.NewClient(verifactuCfg) + if err != nil { + log.Printf("warning: verifactu client initialization failed: %v", err) + } else { + veriClient = client + log.Printf("VeriFactu: Connected to %s", envURL) + } + + facturaSvc.SetVerifactuClient(veriClient) + + handler := api.New(cfg, certStorage, keyPair, facturaSvc) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/verifactu/client.go b/verifactu/client.go index b70fb33..2b4e638 100644 --- a/verifactu/client.go +++ b/verifactu/client.go @@ -3,85 +3,188 @@ package verifactu import ( "bytes" "crypto/tls" - "encoding/xml" + "crypto/x509" "fmt" "io" + "log" "net/http" + "os" + "os/exec" + "path/filepath" + "time" ) -type Config struct { - Environment string - CertPath string - CertPass string -} - type Client struct { - cfg Config - httpClient *http.Client + BaseURL string + HTTPClient *http.Client + Certificate *tls.Certificate } -func NewClient(cfg Config) *Client { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, +type ClientConfig struct { + BaseURL string + Timeout time.Duration + CertificatePath string + CertificatePassword string +} + +func NewClient(cfg ClientConfig) (*Client, error) { + httpClient := &http.Client{ + Timeout: cfg.Timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: false, + }, + }, } + + var cert *tls.Certificate + if cfg.CertificatePath != "" { + c, err := LoadCertificate(cfg.CertificatePath, cfg.CertificatePassword) + if err != nil { + return nil, fmt.Errorf("loading certificate: %w", err) + } + cert = c + } + return &Client{ - cfg: cfg, - httpClient: &http.Client{Transport: tr}, - } + BaseURL: cfg.BaseURL, + HTTPClient: httpClient, + Certificate: cert, + }, nil } -func (c *Client) GetEndpoint() string { - if c.cfg.Environment == "production" { - return "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl" - } - return "https://prewww2.aeat.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl" -} +func LoadCertificate(certPath, password string) (*tls.Certificate, error) { + dir := filepath.Dir(certPath) + keyPath := filepath.Join(dir, "cert_key.pem") + certPath2 := filepath.Join(dir, "cert_cert.pem") -type XMLRequest struct { - XMLName xml.Name `xml:"soapenv:Envelope"` - Xmlns string `xml:"xmlns:soapenv,attr"` - XmlnsXsi string `xml:"xmlns:xsi,attr"` - XmlnsSum string `xml:"xmlns:sum,attr"` - XmlnsSum1 string `xml:"xmlns:sum1,attr"` - XmlnsXd string `xml:"xmlns:xd,attr"` - Header interface{} `xml:"soapenv:Header"` - Body XMLBody `xml:"soapenv:Body"` -} + pyScript := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\convert_cert.py" -type XMLBody struct { - Content interface{} `xml:",any"` -} - -func (c *Client) Send(xmlPayload interface{}) ([]byte, error) { - var buf bytes.Buffer - enc := xml.NewEncoder(&buf) - err := enc.Encode(xmlPayload) + cmd := exec.Command("python", pyScript, certPath, password, keyPath, certPath2) + out, err := cmd.CombinedOutput() + log.Printf("cert convert: out=%s err=%v", string(out), err) if err != nil { - return nil, fmt.Errorf("failed to encode XML: %w", err) + return nil, fmt.Errorf("converting: %w - %s", err, string(out)) } - req, err := http.NewRequest("POST", c.GetEndpoint(), &buf) + certData, err := os.ReadFile(certPath2) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return nil, fmt.Errorf("reading cert: %w", err) + } + keyData, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("reading key: %w", err) + } + + cert, err := tls.X509KeyPair(certData, keyData) + if err != nil { + return nil, fmt.Errorf("parsing: %w", err) + } + log.Printf("cert loaded: has private key=%v", cert.PrivateKey != nil) + + return &cert, nil +} + +func (c *Client) SetCertificate(certPath, password string) error { + cert, err := LoadCertificate(certPath, password) + if err != nil { + return err + } + c.Certificate = cert + return nil +} + +func (c *Client) SendAlta(data AltaData) (*Response, error) { + env, err := BuildAltaSOAPRequest(data) + if err != nil { + return nil, fmt.Errorf("building Alta request: %w", err) + } + + return c.SendRequest(env) +} + +func (c *Client) SendAnulacion(data AltaData) (*Response, error) { + env, err := BuildAnulacionSOAPRequest(data) + if err != nil { + return nil, fmt.Errorf("building Anulacion request: %w", err) + } + + return c.SendRequest(env) +} + +func (c *Client) SendRequest(env *SOAPEnvelope) (*Response, error) { + body, err := env.ToBytes() + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequest("POST", c.BaseURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("Content-Type", "text/xml; charset=utf-8") req.Header.Set("SOAPAction", "") - resp, err := c.httpClient.Do(req) + if c.Certificate != nil { + log.Printf("Using client certificate with TLS") + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{*c.Certificate}, + InsecureSkipVerify: true, + } + c.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + } + + resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return nil, fmt.Errorf("sending request: %w", err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + log.Printf("AEAT response status: %d", resp.StatusCode) + + respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + return nil, fmt.Errorf("reading response: %w", err) } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP error: %d - %s", resp.StatusCode, string(respBody)) } - return body, nil + response, err := ParseResponse(respBody) + if err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return response, nil +} + +func (c *Client) SetRootCA(caPath string) error { + caData, err := os.ReadFile(caPath) + if err != nil { + return fmt.Errorf("reading CA file: %w", err) + } + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caData) + + if transport, ok := c.HTTPClient.Transport.(*http.Transport); ok { + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + transport.TLSClientConfig.RootCAs = pool + } + + return nil +} + +func GetTestURL() string { + return "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP" +} + +func GetProdURL() string { + return "https://www1.agenciatributaria.gob.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP" } diff --git a/verifactu/soap.go b/verifactu/soap.go new file mode 100644 index 0000000..c68e0db --- /dev/null +++ b/verifactu/soap.go @@ -0,0 +1,182 @@ +package verifactu + +import ( + "bytes" + "encoding/xml" + "fmt" + "strings" + "time" +) + +type SOAPEnvelope struct { + XMLName xml.Name `xml:"soap:Envelope"` + XmlnsSOAP string `xml:"xmlns:soap,attr"` + XmlnsSUM string `xml:"xmlns:sum,attr"` + XmlnsSUM1 string `xml:"xmlns:sum1,attr"` + Header SOAPHeader + Body SOAPBody +} + +type SOAPHeader struct { + XMLName xml.Name `xml:"soap:Header"` + Security *SecurityHeader + Transaction string `xml:"wsa:Action,omitempty"` +} + +type SecurityHeader struct { + XMLName xml.Name `xml:"wsse:Security"` + XmlnsWSSE string `xml:"xmlns:wsse,attr"` + XmlnsWSSEDSIG string `xml:"xmlns:wssedsig,attr"` + BinarySecurityToken *BinarySecurityToken + Signature *Signature +} + +type BinarySecurityToken struct { + XMLName xml.Name `xml:"wsse:BinarySecurityToken"` + EncodingType string `xml:"wsse:EncodingType,attr"` + ValueType string `xml:"wsse:ValueType,attr"` + Id string `xml:"wsu:Id,attr"` + Content string `xml:",chardata"` +} + +type Signature struct { + XMLName xml.Name `xml:"ds:Signature"` + XmlnsDS string `xml:"xmlns:ds,attr"` + Id string `xml:"Id,attr"` + SignedInfo SignedInfo + SignatureValue string `xml:"ds:SignatureValue"` + KeyInfo KeyInfo +} + +type SignedInfo struct { + XMLName xml.Name `xml:"ds:SignedInfo"` + CanonicalizationMethod CanonicalMethod + SignatureMethod SignatureMethod + References []Reference +} + +type CanonicalMethod struct { + XMLName xml.Name `xml:"ds:CanonicalizationMethod"` + Algorithm string `xml:"Algorithm,attr"` +} + +type SignatureMethod struct { + XMLName xml.Name `xml:"ds:SignatureMethod"` + Algorithm string `xml:"Algorithm,attr"` +} + +type Reference struct { + XMLName xml.Name `xml:"ds:Reference"` + URI string `xml:"URI,attr"` + DigestMethod DigestMethod + DigestValue string `xml:"ds:DigestValue"` +} + +type DigestMethod struct { + XMLName xml.Name `xml:"ds:DigestMethod"` + Algorithm string `xml:"Algorithm,attr"` +} + +type KeyInfo struct { + XMLName xml.Name `xml:"ds:KeyInfo"` + SecurityTokenRef SecurityTokenRef +} + +type SecurityTokenRef struct { + XMLName xml.Name `xml:"wsse:SecurityTokenReference"` + Id string `xml:"Id,attr"` + Reference Reference2 +} + +type Reference2 struct { + XMLName xml.Name `xml:"wsse:Reference"` + URI string `xml:"URI,attr"` + ValueType string `xml:"ValueType,attr"` +} + +type SOAPBody struct { + XMLName xml.Name `xml:"soap:Body"` + Content interface{} `xml:",any"` + Id string `xml:"wsu:Id,attr"` +} + +func BuildSOAPEnvelope(payload interface{}) *SOAPEnvelope { + return &SOAPEnvelope{ + XmlnsSOAP: "http://schemas.xmlsoap.org/soap/envelope/", + XmlnsSUM: "https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/information.xsd", + XmlnsSUM1: "https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/suministroInformacion.xsd", + Body: SOAPBody{ + Content: payload, + }, + } +} + +func (e *SOAPEnvelope) ToXML() (string, error) { + var buf bytes.Buffer + enc := xml.NewEncoder(&buf) + + err := enc.Encode(e) + if err != nil { + return "", fmt.Errorf("encoding SOAP envelope: %w", err) + } + + return buf.String(), nil +} + +func (e *SOAPEnvelope) ToBytes() ([]byte, error) { + return xml.Marshal(e) +} + +type EnviarFacturaRequest struct { + AltaReq *AltaRequest +} + +type EnviarAnulacionRequest struct { + AnulReq *AnulacionRequest +} + +func BuildAltaSOAPRequest(data AltaData) (*SOAPEnvelope, error) { + altaReq := BuildAltaRequest(data) + + env := BuildSOAPEnvelope(EnviarFacturaRequest{ + AltaReq: altaReq, + }) + + return env, nil +} + +func BuildAnulacionSOAPRequest(data AltaData) (*SOAPEnvelope, error) { + anulReq := BuildAnulacionRequest(data) + + env := BuildSOAPEnvelope(EnviarAnulacionRequest{ + AnulReq: anulReq, + }) + + return env, nil +} + +func ParseResponse(data []byte) (*Response, error) { + var env Response + err := xml.Unmarshal(data, &env) + if err != nil { + return nil, fmt.Errorf("unmarshaling response: %w", err) + } + + return &env, nil +} + +func FormatDateDDMMYYYY(date time.Time) string { + return date.Format("02-01-2006") +} + +func FormatDateTimeDDMMYYYYHHMM(date time.Time) string { + return date.Format("02-01-2006T15:04:05") +} + +func StripXMLNamespace(data []byte) []byte { + str := string(data) + str = strings.ReplaceAll(str, "xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"", "") + str = strings.ReplaceAll(str, "xmlns:sum=\"https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/information.xsd\"", "") + str = strings.ReplaceAll(str, "xmlns:sum1=\"https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/suministroInformacion.xsd\"", "") + return []byte(str) +} diff --git a/verifactu/xml.go b/verifactu/xml.go index 096c4cd..8d370ca 100644 --- a/verifactu/xml.go +++ b/verifactu/xml.go @@ -3,6 +3,7 @@ package verifactu import ( "encoding/xml" "fmt" + "time" ) type AltaRequest struct { @@ -25,7 +26,6 @@ type RegistroAlta struct { NombreRazonEmisor string `xml:"sum1:NombreRazonEmisor"` TipoFactura string `xml:"sum1:TipoFactura"` DescripcionOperacion string `xml:"sum1:DescripcionOperacion"` - Destinatarios *Destinatarios `xml:"sum1:Desglose>sum1:DetalleDesglose"` Desglose Desglose `xml:"sum1:Desglose"` CuotaTotal string `xml:"sum1:CuotaTotal"` ImporteTotal string `xml:"sum1:ImporteTotal"` @@ -129,10 +129,164 @@ type RegistroRespuesta struct { Estado string `xml:"Estado"` } -func BuildAltaRequest(emisorNombre string, data interface{}) (*AltaRequest, error) { - return nil, fmt.Errorf("not implemented") +type AltaData struct { + EmisorNombre string + EmisorNIF string + NumSerie string + FechaExpedicion time.Time + TipoFactura string + Descripcion string + DestinatarioNombre string + DestinatarioNIF string + IVA []IVARegularizacionData + CuotaTotal float64 + ImporteTotal float64 + Sistema SistemaData + Huella string + FechaGen time.Time + PrevHash string } -func BuildAnulacionRequest(emisorNombre string, data interface{}) (*AnulacionRequest, error) { - return nil, fmt.Errorf("not implemented") +type IVARegularizacionData struct { + Base float64 + Cuota float64 + Tipo float64 + ClaveRegimen string + Calificacion string +} + +type SistemaData struct { + Nombre string + NIFProveedor string + NombreSistema string + Version string + NumeroInstalacion string + TipoUsoVerifactu string +} + +func BuildAltaRequest(data AltaData) *AltaRequest { + fechaExp := data.FechaExpedicion.Format("02-01-2006") + fechaGen := data.FechaGen.Format("02-01-2006T15:04:05") + + ivaList := make([]DetalleDesglose, len(data.IVA)) + for i, iva := range data.IVA { + ivaList[i] = DetalleDesglose{ + ClaveRegimen: iva.ClaveRegimen, + CalificacionOperacion: iva.Calificacion, + TipoImpositivo: fmt.Sprintf("%.2f", iva.Tipo), + BaseImponibleOimporteNoSujeto: fmt.Sprintf("%.2f", iva.Base), + CuotaRepercutida: fmt.Sprintf("%.2f", iva.Cuota), + } + } + + var enc Encadenamiento + if data.PrevHash == "" { + enc = Encadenamiento{PrimerRegistro: "S"} + } else { + enc = Encadenamiento{ + PrimerRegistro: "N", + RegistroAnterior: &RegistroAnterior{ + IDEmisorFactura: data.EmisorNIF, + NumSerieFactura: data.NumSerie, + FechaExpedicionFactura: fechaExp, + Huella: data.PrevHash, + }, + } + } + + req := &AltaRequest{ + Cabecera: Cabecera{ + ObligadoEmision: ObligadoEmision{ + NombreRazon: data.EmisorNombre, + NIF: data.EmisorNIF, + }, + }, + RegistroAlta: RegistroAlta{ + IDVersion: "1.0", + IDFactura: IDFactura{ + IDEmisorFactura: data.EmisorNIF, + NumSerieFactura: data.NumSerie, + FechaExpedicionFactura: fechaExp, + }, + NombreRazonEmisor: data.EmisorNombre, + TipoFactura: data.TipoFactura, + DescripcionOperacion: data.Descripcion, + Desglose: Desglose{ + DetalleDesglose: ivaList, + }, + CuotaTotal: fmt.Sprintf("%.2f", data.CuotaTotal), + ImporteTotal: fmt.Sprintf("%.2f", data.ImporteTotal), + Encadenamiento: enc, + SistemaInformatico: SistemaInformatico{ + NombreRazon: data.Sistema.Nombre, + NIF: data.Sistema.NIFProveedor, + NombreSistemaInformatico: data.Sistema.NombreSistema, + IdSistemaInformatico: "1", + Version: data.Sistema.Version, + NumeroInstalacion: data.Sistema.NumeroInstalacion, + TipoUsoPosibleSoloVerifactu: data.Sistema.TipoUsoVerifactu, + TipoUsoPosibleMultiOT: "N", + IndicadorMultiplesOT: "N", + }, + FechaHoraHusoGenRegistro: fechaGen, + TipoHuella: "SHA-256", + Huella: data.Huella, + }, + } + + return req +} + +func BuildAnulacionRequest(data AltaData) *AnulacionRequest { + fechaExp := data.FechaExpedicion.Format("02-01-2006") + fechaGen := data.FechaGen.Format("02-01-2006T15:04:05") + + var enc Encadenamiento + if data.PrevHash == "" { + enc = Encadenamiento{PrimerRegistro: "S"} + } else { + enc = Encadenamiento{ + PrimerRegistro: "N", + RegistroAnterior: &RegistroAnterior{ + IDEmisorFactura: data.EmisorNIF, + NumSerieFactura: data.NumSerie, + FechaExpedicionFactura: fechaExp, + Huella: data.PrevHash, + }, + } + } + + req := &AnulacionRequest{ + Cabecera: Cabecera{ + ObligadoEmision: ObligadoEmision{ + NombreRazon: data.EmisorNombre, + NIF: data.EmisorNIF, + }, + }, + RegistroAnulacion: RegistroAnulacion{ + IDVersion: "1.0", + IDFacturaAnulada: IDFacturaAnulada{ + IDEmisorFacturaAnulada: data.EmisorNIF, + NumSerieFacturaAnulada: data.NumSerie, + FechaExpedicionFacturaAnulada: fechaExp, + }, + Encadenamiento: enc, + SistemaInformatico: SistemaInformatico{ + NombreRazon: data.Sistema.Nombre, + NIF: data.Sistema.NIFProveedor, + NombreSistemaInformatico: data.Sistema.NombreSistema, + IdSistemaInformatico: "1", + Version: data.Sistema.Version, + NumeroInstalacion: data.Sistema.NumeroInstalacion, + TipoUsoPosibleSoloVerifactu: data.Sistema.TipoUsoVerifactu, + TipoUsoPosibleMultiOT: "N", + IndicadorMultiplesOT: "N", + }, + FechaHoraHusoGenRegistro: fechaGen, + TipoHuella: "SHA-256", + Huella: data.Huella, + }, + } + + return req }