Compare commits

...

1 Commits

Author SHA1 Message Date
lite 1d3e781c7b fix: soporte PascalCase en registro de certificados y actualizacion de documentacion
- Añadir campos PascalCase (CertName, CertFile, PasswordEncrypted) para compatibilidad con frontend
- Metodos CertNameResolved(), CertFileResolved(), PasswordResolved() para resolver campos
- Actualizar documentacion/api.md con ejemplos PascalCase, anulacion completa y errores AEAT
- Actualizar documentacion/certificado_pruebas.md con solucion de problemas P12 legacy
- Actualizar README.md con estado actual del proyecto
2026-05-21 19:31:11 -04:00
4 changed files with 117 additions and 15 deletions

View File

@ -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] Alta de facturas con hash encadenado
- [x] Fallback local - [x] Fallback local
- [x] Registro y validación de certificados - [x] Registro y validación de certificados
- [ ] Anulación con AEAT - [x] Anulación de facturas
- [ ] Consultas - [ ] Consultas
- [ ] Subsanación - [ ] 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

View File

@ -17,8 +17,32 @@ import (
type RegisterInput struct { type RegisterInput struct {
CertName string `json:"cert_name"` CertName string `json:"cert_name"`
CertNamePascal string `json:"CertName"`
CertFile string `json:"cert_file"` CertFile string `json:"cert_file"`
CertFilePascal string `json:"CertFile"`
PasswordEncrypted string `json:"password_encrypted"` 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 { type Handler struct {
@ -72,13 +96,16 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return 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.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"missing_fields"}`)) w.Write([]byte(`{"success":false,"error":"missing_fields"}`))
return return
} }
decodedPass, err := base64.StdEncoding.DecodeString(input.PasswordEncrypted) decodedPass, err := base64.StdEncoding.DecodeString(passwordEnc)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid_password_encrypted"}`)) 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) plainPass := string(plainPassBytes)
validation := cert.ValidateP12(input.CertFile, plainPass) validation := cert.ValidateP12(certFile, plainPass)
if !validation.Valid { if !validation.Valid {
resp, _ := json.Marshal(map[string]interface{}{ resp, _ := json.Marshal(map[string]interface{}{
"success": false, "success": false,
@ -106,7 +133,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
tempPath, err := h.cert.StoreFromBase64(input.CertName, input.CertFile) tempPath, err := h.cert.StoreFromBase64(certName, certFile)
if err != nil { if err != nil {
h.cert.DeleteTemp(tempPath) h.cert.DeleteTemp(tempPath)
w.Header().Set("Content-Type", "application/json") 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 { 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 { if err != nil {
h.cert.DeleteTemp(tempPath) h.cert.DeleteTemp(tempPath)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -123,7 +150,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`))
@ -141,7 +168,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass) storedPath, err := h.cert.MoveToPerm(certName, tempPath, plainPass)
if err != nil { if err != nil {
h.cert.DeleteTemp(tempPath) h.cert.DeleteTemp(tempPath)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -149,7 +176,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`))

View File

@ -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). 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 ```json
{ {
"cert_name": "mi-certificado", "cert_name": "mi-certificado",
"cert_file": "BASE64_CONTENT_OF_P12_FILE", "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):** **Response (éxito):**
```json ```json
{ {
@ -155,7 +167,7 @@ Ver [formatos.md](formatos.md) para detalles de cada formato y cómo añadir nue
```json ```json
{ {
"success": true, "success": true,
"csv": "CSV1234567ABC", "csv": "A-FSZKDA8UG7WD9U",
"estado": "Correcto" "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 ### Anular Factura
``` ```
POST /api/v1/facturas/anular 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:** **Request:**
```json ```json
@ -183,12 +198,35 @@ Anula una factura previamente registrada.
"tipo": "anulacion", "tipo": "anulacion",
"factura": { "factura": {
"emisor_nif": "53950250R", "emisor_nif": "53950250R",
"emisor_nombre": "JOSEP VICENT MESTRE LLOBELL",
"num_serie": "FV2026/001", "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ódigos de Error
| Código | Descripción | | Código | Descripción |
@ -202,4 +240,18 @@ Anula una factura previamente registrada.
| `aeat_error` | Error comunicando con la AEAT | | `aeat_error` | Error comunicando con la AEAT |
| `aeat_fault` | Error SOAP de la AEAT | | `aeat_fault` | Error SOAP de la AEAT |
| `hash_save_error` | Error guardando el hash local | | `hash_save_error` | Error guardando el hash local |
| `hash_storage_error` | Error leyendo el hash anterior | | `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 |

View File

@ -90,6 +90,27 @@ VerifactuMidAPI/
## Problemas Comunes ## 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" ### "403 Forbidden"
- El certificado no está autorizado en el entorno de pruebas - El certificado no está autorizado en el entorno de pruebas
- O el certificado es de producción y estás en test - O el certificado es de producción y estás en test