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
This commit is contained in:
admin 2026-04-17 13:03:06 +02:00
parent cd91c9921e
commit 4ab7670232
27 changed files with 2334 additions and 112 deletions

View File

@ -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)
}

View File

@ -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"}`))
}

View File

@ -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/

92
documentacion/README.md Normal file
View File

@ -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

152
documentacion/api.md Normal file
View File

@ -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 |

81
documentacion/arqui.md Normal file
View File

@ -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.

View File

@ -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

90
documentacion/config.md Normal file
View File

@ -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)

View File

@ -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.

View File

@ -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

102
documentacion/testing.md Normal file
View File

@ -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.

82
documentacion/tokens.md Normal file
View File

@ -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.

103
documentacion/verifactu.md Normal file
View File

@ -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

2
go.mod
View File

@ -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

View File

@ -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")
}

View File

@ -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)

View File

@ -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
}

View File

@ -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/"

View File

@ -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)
}

234
internal/factura.go Normal file
View File

@ -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,
}
}

View File

@ -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, "_")
}

View File

@ -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"})

View File

@ -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,

40
main.go
View File

@ -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)

View File

@ -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"
}

182
verifactu/soap.go Normal file
View File

@ -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)
}

View File

@ -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
}