Compare commits
No commits in common. "main" and "v20260517-572358f" have entirely different histories.
main
...
v20260517-
|
|
@ -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"`
|
CertPath string `json:"cert_path"`
|
||||||
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.CertPath == "" || 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.CertPath, 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.StoreTemp(input.CertName, input.CertPath, 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")
|
||||||
|
|
@ -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"}`))
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,29 @@
|
||||||
go version
|
go version
|
||||||
```
|
```
|
||||||
|
|
||||||
No se requieren Python, OpenSSL ni scripts externos. Todo el procesamiento de certificados (.p12/.pfx) es nativo en Go.
|
### Python 3
|
||||||
|
|
||||||
|
Required for test scripts and certificate conversion utilities.
|
||||||
|
|
||||||
|
- **Version:** 3.8 or higher
|
||||||
|
- **Verify installation:**
|
||||||
|
```bash
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenSSL (optional but recommended)
|
||||||
|
|
||||||
|
Used as an alternative to the Python conversion script for `.p12` to `.pem` certificates.
|
||||||
|
|
||||||
|
- **Linux:** `sudo apt install openssl` / `sudo pacman -S openssl`
|
||||||
|
- **macOS:** `brew install openssl`
|
||||||
|
- **Windows:** https://slproweb.com/products/Win32OpenSSL.html
|
||||||
|
- **Verify installation:**
|
||||||
|
```bash
|
||||||
|
openssl version
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
|
|
@ -141,3 +161,7 @@ The API falls back to defaults but logs a warning. Create the file as described
|
||||||
### `403 Forbidden` from AEAT
|
### `403 Forbidden` from AEAT
|
||||||
|
|
||||||
The certificate is not authorized in the AEAT testing environment. Contact AEAT or verify your FNMT certificate is enabled for VeriFactu.
|
The certificate is not authorized in the AEAT testing environment. Contact AEAT or verify your FNMT certificate is enabled for VeriFactu.
|
||||||
|
|
||||||
|
### Hardcoded Windows paths
|
||||||
|
|
||||||
|
`verifactu/client.go` and `internal/cert/validator.go` contain hardcoded Windows paths (`C:\Users\jmest\...`). Update them to your environment or use the `cert_file` config option instead.
|
||||||
|
|
|
||||||
|
|
@ -32,29 +32,17 @@ Obtiene la clave pública RSA para cifrar contraseñas.
|
||||||
```
|
```
|
||||||
POST /api/v1/auth/register
|
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.
|
||||||
|
|
||||||
**Request (snake_case):**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"cert_name": "mi-certificado",
|
"cert_name": "mi_certificado",
|
||||||
"cert_file": "BASE64_CONTENT_OF_P12_FILE",
|
"cert_path": "C:/ruta/al/certificado.p12",
|
||||||
"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
|
||||||
{
|
{
|
||||||
|
|
@ -140,9 +128,12 @@ Registra una factura en VeriFactu. El formato de entrada se detecta automáticam
|
||||||
"invoice": {
|
"invoice": {
|
||||||
"number": "FA2024/001",
|
"number": "FA2024/001",
|
||||||
"date": "2024-09-13T00:00:00Z",
|
"date": "2024-09-13T00:00:00Z",
|
||||||
|
"totalHt": 100.00,
|
||||||
|
"totalTax": 21.00,
|
||||||
|
"total": 121.00,
|
||||||
"notePublic": "Factura de prueba",
|
"notePublic": "Factura de prueba",
|
||||||
"lines": [
|
"lines": [
|
||||||
{"description": "Servicio", "quantity": 1, "unitPrice": 100, "taxRate": 21}
|
{"description": "Servicio", "quantity": 1, "unitPrice": 100, "taxRate": 21, "total": 121.00}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
|
|
@ -167,7 +158,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 +172,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 +186,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 +206,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
|
||||||
|
|
|
||||||
|
|
@ -46,17 +46,20 @@ Formato propio de la API. Se detecta por la presencia del campo `factura`.
|
||||||
|
|
||||||
## Formato: `dolibarr`
|
## Formato: `dolibarr`
|
||||||
|
|
||||||
Compatible con el formato que la web envía al BFF de Dolibarr. Los campos numéricos son `number` (no strings) y las fechas son ISO 8601. Se detecta por la presencia del campo `invoice`.
|
Compatible con el BFF de Dolibarr. Se detecta por la presencia del campo `invoice`. Agrupa automáticamente las líneas por tipo de IVA.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"invoice": {
|
"invoice": {
|
||||||
"number": "FA2024/001",
|
"number": "FA2024/001",
|
||||||
"date": "2024-09-13T00:00:00Z",
|
"date": "2024-09-13T00:00:00Z",
|
||||||
|
"totalHt": 100.00,
|
||||||
|
"totalTax": 21.00,
|
||||||
|
"total": 121.00,
|
||||||
"notePublic": "Servicios de consultoría",
|
"notePublic": "Servicios de consultoría",
|
||||||
"lines": [
|
"lines": [
|
||||||
{"description": "Servicio A", "quantity": 1, "unitPrice": 60, "taxRate": 21},
|
{"description": "Servicio A", "quantity": 1, "unitPrice": 60, "taxRate": 21, "total": 72.60},
|
||||||
{"description": "Servicio B", "quantity": 2, "unitPrice": 40, "taxRate": 10}
|
{"description": "Servicio B", "quantity": 1, "unitPrice": 40, "taxRate": 21, "total": 48.40}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
|
|
@ -80,18 +83,16 @@ Compatible con el formato que la web envía al BFF de Dolibarr. Los campos numé
|
||||||
| Dolibarr | VeriFactu |
|
| Dolibarr | VeriFactu |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `invoice.number` | `num_serie` |
|
| `invoice.number` | `num_serie` |
|
||||||
| `invoice.date` | `fecha_expedicion` (ISO → dd-mm-yyyy) |
|
| `invoice.date` | `fecha_expedicion` (convierte ISO → dd-mm-yyyy) |
|
||||||
| `invoice.notePublic` | `descripcion` |
|
| `invoice.notePublic` | `descripcion` |
|
||||||
| `lines[].quantity × unitPrice` | calcula `base = total / (1 + rate/100)`, `cuota = total - base` |
|
|
||||||
| `lines[].taxRate` | agrupa por tipo → `iva[].tipo` |
|
| `lines[].taxRate` | agrupa por tipo → `iva[].tipo` |
|
||||||
|
| `lines[].total` | calcula `base = total / (1 + rate/100)`, `cuota = total - base` |
|
||||||
| `client.name` | `destinatario.nombre` |
|
| `client.name` | `destinatario.nombre` |
|
||||||
| `client.vatNumber` | `destinatario.nif` |
|
| `client.vatNumber` | `destinatario.nif` |
|
||||||
| suma de líneas | `importe_total` (calculado automáticamente) |
|
| `invoice.total` | `importe_total` |
|
||||||
| `emisor.nif` | `emisor_nif` |
|
| `emisor.nif` | `emisor_nif` |
|
||||||
| `emisor.nombre` | `emisor_nombre` |
|
| `emisor.nombre` | `emisor_nombre` |
|
||||||
|
|
||||||
> Los totales (`totalHt`, `totalTax`, `total`) **no se envían** — se calculan a partir de las líneas. Esto evita inconsistencias y permite validar que los números cuadran.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Añadir un nuevo formato
|
## Añadir un nuevo formato
|
||||||
|
|
|
||||||
|
|
@ -10,28 +10,27 @@
|
||||||
|
|
||||||
### Validación
|
### Validación
|
||||||
|
|
||||||
La API valida nativamente (sin scripts externos):
|
La API valida:
|
||||||
1. **Formato PKCS#12 válido**
|
1. **Existencia del archivo**
|
||||||
2. **Contraseña correcta**
|
2. **Contraseña correcta**
|
||||||
3. **Fechas de validez** (no expirado, no futuro)
|
3. **Fechas de validez** (no expirado, no futuror)
|
||||||
4. **Días hasta expiración**
|
4. **Días hasta expiración**
|
||||||
|
|
||||||
### Almacenamiento
|
### Almacenamiento
|
||||||
|
|
||||||
El certificado se envía como base64 en el JSON de registro:
|
```
|
||||||
|
Flujo temporal:
|
||||||
```json
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
{
|
│ Original │───▶│ /tmp/ │───▶│ /certs/ │
|
||||||
"cert_name": "mi-cert",
|
│ (user) │ │ (validado)│ │ (permanente)
|
||||||
"cert_file": "BASE64_P12_CONTENT",
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
"password_encrypted": "..."
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
1. El cliente envía el certificado como base64
|
1. El usuario envía el certificado original
|
||||||
2. Se valida el PKCS#12 nativamente en Go
|
2. Se guarda temporalmente en `data/certs/tmp/`
|
||||||
3. Se guarda en `data/certs/<cert_name>.p12`
|
3. Se valida
|
||||||
4. Se genera un token de sesión
|
4. Si es válido, se mueve a `data/certs/`
|
||||||
|
5. Si falla, se borra el temporal
|
||||||
|
|
||||||
## Cifrado RSA
|
## Cifrado RSA
|
||||||
|
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -3,5 +3,3 @@ module VerifactuMidAPI
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
require golang.org/x/crypto v0.51.0 // indirect
|
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -1,5 +1,3 @@
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package cert
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -20,6 +19,7 @@ type Storage struct {
|
||||||
|
|
||||||
type Certificate struct {
|
type Certificate struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
OriginalPath string `json:"original_path"`
|
||||||
StoredPath string `json:"stored_path"`
|
StoredPath string `json:"stored_path"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
|
|
@ -46,6 +46,8 @@ func (s *Storage) Init() error {
|
||||||
if err := os.MkdirAll(s.basePath, 0700); err != nil {
|
if err := os.MkdirAll(s.basePath, 0700); err != nil {
|
||||||
return fmt.Errorf("creating cert storage directory: %w", err)
|
return fmt.Errorf("creating cert storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load existing certificates from disk
|
||||||
return s.loadFromDisk()
|
return s.loadFromDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,20 +80,23 @@ func (s *Storage) loadFromDisk() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) {
|
func (s *Storage) StoreTemp(id, origPath, password string) (string, error) {
|
||||||
tmpDir := filepath.Join(s.basePath, "tmp")
|
tmpPath := filepath.Join(s.basePath, "tmp")
|
||||||
if err := os.MkdirAll(tmpDir, 0700); err != nil {
|
if err := os.MkdirAll(tmpPath, 0700); err != nil {
|
||||||
return "", fmt.Errorf("creating tmp directory: %w", err)
|
return "", fmt.Errorf("creating tmp directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
der, err := base64.StdEncoding.DecodeString(base64Content)
|
ext := filepath.Ext(origPath)
|
||||||
|
storedFilename := fmt.Sprintf("%s%s", id, ext)
|
||||||
|
storedPath := filepath.Join(tmpPath, storedFilename)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(origPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid base64: %w", err)
|
return "", fmt.Errorf("reading certificate file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storedPath := filepath.Join(tmpDir, id+".p12")
|
if err := os.WriteFile(storedPath, data, 0600); err != nil {
|
||||||
if err := os.WriteFile(storedPath, der, 0600); err != nil {
|
return "", fmt.Errorf("storing certificate: %w", err)
|
||||||
return "", fmt.Errorf("writing certificate: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return storedPath, nil
|
return storedPath, nil
|
||||||
|
|
@ -101,7 +106,9 @@ func (s *Storage) MoveToPerm(id, tempPath, password string) (string, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
storedPath := filepath.Join(s.basePath, id+".p12")
|
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.Stat(storedPath); err == nil {
|
||||||
if err := os.Remove(storedPath); err != nil {
|
if err := os.Remove(storedPath); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package cert
|
package cert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"os"
|
||||||
"time"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"golang.org/x/crypto/pkcs12"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ValidationResult struct {
|
type ValidationResult struct {
|
||||||
|
|
@ -26,57 +26,70 @@ type CertInfo struct {
|
||||||
|
|
||||||
const WarningDaysThreshold = 30
|
const WarningDaysThreshold = 30
|
||||||
|
|
||||||
func ValidateP12(base64Content, password string) *ValidationResult {
|
func ValidateP12(filePath, password string) *ValidationResult {
|
||||||
result := &ValidationResult{Valid: true}
|
result := &ValidationResult{Valid: true}
|
||||||
|
|
||||||
der, err := base64.StdEncoding.DecodeString(base64Content)
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
if err != nil {
|
|
||||||
result.Valid = false
|
result.Valid = false
|
||||||
result.Error = "invalid_base64"
|
result.Error = "file_not_found"
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
_, cert, err := pkcs12.Decode(der, password)
|
scriptPath := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\validate_cert.ps1"
|
||||||
if err != nil {
|
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.Valid = false
|
||||||
result.Error = "invalid_password_or_format"
|
result.Error = "invalid_password_or_format"
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
if cert == nil {
|
if strings.HasPrefix(output, "NOT_FOUND") {
|
||||||
result.Valid = false
|
result.Valid = false
|
||||||
result.Error = "no_certificate_found"
|
result.Error = "file_not_found"
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
if strings.HasPrefix(output, "INVALID") {
|
||||||
if now.Before(cert.NotBefore) {
|
result.Valid = false
|
||||||
|
result.Error = "invalid_password_or_format"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(output, "NOT_YET_VALID") {
|
||||||
result.Valid = false
|
result.Valid = false
|
||||||
result.Error = "certificate_not_yet_valid"
|
result.Error = "certificate_not_yet_valid"
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
if now.After(cert.NotAfter) {
|
if strings.HasPrefix(output, "EXPIRED") {
|
||||||
result.Valid = false
|
result.Valid = false
|
||||||
result.Error = "certificate_expired"
|
result.Error = "certificate_expired"
|
||||||
result.CertInfo = &CertInfo{Expired: true}
|
result.CertInfo = &CertInfo{Expired: true}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
|
if strings.HasPrefix(output, "OK:") {
|
||||||
|
daysStr := strings.TrimPrefix(output, "OK:")
|
||||||
|
days, _ := strconv.Atoi(daysStr)
|
||||||
|
|
||||||
result.CertInfo = &CertInfo{
|
result.CertInfo = &CertInfo{
|
||||||
Subject: cert.Subject.String(),
|
Subject: "Certificate",
|
||||||
Issuer: cert.Issuer.String(),
|
Issuer: "Certificate",
|
||||||
NotBefore: cert.NotBefore.Format("2006-01-02"),
|
DaysUntilExpiry: days,
|
||||||
NotAfter: cert.NotAfter.Format("2006-01-02"),
|
|
||||||
DaysUntilExpiry: daysUntilExpiry,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if daysUntilExpiry <= WarningDaysThreshold {
|
if days <= WarningDaysThreshold {
|
||||||
result.Warnings = append(result.Warnings, "certificate_expiring_soon")
|
result.Warnings = append(result.Warnings, "certificate_expiring_soon")
|
||||||
result.CertInfo.ExpiringSoon = true
|
result.CertInfo.ExpiringSoon = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "invalid_password_or_format"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ type Input struct {
|
||||||
type InvoiceInput struct {
|
type InvoiceInput struct {
|
||||||
Number string `json:"number"`
|
Number string `json:"number"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
|
TotalHt float64 `json:"totalHt"`
|
||||||
|
TotalTax float64 `json:"totalTax"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
NotePublic string `json:"notePublic,omitempty"`
|
NotePublic string `json:"notePublic,omitempty"`
|
||||||
Lines []LineInput `json:"lines"`
|
Lines []LineInput `json:"lines"`
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +39,7 @@ type LineInput struct {
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
UnitPrice float64 `json:"unitPrice"`
|
UnitPrice float64 `json:"unitPrice"`
|
||||||
TaxRate float64 `json:"taxRate"`
|
TaxRate float64 `json:"taxRate"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientInput struct {
|
type ClientInput struct {
|
||||||
|
|
@ -60,10 +64,6 @@ func (t *Transformer) Transform(raw json.RawMessage) (*formats.TransformResult,
|
||||||
return nil, fmt.Errorf("invalid dolibarr format: %w", err)
|
return nil, fmt.Errorf("invalid dolibarr format: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(in.Invoice.Lines) == 0 {
|
|
||||||
return nil, fmt.Errorf("dolibarr format: at least one line is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
date, err := parseDate(in.Invoice.Date)
|
date, err := parseDate(in.Invoice.Date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid invoice date: %w", err)
|
return nil, fmt.Errorf("invalid invoice date: %w", err)
|
||||||
|
|
@ -72,9 +72,8 @@ func (t *Transformer) Transform(raw json.RawMessage) (*formats.TransformResult,
|
||||||
ivaMap := make(map[float64]*formats.IVAData)
|
ivaMap := make(map[float64]*formats.IVAData)
|
||||||
for _, line := range in.Invoice.Lines {
|
for _, line := range in.Invoice.Lines {
|
||||||
rate := line.TaxRate
|
rate := line.TaxRate
|
||||||
lineTotal := line.Quantity * line.UnitPrice
|
base := line.Total / (1 + rate/100)
|
||||||
base := lineTotal / (1 + rate/100)
|
cuota := line.Total - base
|
||||||
cuota := lineTotal - base
|
|
||||||
|
|
||||||
if existing, ok := ivaMap[rate]; ok {
|
if existing, ok := ivaMap[rate]; ok {
|
||||||
existing.Base += base
|
existing.Base += base
|
||||||
|
|
@ -89,11 +88,9 @@ func (t *Transformer) Transform(raw json.RawMessage) (*formats.TransformResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
iva := make([]formats.IVAData, 0, len(ivaMap))
|
iva := make([]formats.IVAData, 0, len(ivaMap))
|
||||||
var importeTotal float64
|
|
||||||
for _, v := range ivaMap {
|
for _, v := range ivaMap {
|
||||||
v.Base = round2(v.Base)
|
v.Base = round2(v.Base)
|
||||||
v.Cuota = round2(v.Cuota)
|
v.Cuota = round2(v.Cuota)
|
||||||
importeTotal += v.Base + v.Cuota
|
|
||||||
iva = append(iva, *v)
|
iva = append(iva, *v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +107,11 @@ func (t *Transformer) Transform(raw json.RawMessage) (*formats.TransformResult,
|
||||||
desc = "Factura"
|
desc = "Factura"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
importeTotal := in.Invoice.Total
|
||||||
|
if importeTotal == 0 {
|
||||||
|
importeTotal = in.Invoice.TotalHt + in.Invoice.TotalTax
|
||||||
|
}
|
||||||
|
|
||||||
return &formats.TransformResult{
|
return &formats.TransformResult{
|
||||||
EmisorNIF: in.Emisor.NIF,
|
EmisorNIF: in.Emisor.NIF,
|
||||||
EmisorNombre: in.Emisor.Nombre,
|
EmisorNombre: in.Emisor.Nombre,
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/pkcs12"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
|
@ -54,37 +54,35 @@ func NewClient(cfg ClientConfig) (*Client, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadCertificate(certPath, password string) (*tls.Certificate, error) {
|
func LoadCertificate(certPath, password string) (*tls.Certificate, error) {
|
||||||
der, err := os.ReadFile(certPath)
|
dir := filepath.Dir(certPath)
|
||||||
|
keyPath := filepath.Join(dir, "cert_key.pem")
|
||||||
|
certPath2 := filepath.Join(dir, "cert_cert.pem")
|
||||||
|
|
||||||
|
pyScript := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\convert_cert.py"
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("reading cert file: %w", err)
|
return nil, fmt.Errorf("converting: %w - %s", err, string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseP12(der, password)
|
certData, err := os.ReadFile(certPath2)
|
||||||
}
|
|
||||||
|
|
||||||
func LoadCertificateFromBytes(der []byte, password string) (*tls.Certificate, error) {
|
|
||||||
return parseP12(der, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseP12(der []byte, password string) (*tls.Certificate, error) {
|
|
||||||
key, cert, err := pkcs12.Decode(der, password)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decoding PKCS#12: %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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cert == nil {
|
cert, err := tls.X509KeyPair(certData, keyData)
|
||||||
return nil, fmt.Errorf("no certificate found in PKCS#12")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing: %w", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("cert loaded: has private key=%v", cert.PrivateKey != nil)
|
||||||
|
|
||||||
tlsCert := &tls.Certificate{
|
return &cert, nil
|
||||||
Certificate: [][]byte{cert.Raw},
|
|
||||||
PrivateKey: key,
|
|
||||||
Leaf: cert,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("cert loaded: subject=%s has private key=%v", cert.Subject, key != nil)
|
|
||||||
|
|
||||||
return tlsCert, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SetCertificate(certPath, password string) error {
|
func (c *Client) SetCertificate(certPath, password string) error {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue