Compare commits
No commits in common. "main" and "v20260519-3ede32e" have entirely different histories.
main
...
v20260519-
|
|
@ -43,8 +43,6 @@ 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
|
||||||
- [x] Anulación de facturas
|
- [ ] Anulación con AEAT
|
||||||
- [ ] 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
|
|
||||||
|
|
|
||||||
|
|
@ -17,32 +17,8 @@ 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 {
|
||||||
|
|
@ -96,16 +72,13 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
certName := input.CertNameResolved()
|
if input.CertName == "" || input.CertFile == "" || input.PasswordEncrypted == "" {
|
||||||
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(passwordEnc)
|
decodedPass, err := base64.StdEncoding.DecodeString(input.PasswordEncrypted)
|
||||||
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"}`))
|
||||||
|
|
@ -121,7 +94,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
plainPass := string(plainPassBytes)
|
plainPass := string(plainPassBytes)
|
||||||
|
|
||||||
validation := cert.ValidateP12(certFile, plainPass)
|
validation := cert.ValidateP12(input.CertFile, plainPass)
|
||||||
if !validation.Valid {
|
if !validation.Valid {
|
||||||
resp, _ := json.Marshal(map[string]interface{}{
|
resp, _ := json.Marshal(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|
@ -133,7 +106,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tempPath, err := h.cert.StoreFromBase64(certName, certFile)
|
tempPath, err := h.cert.StoreFromBase64(input.CertName, input.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")
|
||||||
|
|
@ -142,7 +115,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(certName, tempPath, plainPass)
|
storedPath, err := h.cert.MoveToPerm(input.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")
|
||||||
|
|
@ -150,7 +123,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass)
|
tokenData, err := h.cert.GenerateToken(input.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"}`))
|
||||||
|
|
@ -168,7 +141,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
storedPath, err := h.cert.MoveToPerm(certName, tempPath, plainPass)
|
storedPath, err := h.cert.MoveToPerm(input.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")
|
||||||
|
|
@ -176,7 +149,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass)
|
tokenData, err := h.cert.GenerateToken(input.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"}`))
|
||||||
|
|
|
||||||
|
|
@ -34,27 +34,15 @@ 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 (snake_case):**
|
**Request:**
|
||||||
```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_encrypted_password"
|
"password_encrypted": "base64_encoded_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
|
||||||
{
|
{
|
||||||
|
|
@ -167,7 +155,7 @@ Ver [formatos.md](formatos.md) para detalles de cada formato y cómo añadir nue
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"csv": "A-FSZKDA8UG7WD9U",
|
"csv": "CSV1234567ABC",
|
||||||
"estado": "Correcto"
|
"estado": "Correcto"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -181,16 +169,13 @@ 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. Usa el mismo formato que el alta pero con `tipo: "anulacion"` y `tipo_factura: "R1"` (u otro tipo de rectificativa).
|
Anula una factura previamente registrada.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
|
|
@ -198,35 +183,12 @@ Anula una factura previamente registrada. Usa el mismo formato que el alta pero
|
||||||
"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": "21-05-2026",
|
"fecha_expedicion": "17-04-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 |
|
||||||
|
|
@ -241,17 +203,3 @@ Anula una factura previamente registrada. Usa el mismo formato que el alta pero
|
||||||
| `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 |
|
|
||||||
|
|
@ -90,27 +90,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,8 @@ func (s *Storage) loadFromDisk() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) {
|
func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) {
|
||||||
tmpDir := filepath.Join(s.basePath, "tmp")
|
if err := os.MkdirAll(s.basePath, 0700); err != nil {
|
||||||
if err := os.MkdirAll(tmpDir, 0700); err != nil {
|
return "", fmt.Errorf("creating cert directory: %w", err)
|
||||||
return "", fmt.Errorf("creating tmp directory: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
der, err := base64.StdEncoding.DecodeString(base64Content)
|
der, err := base64.StdEncoding.DecodeString(base64Content)
|
||||||
|
|
@ -89,7 +88,7 @@ func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) {
|
||||||
return "", fmt.Errorf("invalid base64: %w", err)
|
return "", fmt.Errorf("invalid base64: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storedPath := filepath.Join(tmpDir, id+".p12")
|
storedPath := filepath.Join(s.basePath, id+".p12")
|
||||||
if err := os.WriteFile(storedPath, der, 0600); err != nil {
|
if err := os.WriteFile(storedPath, der, 0600); err != nil {
|
||||||
return "", fmt.Errorf("writing certificate: %w", err)
|
return "", fmt.Errorf("writing certificate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue