diff --git a/README.md b/README.md index f12122d..4f4a842 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Recibe facturas en JSON, calcula el hash encadenado, genera el XML SOAP y lo env - [x] Alta de facturas con hash encadenado - [x] Fallback local - [x] Registro y validación de certificados -- [ ] Anulación con AEAT +- [x] Anulación de facturas - [ ] Consultas - [ ] Subsanación +- [x] Soporte de campos PascalCase y snake_case en registro +- [x] Documentación de errores AEAT y solución de problemas con certificados P12 diff --git a/api/handler.go b/api/handler.go index 07a69a5..b5fec42 100644 --- a/api/handler.go +++ b/api/handler.go @@ -17,8 +17,32 @@ import ( type RegisterInput struct { CertName string `json:"cert_name"` + CertNamePascal string `json:"CertName"` CertFile string `json:"cert_file"` + CertFilePascal string `json:"CertFile"` PasswordEncrypted string `json:"password_encrypted"` + PasswordPascal string `json:"PasswordEncrypted"` +} + +func (r RegisterInput) CertNameResolved() string { + if r.CertName != "" { + return r.CertName + } + return r.CertNamePascal +} + +func (r RegisterInput) CertFileResolved() string { + if r.CertFile != "" { + return r.CertFile + } + return r.CertFilePascal +} + +func (r RegisterInput) PasswordResolved() string { + if r.PasswordEncrypted != "" { + return r.PasswordEncrypted + } + return r.PasswordPascal } type Handler struct { @@ -72,13 +96,16 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } - if input.CertName == "" || input.CertFile == "" || input.PasswordEncrypted == "" { + certName := input.CertNameResolved() + certFile := input.CertFileResolved() + passwordEnc := input.PasswordResolved() + if certName == "" || certFile == "" || passwordEnc == "" { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"success":false,"error":"missing_fields"}`)) return } - decodedPass, err := base64.StdEncoding.DecodeString(input.PasswordEncrypted) + decodedPass, err := base64.StdEncoding.DecodeString(passwordEnc) if err != nil { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"success":false,"error":"invalid_password_encrypted"}`)) @@ -94,7 +121,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { plainPass := string(plainPassBytes) - validation := cert.ValidateP12(input.CertFile, plainPass) + validation := cert.ValidateP12(certFile, plainPass) if !validation.Valid { resp, _ := json.Marshal(map[string]interface{}{ "success": false, @@ -106,7 +133,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } - tempPath, err := h.cert.StoreFromBase64(input.CertName, input.CertFile) + tempPath, err := h.cert.StoreFromBase64(certName, certFile) if err != nil { h.cert.DeleteTemp(tempPath) w.Header().Set("Content-Type", "application/json") @@ -115,7 +142,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { } if len(validation.Warnings) > 0 { - storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass) + storedPath, err := h.cert.MoveToPerm(certName, tempPath, plainPass) if err != nil { h.cert.DeleteTemp(tempPath) w.Header().Set("Content-Type", "application/json") @@ -123,7 +150,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } - tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) + tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass) if err != nil { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) @@ -141,7 +168,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } - storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass) + storedPath, err := h.cert.MoveToPerm(certName, tempPath, plainPass) if err != nil { h.cert.DeleteTemp(tempPath) w.Header().Set("Content-Type", "application/json") @@ -149,7 +176,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } - tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) + tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass) if err != nil { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) diff --git a/documentacion/api.md b/documentacion/api.md index 16f9a14..8ff07f1 100644 --- a/documentacion/api.md +++ b/documentacion/api.md @@ -34,15 +34,27 @@ POST /api/v1/auth/register ``` Registra y valida un certificado digital. El certificado se envía como base64 en el body (no como ruta de fichero). -**Request:** +**Request (snake_case):** ```json { "cert_name": "mi-certificado", "cert_file": "BASE64_CONTENT_OF_P12_FILE", - "password_encrypted": "base64_encoded_password" + "password_encrypted": "base64_encoded_encrypted_password" } ``` +**Request (PascalCase — compatibilidad con frontend):** +```json +{ + "CertName": "mi-certificado", + "CertFile": "BASE64_CONTENT_OF_P12_FILE", + "PasswordEncrypted": "base64_encoded_encrypted_password" +} +``` + +> **Nota:** `password_encrypted` debe ser la contraseña del certificado cifrada con la clave pública RSA obtenida en `/api/v1/auth/public-key`. No se envía en texto plano. +``` + **Response (éxito):** ```json { @@ -155,7 +167,7 @@ Ver [formatos.md](formatos.md) para detalles de cada formato y cómo añadir nue ```json { "success": true, - "csv": "CSV1234567ABC", + "csv": "A-FSZKDA8UG7WD9U", "estado": "Correcto" } ``` @@ -169,13 +181,16 @@ Ver [formatos.md](formatos.md) para detalles de cada formato y cómo añadir nue } ``` +> **Nota:** El campo `sistema.nombre` debe coincidir con el nombre registrado en la AEAT para el NIF del emisor. Si no coincide, la AEAT rechazará la factura con error de censo (código 1110 o 1239). +``` + --- ### Anular Factura ``` POST /api/v1/facturas/anular ``` -Anula una factura previamente registrada. +Anula una factura previamente registrada. Usa el mismo formato que el alta pero con `tipo: "anulacion"` y `tipo_factura: "R1"` (u otro tipo de rectificativa). **Request:** ```json @@ -183,12 +198,35 @@ Anula una factura previamente registrada. "tipo": "anulacion", "factura": { "emisor_nif": "53950250R", + "emisor_nombre": "JOSEP VICENT MESTRE LLOBELL", "num_serie": "FV2026/001", - "fecha_expedicion": "17-04-2026" + "fecha_expedicion": "21-05-2026", + "tipo_factura": "R1", + "descripcion": "Anulacion de factura de prueba", + "iva": [ + {"base": 1000.00, "cuota": 210.00, "tipo": 21.0} + ], + "importe_total": 1210.00 + }, + "sistema": { + "nombre": "JOSEP VICENT MESTRE LLOBELL", + "nif_proveedor": "53950250R", + "version": "1.0" } } ``` +**Response (éxito):** +```json +{ + "success": true, + "estado": "Anulada" +} +``` + +> **Nota:** El campo `sistema.nombre` debe coincidir con el nombre registrado en la AEAT para el NIF del emisor. Si no coincide, la AEAT rechazará la factura con error de censo. +``` + ## Códigos de Error | Código | Descripción | @@ -202,4 +240,18 @@ Anula una factura previamente registrada. | `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 +| `hash_storage_error` | Error leyendo el hash anterior | +| `missing_fields` | Faltan campos obligatorios en el registro | +| `invalid_json` | El JSON enviado no es válido | +| `decrypt_failed` | No se pudo descifrar la contraseña con la clave privada | +| `emisor_nif_required` | Falta el NIF del emisor en la anulación | +| `num_serie_required` | Falta el número de serie en la anulación | +| `fecha_expedicion_required` | Falta la fecha de expedición en la anulación | + +## Errores de la AEAT más comunes + +| Código | Descripción | Solución | +|--------|------------|----------| +| 1110 | NIF no identificado en el censo | Verificar que `sistema.nombre` coincide con el nombre del NIF | +| 1189 | Bloque Destinatarios obligatorio para tipo F1/F3/R1-R4 | Incluir `destinatario` con nombre y NIF | +| 1239 | Error en bloque Destinatario | Verificar formato del NIF del destinatario | \ No newline at end of file diff --git a/documentacion/certificado_pruebas.md b/documentacion/certificado_pruebas.md index 7c846c3..60e451b 100644 --- a/documentacion/certificado_pruebas.md +++ b/documentacion/certificado_pruebas.md @@ -90,6 +90,27 @@ VerifactuMidAPI/ ## Problemas Comunes +### "pkcs12: expected exactly two safe bags in the PFX PDU" +El archivo .p12 contiene más de un certificado (cadena completa con CA intermedias). La librería Go `pkcs12` espera solo el certificado de cliente y la clave privada. + +**Solución:** Extraer solo el certificado de cliente y crear un nuevo P12: +```bash +# Extraer clave privada +openssl pkcs12 -in original.p12 -nocerts -nodes -passin pass:PASSWORD -out key.pem + +# Extraer solo el certificado de cliente +openssl pkcs12 -in original.p12 -clcerts -nokeys -passin pass:PASSWORD -out client_cert.pem + +# Crear nuevo P12 simplificado +openssl pkcs12 -export -out personal.p12 -inkey key.pem -in client_cert.pem \ + -passout pass:PASSWORD -name "personal" -legacy -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES +``` + +### "pkcs12: unknown digest algorithm: 2.16.840.1.101.3.4.2.1" +La librería Go `pkcs12` no soporta cifrado SHA-256 en archivos P12. Se necesita usar cifrado legacy (SHA1/3DES). + +**Solución:** Recrear el P12 con cifrado legacy (ver comando `-legacy -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES` arriba). + ### "403 Forbidden" - El certificado no está autorizado en el entorno de pruebas - O el certificado es de producción y estás en test