Compare commits

..

No commits in common. "main" and "v20260517-572358f" have entirely different histories.

13 changed files with 172 additions and 231 deletions

View File

@ -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

View File

@ -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"}`))

View File

@ -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.

View File

@ -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 |

View File

@ -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

View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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 {

View File

@ -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
}

View File

@ -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,

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,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 {