Compare commits

...

5 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
lite bed50a49a3 fix: StoreFromBase64 writes to tmp/ instead of permanent location
- Fix bug where certificate was written directly to data/certs/
- MoveToPerm now correctly renames from tmp/ to permanent location
2026-05-19 17:19:44 -04:00
lite 3ede32ef20 refactor: only accept certificate as base64, remove cert_path field
- RegisterInput now only has cert_file (base64 content)
- Removed cert_path fallback and raw map parsing
- Clean struct-based JSON unmarshaling
2026-05-19 17:16:48 -04:00
lite 9c4f11d7c7 refactor: accept certificates as base64, remove external dependencies
- Replace file path with base64 content in register endpoint
- Use native Go pkcs12 for certificate validation and loading
- Remove PowerShell script dependency (validator.go)
- Remove Python script dependency (client.go)
- Remove hardcoded Windows paths
- Cross-platform: works on Linux, macOS, Windows without external tools
- Update documentation (api.md, seguridad.md, prerequisites.md)
2026-05-19 16:03:28 -04:00
lite b99d09789b refactor(dolibarr): use typed fields matching web→BFF format
- Remove totalHt/totalTax/total from input (calculated from lines)
- Remove line.total (calculated as quantity × unitPrice)
- All numeric fields are proper numbers (not strings)
- Dates are ISO 8601 (converted to dd-mm-yyyy internally)
- Totals calculated and validated automatically
2026-05-17 17:40:33 -04:00
13 changed files with 231 additions and 172 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"`
CertPath string `json:"cert_path"` CertNamePascal string `json:"CertName"`
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.CertPath == "" || 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.CertPath, 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.StoreTemp(input.CertName, input.CertPath, plainPass) 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

@ -11,29 +11,9 @@
go version go version
``` ```
### Python 3 No se requieren Python, OpenSSL ni scripts externos. Todo el procesamiento de certificados (.p12/.pfx) es nativo en Go.
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
@ -161,7 +141,3 @@ 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.

View File

@ -32,17 +32,29 @@ 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. 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_path": "C:/ruta/al/certificado.p12", "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
{ {
@ -128,12 +140,9 @@ 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, "total": 121.00} {"description": "Servicio", "quantity": 1, "unitPrice": 100, "taxRate": 21}
] ]
}, },
"client": { "client": {
@ -158,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"
} }
``` ```
@ -172,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
@ -186,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 |
@ -205,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

View File

@ -46,20 +46,17 @@ Formato propio de la API. Se detecta por la presencia del campo `factura`.
## Formato: `dolibarr` ## Formato: `dolibarr`
Compatible con el BFF de Dolibarr. Se detecta por la presencia del campo `invoice`. Agrupa automáticamente las líneas por tipo de IVA. 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`.
```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, "total": 72.60}, {"description": "Servicio A", "quantity": 1, "unitPrice": 60, "taxRate": 21},
{"description": "Servicio B", "quantity": 1, "unitPrice": 40, "taxRate": 21, "total": 48.40} {"description": "Servicio B", "quantity": 2, "unitPrice": 40, "taxRate": 10}
] ]
}, },
"client": { "client": {
@ -83,16 +80,18 @@ Compatible con el BFF de Dolibarr. Se detecta por la presencia del campo `invoic
| Dolibarr | VeriFactu | | Dolibarr | VeriFactu |
|---|---| |---|---|
| `invoice.number` | `num_serie` | | `invoice.number` | `num_serie` |
| `invoice.date` | `fecha_expedicion` (convierte ISO → dd-mm-yyyy) | | `invoice.date` | `fecha_expedicion` (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` |
| `invoice.total` | `importe_total` | | suma de líneas | `importe_total` (calculado automáticamente) |
| `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

View File

@ -10,27 +10,28 @@
### Validación ### Validación
La API valida: La API valida nativamente (sin scripts externos):
1. **Existencia del archivo** 1. **Formato PKCS#12 válido**
2. **Contraseña correcta** 2. **Contraseña correcta**
3. **Fechas de validez** (no expirado, no futuror) 3. **Fechas de validez** (no expirado, no futuro)
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/ │ {
│ (user) │ │ (validado)│ │ (permanente) "cert_name": "mi-cert",
└─────────────┘ └─────────────┘ └─────────────┘ "cert_file": "BASE64_P12_CONTENT",
"password_encrypted": "..."
}
``` ```
1. El usuario envía el certificado original 1. El cliente envía el certificado como base64
2. Se guarda temporalmente en `data/certs/tmp/` 2. Se valida el PKCS#12 nativamente en Go
3. Se valida 3. Se guarda en `data/certs/<cert_name>.p12`
4. Si es válido, se mueve a `data/certs/` 4. Se genera un token de sesión
5. Si falla, se borra el temporal
## Cifrado RSA ## Cifrado RSA

2
go.mod
View File

@ -3,3 +3,5 @@ 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
View File

@ -1,3 +1,5 @@
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=

View File

@ -3,6 +3,7 @@ package cert
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"os" "os"
@ -19,7 +20,6 @@ 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,8 +46,6 @@ 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()
} }
@ -80,23 +78,20 @@ func (s *Storage) loadFromDisk() error {
return nil return nil
} }
func (s *Storage) StoreTemp(id, origPath, password string) (string, error) { func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) {
tmpPath := filepath.Join(s.basePath, "tmp") tmpDir := filepath.Join(s.basePath, "tmp")
if err := os.MkdirAll(tmpPath, 0700); err != nil { if err := os.MkdirAll(tmpDir, 0700); err != nil {
return "", fmt.Errorf("creating tmp directory: %w", err) return "", fmt.Errorf("creating tmp directory: %w", err)
} }
ext := filepath.Ext(origPath) der, err := base64.StdEncoding.DecodeString(base64Content)
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("reading certificate file: %w", err) return "", fmt.Errorf("invalid base64: %w", err)
} }
if err := os.WriteFile(storedPath, data, 0600); err != nil { storedPath := filepath.Join(tmpDir, id+".p12")
return "", fmt.Errorf("storing certificate: %w", err) if err := os.WriteFile(storedPath, der, 0600); err != nil {
return "", fmt.Errorf("writing certificate: %w", err)
} }
return storedPath, nil return storedPath, nil
@ -106,9 +101,7 @@ func (s *Storage) MoveToPerm(id, tempPath, password string) (string, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
ext := filepath.Ext(tempPath) storedPath := filepath.Join(s.basePath, id+".p12")
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 {

View File

@ -1,10 +1,10 @@
package cert package cert
import ( import (
"os" "encoding/base64"
"os/exec" "time"
"strconv"
"strings" "golang.org/x/crypto/pkcs12"
) )
type ValidationResult struct { type ValidationResult struct {
@ -26,70 +26,57 @@ type CertInfo struct {
const WarningDaysThreshold = 30 const WarningDaysThreshold = 30
func ValidateP12(filePath, password string) *ValidationResult { func ValidateP12(base64Content, password string) *ValidationResult {
result := &ValidationResult{Valid: true} result := &ValidationResult{Valid: true}
if _, err := os.Stat(filePath); os.IsNotExist(err) { der, err := base64.StdEncoding.DecodeString(base64Content)
if err != nil {
result.Valid = false result.Valid = false
result.Error = "file_not_found" result.Error = "invalid_base64"
return result return result
} }
scriptPath := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\validate_cert.ps1" _, cert, err := pkcs12.Decode(der, password)
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-p12Path", filePath, "-pwd", password) if err != nil {
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 strings.HasPrefix(output, "NOT_FOUND") { if cert == nil {
result.Valid = false result.Valid = false
result.Error = "file_not_found" result.Error = "no_certificate_found"
return result return result
} }
if strings.HasPrefix(output, "INVALID") { now := time.Now()
result.Valid = false if now.Before(cert.NotBefore) {
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 strings.HasPrefix(output, "EXPIRED") { if now.After(cert.NotAfter) {
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
} }
if strings.HasPrefix(output, "OK:") { daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
daysStr := strings.TrimPrefix(output, "OK:")
days, _ := strconv.Atoi(daysStr)
result.CertInfo = &CertInfo{ result.CertInfo = &CertInfo{
Subject: "Certificate", Subject: cert.Subject.String(),
Issuer: "Certificate", Issuer: cert.Issuer.String(),
DaysUntilExpiry: days, NotBefore: cert.NotBefore.Format("2006-01-02"),
} NotAfter: cert.NotAfter.Format("2006-01-02"),
DaysUntilExpiry: daysUntilExpiry,
if days <= WarningDaysThreshold { }
result.Warnings = append(result.Warnings, "certificate_expiring_soon")
result.CertInfo.ExpiringSoon = true if daysUntilExpiry <= 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 return result
} }

View File

@ -18,20 +18,17 @@ type Transformer struct{}
func (t *Transformer) Name() string { return "dolibarr" } func (t *Transformer) Name() string { return "dolibarr" }
type Input struct { type Input struct {
Invoice InvoiceInput `json:"invoice"` Invoice InvoiceInput `json:"invoice"`
Client *ClientInput `json:"client,omitempty"` Client *ClientInput `json:"client,omitempty"`
Emisor EmisorInput `json:"emisor"` Emisor EmisorInput `json:"emisor"`
Sistema SistemaInput `json:"sistema"` Sistema SistemaInput `json:"sistema"`
} }
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"` NotePublic string `json:"notePublic,omitempty"`
TotalTax float64 `json:"totalTax"` Lines []LineInput `json:"lines"`
Total float64 `json:"total"`
NotePublic string `json:"notePublic,omitempty"`
Lines []LineInput `json:"lines"`
} }
type LineInput struct { type LineInput struct {
@ -39,11 +36,10 @@ 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 {
Name string `json:"name"` Name string `json:"name"`
VatNumber string `json:"vatNumber"` VatNumber string `json:"vatNumber"`
} }
@ -64,6 +60,10 @@ 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,8 +72,9 @@ 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
base := line.Total / (1 + rate/100) lineTotal := line.Quantity * line.UnitPrice
cuota := line.Total - base base := lineTotal / (1 + rate/100)
cuota := lineTotal - base
if existing, ok := ivaMap[rate]; ok { if existing, ok := ivaMap[rate]; ok {
existing.Base += base existing.Base += base
@ -88,9 +89,11 @@ 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)
} }
@ -107,11 +110,6 @@ 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,

View File

@ -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,35 +54,37 @@ func NewClient(cfg ClientConfig) (*Client, error) {
} }
func LoadCertificate(certPath, password string) (*tls.Certificate, error) { func LoadCertificate(certPath, password string) (*tls.Certificate, error) {
dir := filepath.Dir(certPath) der, err := os.ReadFile(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("converting: %w - %s", err, string(out)) return nil, fmt.Errorf("reading cert file: %w", err)
} }
certData, err := os.ReadFile(certPath2) return parseP12(der, password)
}
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("reading cert: %w", err) return nil, fmt.Errorf("decoding PKCS#12: %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 cert == nil {
if err != nil { return nil, fmt.Errorf("no certificate found in PKCS#12")
return nil, fmt.Errorf("parsing: %w", err)
} }
log.Printf("cert loaded: has private key=%v", cert.PrivateKey != nil)
return &cert, nil tlsCert := &tls.Certificate{
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 {