init: monorepo completo — doli-front, dolibarr-bff, VerifactuMidAPI

Código real de los 3 proyectos incluido directamente:
- doli-front: SPA Vanilla JS + Vite
- dolibarr-bff: BFF .NET 10
- VerifactuMidAPI: microservicio Go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Алекс 2026-05-29 16:55:07 +02:00
parent 4609c43b40
commit 322608286f
244 changed files with 29217 additions and 3 deletions

33
.gitignore vendored
View File

@ -1,5 +1,38 @@
# Dependencias
node_modules/
**/node_modules/
# Build / compilados
dist/
build/
**/bin/
**/obj/
# Tests
test-results/
coverage/
# Certificados y secretos
*.pfx
*.p12
.env
.env.local
**/.env
**/.env.local
# Editores
.vscode/
.idea/
*.suo
*.user
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Go
**/__debug_bin

@ -1 +0,0 @@
Subproject commit 35446f3c490c6b476e6657ee6a9db777bf84284c

View File

@ -0,0 +1,75 @@
name: Build & Release
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows]
goarch: [amd64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: go build -o verifactu-api-bin .
- name: Package Linux
if: matrix.goos == 'linux'
run: |
mkdir -p verifactu-api
mv verifactu-api-bin verifactu-api/verifactu-api
cp config.yml verifactu-api/
tar czf verifactu-api-linux-amd64.tar.gz verifactu-api/
- name: Package Windows
if: matrix.goos == 'windows'
run: |
mkdir -p verifactu-api
mv verifactu-api-bin verifactu-api/verifactu-api.exe
cp config.yml verifactu-api/
zip -r verifactu-api-windows-amd64.zip verifactu-api/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: verifactu-api-${{ matrix.goos }}-amd64
path: verifactu-api-${{ matrix.goos }}-amd64.*
release:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="v$(date +%Y%m%d)-${GITHUB_SHA::7}"
gh release create "$TAG" \
--title "Build $TAG" \
--generate-notes \
artifacts/verifactu-api-linux-amd64/verifactu-api-linux-amd64.tar.gz \
artifacts/verifactu-api-windows-amd64/verifactu-api-windows-amd64.zip

59
VerifactuMidAPI/.gitignore vendored Normal file
View File

@ -0,0 +1,59 @@
# Binarios
*.exe
*.dll
*.so
*.dylib
verifactu-api.exe
# Build
/build/
/dist/
# Go
*.test
coverage.txt
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Configuración sensible
.env
.env.local
config.local.yml
# Certificados y claves
certs/
keys/
*.p12
*.pem
*.key
# Documentación (no incluir en el repo)
Documentacion de Verifactu/
# Datos generados en tiempo de ejecución
data/
# Logs
*.log
test_log.txt
# Scripts auxiliares reemplazados por Go nativo
validate_cert.ps1
validate_cert.js
convert_cert.ps1
convert_cert.py
# Caché Python
test/__pycache__/
# Memoria del asistente
.claude/

47
VerifactuMidAPI/AGENTS.md Normal file
View File

@ -0,0 +1,47 @@
# AGENTS.md
## Project
- Go 1.26.1
- No external dependencies yet
- Entry point: `main.go`
## Architecture
```
proyecto/
├── api/ → HTTP exposure, endpoints, routing
├── internal/ → business logic, validation, normalization
└── verifactu/ → AEAT communication, XML, signing, crypto chaining
```
## VeriFactu Protocol
- **Transport:** SOAP 1.1 / HTTPS
- **Format:** XML (UTF-8), document/literal
- **Auth:** Qualified electronic certificate (.p12)
- **Hash:** SHA-256, hex uppercase, chained with previous record
- **Max records per request:** 1,000
- **Environments:** Testing (sandbox) / Production
- **Current:** Testing only
- **Config:** Change via config file or env var (not code)
- **PDF generation:** Handled by Dolibarr (not this API's responsibility)
## VeriFactu Documentation
See `Documentacion de Verifactu/` folder:
- `01_que_es_verifactu.md` - Overview, legal framework
- `02_como_funciona.md` - Flow, operations, responses
- `03_entornos_y_urls.md` - URLs for test/prod, XSD, WSDL
- `04_encadenamiento_hash.md` - Crypto chaining, hash calculation
- `05_estructura_xml.md` - XML structure, fields
- `06_operaciones.md` - Alta, anulación, subsanación
- `07_consultas.md` - Query sent records
- `08_errores_y_respuestas.md` - Error codes
- `09_control_de_flujo.md` - Rate limits
- `10_pasar_a_produccion.md` - Prod migration steps
## Dependencies
- Use Go modules (`go.mod`), no vendoring
## Pending Decisions (to discuss)
- Exact input JSON format
- Authentication mechanism
- Persistence (database, file, etc.)
- Documentation language

50
VerifactuMidAPI/README.md Normal file
View File

@ -0,0 +1,50 @@
# VeriFactu MidAPI
API intermediaria para enviar facturas a la AEAT a través del protocolo VeriFactu.
Recibe facturas en JSON, calcula el hash encadenado, genera el XML SOAP y lo envía a la AEAT. Si la AEAT no está disponible, guarda la factura localmente (fallback).
---
## Documentación
| | |
|---|---|
| [Requisitos y setup](documentacion/PREREQUISITES.md) | Go, Python, OpenSSL, certificado, configuración |
| [API Reference](documentacion/api.md) | Endpoints, requests, responses |
| [Protocolo VeriFactu](documentacion/verifactu.md) | Operaciones, hash, URLs AEAT, XML |
| [Formato de datos](documentacion/formato_datos.md) | NIF, fechas, tipos factura, IVA, ejemplo JSON |
| [Formatos de entrada](documentacion/formatos.md) | native, dolibarr, y cómo añadir nuevos |
| [Arquitectura](documentacion/arqui.md) | Capas, flujo de datos, cifrado |
| [Seguridad](documentacion/seguridad.md) | Certificados, RSA, HTTPS |
| [Certificados](documentacion/certificado_pruebas.md) | Obtener y configurar certificado FNMT |
| [Tokens](documentacion/tokens.md) | Sistema de autenticación por tokens |
| [Configuración](documentacion/config.md) | config.yml, variables de entorno |
| [Testing](documentacion/testing.md) | Tests, depuración |
| [Errores](documentacion/ERRORES.md) | Códigos de error |
---
## Endpoints
| Método | Ruta | Descripción |
|--------|------|-------------|
| `GET` | `/api/v1/health` | Health check |
| `GET` | `/api/v1/auth/public-key` | Clave pública RSA |
| `POST` | `/api/v1/auth/register` | Registrar certificado .p12 |
| `GET` | `/api/v1/formats` | Lista formatos disponibles |
| `POST` | `/api/v1/facturas` | Alta de factura (formato auto-detectado) |
| `POST` | `/api/v1/facturas/anular` | Anular factura |
---
## Estado
- [x] Alta de facturas con hash encadenado
- [x] Fallback local
- [x] Registro y validación de certificados
- [x] Anulación de facturas
- [ ] Consultas
- [ ] 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

@ -0,0 +1,277 @@
package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"VerifactuMidAPI/internal"
"VerifactuMidAPI/internal/cert"
"VerifactuMidAPI/internal/config"
"VerifactuMidAPI/internal/crypto"
"VerifactuMidAPI/internal/formats"
)
type RegisterInput struct {
CertName string `json:"cert_name"`
CertNamePascal string `json:"CertName"`
CertFile string `json:"cert_file"`
CertFilePascal string `json:"CertFile"`
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 {
cfg *config.Config
cert *cert.Storage
crypto *crypto.KeyPair
facturaSvc *internal.FacturaService
}
func New(cfg *config.Config, certStorage *cert.Storage, keyPair *crypto.KeyPair, facturaSvc *internal.FacturaService) *Handler {
return &Handler{
cfg: cfg,
cert: certStorage,
crypto: keyPair,
facturaSvc: facturaSvc,
}
}
func (h *Handler) GetPublicKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pubPEM, err := h.crypto.PublicKeyPEM()
if err != nil {
http.Error(w, "failed to get public key", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fmt.Sprintf(`{"public_key":"%s"}`, base64.StdEncoding.EncodeToString(pubPEM))))
}
func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
var input RegisterInput
if err := json.Unmarshal(body, &input); err != nil {
log.Printf("[RegisterCert] ERROR: JSON invalido: %v", err)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid_json"}`))
return
}
certName := input.CertNameResolved()
certFileB64 := input.CertFileResolved()
passwordEnc := input.PasswordResolved()
log.Printf("[RegisterCert] Inicio: cert_name=%q, cert_file_len=%d, password_len=%d",
certName, len(certFileB64), len(passwordEnc))
if certName == "" || certFileB64 == "" || passwordEnc == "" {
log.Printf("[RegisterCert] ERROR: campos vacios")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"missing_fields"}`))
return
}
certBytes, err := base64.StdEncoding.DecodeString(certFileB64)
if err != nil {
log.Printf("[RegisterCert] ERROR: base64 del cert invalido: %v", err)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid_cert_file"}`))
return
}
log.Printf("[RegisterCert] Certificado decodificado: %d bytes", len(certBytes))
decodedPass, err := base64.StdEncoding.DecodeString(passwordEnc)
if err != nil {
log.Printf("[RegisterCert] ERROR: base64 contrasena invalido: %v", err)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid_password_encrypted"}`))
return
}
plainPassBytes, err := h.crypto.Decrypt(decodedPass)
if err != nil {
log.Printf("[RegisterCert] ERROR: descifrado RSA fallido: %v", err)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"decrypt_failed"}`))
return
}
log.Printf("[RegisterCert] Contrasena descifrada correctamente")
plainPass := string(plainPassBytes)
log.Printf("[RegisterCert] Validando certificado P12...")
validation := cert.ValidateP12Bytes(certBytes, plainPass)
if !validation.Valid {
log.Printf("[RegisterCert] ERROR validacion: %s", validation.Error)
resp, _ := json.Marshal(map[string]interface{}{
"success": false,
"error": validation.Error,
"cert": validation.CertInfo,
})
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
return
}
log.Printf("[RegisterCert] Certificado valido. Dias hasta expiracion: %d, warnings: %v",
validation.CertInfo.DaysUntilExpiry, validation.Warnings)
tempPath, err := h.cert.StoreFromBase64(certName, certFileB64)
if err != nil {
log.Printf("[RegisterCert] ERROR StoreFromBase64: %v", err)
h.cert.DeleteTemp(tempPath)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"temp_storage_failed"}`))
return
}
log.Printf("[RegisterCert] Certificado guardado en temporal: %s", tempPath)
storedPath, err := h.cert.MoveToPerm(certName, tempPath)
if err != nil {
log.Printf("[RegisterCert] ERROR MoveToPerm: %v", err)
h.cert.DeleteTemp(tempPath)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"storage_failed"}`))
return
}
log.Printf("[RegisterCert] Certificado almacenado definitivamente: %s", storedPath)
tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass)
if err != nil {
log.Printf("[RegisterCert] ERROR GenerateToken: %v", err)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`))
return
}
log.Printf("[RegisterCert] Token generado: %s", tokenData.Token)
if err := h.facturaSvc.ReloadCertificate(storedPath, plainPass); err != nil {
log.Printf("[RegisterCert] WARNING: no se pudo recargar el certificado en el cliente VeriFactu: %v", err)
} else {
log.Printf("[RegisterCert] Certificado recargado en el cliente VeriFactu")
}
if len(validation.Warnings) > 0 {
log.Printf("[RegisterCert] OK con warnings: %v", validation.Warnings)
resp, _ := json.Marshal(map[string]interface{}{
"success": true,
"cert": validation.CertInfo,
"token": tokenData.Token,
"warnings": validation.Warnings,
})
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
return
}
log.Printf("[RegisterCert] OK: certificado %q registrado", certName)
resp, _ := json.Marshal(map[string]interface{}{
"success": true,
"cert": validation.CertInfo,
"token": tokenData.Token,
})
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
func (h *Handler) HandleFacturas(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
log.Printf("facturas request: %s", string(body))
output, _ := h.facturaSvc.ProcessAlta(body)
log.Printf("facturas output:%+v", output)
resp, _ := json.Marshal(output)
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
func (h *Handler) HandleFacturasAnular(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
var input internal.AnulacionInput
if err := json.Unmarshal(body, &input); err != nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid_json"}`))
return
}
output, _ := h.facturaSvc.ProcessAnulacion(input)
resp, _ := json.Marshal(output)
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
func (h *Handler) ListFormats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
available := formats.Available()
resp, _ := json.Marshal(map[string]interface{}{
"formats": available,
})
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}

View File

@ -0,0 +1,19 @@
package api
import (
"net/http"
)
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/auth/public-key", h.GetPublicKey)
mux.HandleFunc("/api/v1/auth/register", h.RegisterCert)
mux.HandleFunc("/api/v1/facturas", h.HandleFacturas)
mux.HandleFunc("/api/v1/facturas/anular", h.HandleFacturasAnular)
mux.HandleFunc("/api/v1/formats", h.ListFormats)
mux.HandleFunc("/api/v1/health", h.HealthCheck)
}
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}

View File

@ -0,0 +1,60 @@
package main
import (
"crypto/tls"
"fmt"
"os"
goPkcs12 "software.sslmate.com/src/go-pkcs12"
stdPkcs12 "golang.org/x/crypto/pkcs12"
)
func main() {
path := os.Args[1]
pass := os.Args[2]
data, err := os.ReadFile(path)
if err != nil {
fmt.Printf("ERROR leyendo fichero: %v\n", err)
os.Exit(1)
}
fmt.Printf("Fichero leído: %d bytes\n", len(data))
// Intento 1: go-pkcs12 Decode (moderno)
_, cert, err := goPkcs12.Decode(data, pass)
if err != nil {
fmt.Printf("go-pkcs12 Decode FALLO: %v\n", err)
} else {
fmt.Printf("go-pkcs12 Decode OK: subject=%s, expira=%s\n", cert.Subject.CommonName, cert.NotAfter.Format("2006-01-02"))
}
// Intento 2: go-pkcs12 Legacy
cert2, err := goPkcs12.DecodeTrustStore(data, pass)
if err != nil {
fmt.Printf("go-pkcs12 DecodeTrustStore FALLO: %v\n", err)
} else {
fmt.Printf("go-pkcs12 DecodeTrustStore OK: %d certs\n", len(cert2))
}
// Intento 3: golang.org/x/crypto/pkcs12 (legacy)
blocks, err := stdPkcs12.ToPEM(data, pass)
if err != nil {
fmt.Printf("x/crypto ToPEM FALLO: %v\n", err)
} else {
fmt.Printf("x/crypto ToPEM OK: %d bloques PEM\n", len(blocks))
}
// Intento 4: tls.X509KeyPair vía x/crypto
if err == nil {
var certPEM, keyPEM []byte
for _, b := range blocks {
if b.Type == "CERTIFICATE" {
certPEM = append(certPEM, b.Bytes...)
} else if b.Type == "PRIVATE KEY" {
keyPEM = append(keyPEM, b.Bytes...)
}
}
_ = tls.Certificate{}
fmt.Printf(" cert PEM bytes: %d, key PEM bytes: %d\n", len(certPEM), len(keyPEM))
}
}

View File

@ -0,0 +1,101 @@
package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
)
func main() {
if len(os.Args) < 4 {
fmt.Println("uso: register_cert <cert_name> <p12_path> <password>")
os.Exit(1)
}
certName := os.Args[1]
p12Path := os.Args[2]
password := os.Args[3]
// 1. Obtener clave pública de la API
resp, err := http.Get("http://localhost:6789/api/v1/auth/public-key")
if err != nil {
fmt.Printf("ERROR obteniendo clave pública: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var pkResp struct {
PublicKey string `json:"public_key"`
}
json.Unmarshal(body, &pkResp)
pemBytes, err := base64.StdEncoding.DecodeString(pkResp.PublicKey)
if err != nil {
fmt.Printf("ERROR decodificando PEM base64: %v\n", err)
os.Exit(1)
}
block, _ := pem.Decode(pemBytes)
if block == nil {
fmt.Println("ERROR: PEM inválido")
os.Exit(1)
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
fmt.Printf("ERROR parseando clave pública: %v\n", err)
os.Exit(1)
}
rsaPub := pub.(*rsa.PublicKey)
// 2. Cifrar contraseña con RSA PKCS1v15
encPass, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPub, []byte(password))
if err != nil {
fmt.Printf("ERROR cifrando contraseña: %v\n", err)
os.Exit(1)
}
encPassB64 := base64.StdEncoding.EncodeToString(encPass)
// 3. Leer P12 y codificar en base64
p12Data, err := os.ReadFile(p12Path)
if err != nil {
fmt.Printf("ERROR leyendo P12: %v\n", err)
os.Exit(1)
}
p12B64 := base64.StdEncoding.EncodeToString(p12Data)
// 4. Enviar petición de registro
payload, _ := json.Marshal(map[string]string{
"cert_name": certName,
"cert_file": p12B64,
"password_encrypted": encPassB64,
})
regResp, err := http.Post(
"http://localhost:6789/api/v1/auth/register",
"application/json",
bytes.NewReader(payload),
)
if err != nil {
fmt.Printf("ERROR enviando registro: %v\n", err)
os.Exit(1)
}
defer regResp.Body.Close()
result, _ := io.ReadAll(regResp.Body)
// Pretty-print la respuesta
var pretty map[string]interface{}
if json.Unmarshal(result, &pretty) == nil {
out, _ := json.MarshalIndent(pretty, "", " ")
fmt.Println(string(out))
} else {
fmt.Println(string(result))
}
}

View File

@ -0,0 +1,14 @@
server:
port: 6789
verifactu:
production: false
certificates:
storage_path: ./data/certs/
crypto:
keys_path: ./keys/
# Name and email for RSA key generation (if keys don't exist)
name: "VeriFactu API"
email: "admin@byolivia.work"

View File

@ -0,0 +1,55 @@
# Códigos de Error
## Errores de autenticación
| Código | Descripción |
|--------|-------------|
| `missing_fields` | Faltan campos obligatorios en el registro |
| `invalid_json` | El cuerpo de la petición no es JSON válido |
| `invalid_password_encrypted` | La contraseña cifrada no es Base64 válido |
| `decrypt_failed` | No se pudo descifrar la contraseña con la clave privada RSA |
| `file_not_found` | El archivo de certificado no existe |
| `invalid_password_or_format` | Contraseña incorrecta o formato de certificado inválido |
| `certificate_not_yet_valid` | El certificado aún no es válido |
| `certificate_expired` | El certificado ha expirado |
| `certificate_expiring_soon` | El certificado caduca en menos de 30 días (warning) |
| `temp_storage_failed` | Error guardando el certificado temporal |
| `storage_failed` | Error moviendo el certificado a almacenamiento permanente |
| `token_generation_failed` | Error generando el token de sesión |
## Errores de validación de facturas
La API devuelve `validation_failed` con detalles por campo:
```json
{
"success": false,
"error": "validation_failed: factura.emisor_nif: invalid NIF format; ..."
}
```
| Campo | Error |
|-------|-------|
| `tipo` | Debe ser `alta` o `anulacion` |
| `factura.emisor_nif` | Vacío o formato inválido (esperado: 9 caracteres, regex `^[A-Z0-9]\d{7}[A-Z]$`) |
| `factura.num_serie` | Vacío |
| `factura.fecha_expedicion` | Vacío o formato inválido (esperado: `dd-mm-yyyy`) |
| `factura.tipo_factura` | Vacío o no es `F1`, `F2`, `R1`-`R5` |
| `factura.iva[]` | Sin registros, o base/cuota/tipo negativos |
| `factura.importe_total` | Menor o igual a 0 |
| `sistema.nombre` | Vacío |
| `sistema.nif_proveedor` | Vacío |
| `sistema.version` | Vacío |
## Errores de procesamiento
| Código | Descripción |
|--------|-------------|
| `hash_storage_error` | Error leyendo el último hash almacenado |
| `hash_save_error` | Error guardando el hash de la factura actual |
| `aeat_error: <detalle>` | Error comunicando con la AEAT |
| `aeat_fault: <detalle>` | Fault SOAP devuelto por la AEAT |
## Errores AEAT
La AEAT devuelve códigos de error en el campo `CodigoErrorRegistro` de la respuesta SOAP. Consulta la documentación oficial de la AEAT para la lista completa.

View File

@ -0,0 +1,143 @@
# Prerequisites
## System Requirements
### Go
- **Version:** 1.26 or higher
- **Download:** https://go.dev/dl/
- **Verify installation:**
```bash
go version
```
No se requieren Python, OpenSSL ni scripts externos. Todo el procesamiento de certificados (.p12/.pfx) es nativo en Go.
---
## Project Setup
### 1. Clone the repository
```bash
git clone <repository-url>
cd VerifactuMidAPI
```
### 2. Install Go dependencies
```bash
go mod download
```
### 3. Create required directories
```bash
mkdir -p data/certs data keys
```
| Directory | Purpose |
|---|---|
| `data/certs/` | Stored `.p12` certificates |
| `data/` | Hash chain records (JSON files per emitter) |
| `keys/` | Auto-generated RSA key pair for password encryption |
### 4. Configure the application
Copy or edit `config.yml`:
```yaml
server:
port: 6789
verifactu:
production: false
certificates:
storage_path: ./data/certs/
cert_file: ./data/certs/personal.p12
cert_password: YOUR_PASSWORD
crypto:
keys_path: ./keys/
name: "VeriFactu API"
email: "admin@example.com"
```
> **Important:** Change `cert_password` and `email` to your own values.
### 5. Obtain a digital certificate
VeriFactu requires a **qualified electronic certificate** (eIDAS compliant) in `.p12` or `.pfx` format.
- **FNMT Persona Fisica** (free): https://www.fnmt.es
- Must be valid and registered with AEAT for the testing environment
- Place it at `./data/certs/personal.p12` (or update `cert_file` in config)
---
## Build & Run
### Development (auto-reload with source)
```bash
go run .
```
### Production build
```bash
go build -o verifactu-api .
./verifactu-api
```
The server starts on `http://localhost:6789` (or the port configured in `config.yml`).
---
## Verify the installation
### 1. Health check
```bash
curl http://localhost:6789/api/v1/health
# Expected: {"status":"ok"}
```
### 2. Get public key
```bash
curl http://localhost:6789/api/v1/auth/public-key
# Expected: {"public_key":"<base64>"}
```
---
## Troubleshooting
### `go: command not found`
Go is not installed or not in your `PATH`. Add it:
```bash
# Linux/macOS
export PATH=$PATH:/usr/local/go/bin
# Windows: add C:\Program Files\Go\bin to system PATH
```
### `config.yml not found`
The API falls back to defaults but logs a warning. Create the file as described in step 4.
### `certificate expired` / `invalid_password_or_format`
- Verify the certificate dates with:
```bash
openssl pkcs12 -in ./data/certs/personal.p12 -info -nokeys
```
- Ensure the password in `config.yml` matches the certificate.
### `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.

View File

@ -0,0 +1,35 @@
# Documentación
Índice de documentación técnica de VeriFactu MidAPI.
## Guías
| Documento | Descripción |
|-----------|-------------|
| [PREREQUISITES.md](PREREQUISITES.md) | Requisitos del sistema, setup paso a paso, build, run y troubleshooting |
| [ERRORES.md](ERRORES.md) | Códigos de error de la API y de la AEAT |
## Referencia técnica
| Documento | Descripción |
|-----------|-------------|
| [api.md](api.md) | Referencia completa de endpoints, requests y responses |
| [verifactu.md](verifactu.md) | Protocolo VeriFactu: operaciones, hash encadenado, URLs AEAT, formato XML |
| [formato_datos.md](formato_datos.md) | Formatos de datos: NIF, fechas, tipos de factura, IVA, ejemplo JSON |
| [formatos.md](formatos.md) | Formatos de entrada soportados: native, dolibarr, y cómo añadir nuevos |
| [arqui.md](arqui.md) | Arquitectura del proyecto, capas y flujo de datos |
## Seguridad y certificados
| Documento | Descripción |
|-----------|-------------|
| [seguridad.md](seguridad.md) | Certificados digitales, cifrado RSA, HTTPS, rate limiting |
| [certificado_pruebas.md](certificado_pruebas.md) | Obtención y configuración de certificados FNMT para pruebas |
| [tokens.md](tokens.md) | Sistema de tokens: flujo, almacenamiento, seguridad |
## Configuración y testing
| Documento | Descripción |
|-----------|-------------|
| [config.md](config.md) | Fichero config.yml, variables de entorno, estructura de directorios |
| [testing.md](testing.md) | Tests de certificados, tests de facturas, depuración |

View File

@ -0,0 +1,257 @@
# API Reference
## Endpoints
### Health
```
GET /api/v1/health
```
Verifica que la API está funcionando.
**Response:**
```json
{"status": "ok"}
```
---
### Obtener Clave Pública
```
GET /api/v1/auth/public-key
```
Obtiene la clave pública RSA para cifrar contraseñas.
**Response:**
```json
{"public_key": "base64_encoded_key"}
```
---
### Registrar Certificado
```
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).
**Request (snake_case):**
```json
{
"cert_name": "mi-certificado",
"cert_file": "BASE64_CONTENT_OF_P12_FILE",
"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):**
```json
{
"success": true,
"cert": {
"subject": "...",
"issuer": "...",
"expired": false,
"expiring_soon": false,
"days_until_expiry": 365
},
"token": "A1B2C3D4..."
}
```
**Response (error):**
```json
{
"success": false,
"error": "certificate_expired",
"cert": {
"subject": "...",
"issuer": "...",
"expired": true
}
}
```
---
### Formatos Disponibles
```
GET /api/v1/formats
```
Lista los formatos de entrada soportados. La API detecta automáticamente el formato del JSON recibido.
**Response:**
```json
{
"formats": ["dolibarr", "native"]
}
```
---
### Alta de Factura
```
POST /api/v1/facturas
```
Registra una factura en VeriFactu. El formato de entrada se detecta automáticamente (no requiere parámetro).
**Formato nativo (por defecto):**
```json
{
"tipo": "alta",
"factura": {
"emisor_nif": "53950250R",
"emisor_nombre": "EMPRESA EJEMPLO SL",
"num_serie": "FV2026/001",
"fecha_expedicion": "17-04-2026",
"tipo_factura": "F1",
"descripcion": "Factura de prueba",
"destinatario": {
"nombre": "CLIENTE SL",
"nif": "B98765432"
},
"iva": [
{"base": 100.00, "cuota": 21.00, "tipo": 21.0}
],
"importe_total": 121.00
},
"sistema": {
"nombre": "Mi Sistema",
"nif_proveedor": "53950250R",
"version": "1.0"
}
}
```
**Formato Dolibarr BFF:**
```json
{
"invoice": {
"number": "FA2024/001",
"date": "2024-09-13T00:00:00Z",
"notePublic": "Factura de prueba",
"lines": [
{"description": "Servicio", "quantity": 1, "unitPrice": 100, "taxRate": 21}
]
},
"client": {
"name": "CLIENTE SL",
"vatNumber": "B98765432"
},
"emisor": {
"nif": "53950250R",
"nombre": "EMPRESA EJEMPLO SL"
},
"sistema": {
"nombre": "Mi Sistema",
"nif_proveedor": "53950250R",
"version": "1.0"
}
}
```
Ver [formatos.md](formatos.md) para detalles de cada formato y cómo añadir nuevos.
**Response (AEAT disponible):**
```json
{
"success": true,
"csv": "A-FSZKDA8UG7WD9U",
"estado": "Correcto"
}
```
**Response (fallback local):**
```json
{
"success": true,
"csv": "0CE5F940CEA...",
"estado": "Correcto (local)"
}
```
> **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
```
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).
**Request:**
```json
{
"tipo": "anulacion",
"factura": {
"emisor_nif": "53950250R",
"emisor_nombre": "JOSEP VICENT MESTRE LLOBELL",
"num_serie": "FV2026/001",
"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ódigo | Descripción |
|--------|------------|
| `certificate_expired` | El certificado ha expirado |
| `certificate_not_yet_valid` | El certificado aún no es válido |
| `certificate_expiring_soon` | El certificado caduca en menos de 30 días |
| `invalid_password_or_format` | Contraseña incorrecta o formato inválido |
| `file_not_found` | El archivo de certificado no existe |
| `validation_failed` | Los datos de la factura no son válidos |
| `aeat_error` | Error comunicando con la AEAT |
| `aeat_fault` | Error SOAP de la AEAT |
| `hash_save_error` | Error guardando el hash local |
| `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

@ -0,0 +1,85 @@
# Arquitectura
## Visión General
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Cliente │────▶│ API REST │────▶│ AEAT │
│ (App) │ │ (this) │ │ VeriFactu │
└─────────────┘ └──────────────┘ └─────────────┘
```
## Capas
### 1. API Layer (`api/`)
- **handler.go**: Handlers HTTP para endpoints
- **router.go**: Registro de rutas
### 2. Business Logic (`internal/`)
- **factura.go**: Servicio de facturas (alta, anulación)
- **formats/**: Sistema de formatos de entrada con detección automática
- **registry.go**: Registro y auto-detección de formatos
- **native/**: Formato nativo de la API
- **dolibarr/**: Formato compatible con Dolibarr BFF
- **transformer.go**: Transformación JSON → datos de factura
- **hash.go**: Cálculo de hash encadenado
- **models.go**: Modelos y validación de entrada
- **config/**: Carga de configuración
- **cert/**: Gestión de certificados
- **crypto/**: Criptografía RSA
### 3. External Communication (`verifactu/`)
- **client.go**: Cliente SOAP para AEAT
- **soap.go**: Construcción de mensajes SOAP
- **xml.go**: Generación XML VeriFactu
## Flujo de Datos
```
Request HTTP
JSON Input
TransformAuto() ──▶ Detecta formato (native, dolibarr, ...)
Transformer.Transform() ──▶ TransformResult
CalculateHash(prevHash)
BuildSOAPRequest()
SendToAEAT()
Response
```
## Cifrado de Contraseñas
El cliente envía la contraseña del certificado cifrada con la clave pública RSA de la API:
```
1. GET /api/v1/auth/public-key → Obtener clave pública
2. RSA encrypt(password) → Cifrar contraseña
3. POST /api/v1/auth/register → Registrar cert con password cifrada
```
## Tokens
Al registrar un certificado se genera un token que identifica la sesión:
```json
{
"success": true,
"cert": {...},
"token": "A1B2C3D4E5F6..."
}
```
Este token se usa en requests de facturas para identificar el certificado a usar.

View File

@ -0,0 +1,142 @@
# Certificado Digital para VeriFactu
## Introducción
VeriFactu requiere autenticación mTLS: la API presenta tu certificado al conectarse con la AEAT. Tanto en pruebas como en producción se usa el mismo certificado real — no existe un certificado especial de pruebas.
## Certificado válido: FNMT Persona Física
El **Certificado de Ciudadano (Persona Física) de la FNMT** es gratuito y válido tanto para el entorno de pruebas como para producción.
**Obtención gratuita — opciones:**
1. **Presencial en oficina AEAT** (recomendado): llevar el DNI a cualquier delegación de la Agencia Tributaria. Trámite en 2 minutos, sin cita previa en la mayoría.
2. **Con DNIe + NFC**: si tu DNI es posterior a 2015, puedes hacer todo el proceso online desde `sede.fnmt.gob.es` usando el chip NFC del DNI con tu móvil o un lector de tarjetas.
3. **Videollamada** (coste 2,99 € + IVA): servicio de identificación remota sin desplazamiento.
> La renovación online es gratuita mientras el certificado esté vigente (hasta 60 días antes de su vencimiento).
## Verificar el certificado (PowerShell)
```powershell
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(".\data\certs\personal.p12", "TU_CONTRASEÑA")
Write-Host "Válido desde:" $cert.NotBefore
Write-Host "Válido hasta:" $cert.NotAfter
Write-Host "Asunto:" $cert.Subject
Write-Host "Vigente:" ($cert.NotAfter -gt (Get-Date))
```
El campo `Subject` debe contener `OU=CIUDADANOS` (persona física) y el NIF del titular.
## Registrar Nuevo Certificado
Una vez tengas el archivo .p12:
### 1. Copiar a una ubicación segura
```bash
cp micertificado.p12 ./data/certs/
```
### 2. Actualizar config.yml
```yaml
certificates:
storage_path: ./data/certs/
cert_file: ./data/certs/nuevo_cert.p12
cert_password: TU_CONTRASEÑA
```
### 3. Convertir certificado
La API necesita el certificado en formato PEM:
```bash
python convert_cert.py ./data/certs/nuevo_cert.p12 TU_CONTRASEÑA
```
Esto genera:
- `cert_key.pem` (clave privada)
- `cert_cert.pem` (certificado público)
### 4. Reiniciar API
```bash
# Detener API
taskkill /F /IM main.exe
# Iniciar
go run main.go
```
### 5. Probar
```bash
python test_invoice.py
```
## Estructura de Directorios
```
VerifactuMidAPI/
├── data/
│ └── certs/
│ ├── personal.p12 # Tu certificado
│ ├── cert_key.pem # Generado automáticamente
│ └── cert_cert.pem # Generado automáticamente
└── config.yml # Configuración
```
## 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"
- El certificado no está autorizado en el entorno de pruebas
- O el certificado es de producción y estás en test
### "Certificate expired"
- El certificado ha caducado
- Solicitar uno nuevo
### "Invalid password"
- La contraseña del .p12 es incorrecta
### "tls: failed to find any PEM data"
- Error al convertir el certificado
- Ejecutar `python convert_cert.py` manualmente para ver el error
## Siguientes Pasos
1. Obtener certificado de pruebas FNMT
2. Registrar en la API
3. Probar con `python test_invoice.py`
4. Verificar que devuelve estado "Correcto" (no "Correcto (local)")
## Recursos
- FNMT: https://www.fnmt.es
- Certificados válidos VeriFactu: https://www.sede.fnmt.gob.es/preguntas-frecuentes/certificado-de-representante/-/asset_publisher/eIal9z2VE0Kb/content/certificados-electr%C3%B3nicos-v%C3%A1lidos-para-el-sistema-veri*factu
- Certificado entidad sin personalidad jurídica: https://www.sede.fnmt.gob.es/certificados/certificado-de-representante/entidad-sin-personalidad-juridica
- Portal AEAT Pruebas: https://preportal.aeat.es
- Sede AEAT: https://sede.agenciatributaria.gob.es

View File

@ -0,0 +1,90 @@
# Configuración
## Fichero config.yml
```yaml
server:
port: 6789
verifactu:
production: false
certificates:
storage_path: ./data/certs/
crypto:
keys_path: ./keys/
name: "VeriFactu API"
email: "admin@ejemplo.com"
```
## Explicación de Parametros
### server.port
Puerto TCP donde escucha la API.
### verifactu.production
- `false`: Entorno de testing (prewww2.aeat.es)
- `true`: Entorno de producción (www2.agenciatributaria.gob.es)
### certificates.storage_path
Directorio donde se almacenan los certificados registrados.
### crypto.keys_path
Directorio donde se almacenan las claves RSA.
### crypto.name
Nombre para generar nuevas claves RSA (si no existen).
### crypto.email
Email para generar nuevas claves RSA.
## Variables de Entorno
```bash
# Sobrescribir configuración
VERIFACTU_PORT=8080
VERIFACTU_PRODUCTION=true
```
## Estructura de Directorios
```
proyecto/
├── api/
├── internal/
├── verifactu/
├── documentacion/
├── test/
├── data/
│ └── certs/ # Certificados registrados
├── keys/ # Claves RSA
├── config.yml # Configuración
├── validate_cert.ps1 # Script de validación
└── main.go
```
## Primera Ejecución
1. Compilar: `go build -o verifactu-api.exe ./main.go`
2. Ejecutar: `./verifactu-api.exe`
3. La API genera claves RSA automaticamente
4. Crea estructura de directorios
## Cambio de Entorno
Para cambiar de test a producción:
1. Editar `config.yml`:
```yaml
verifactu:
production: true
```
2. Reiniciar la API
## Puertos
- 6789: Puerto por defecto
- 443: HTTPS producción
- 80: HTTP (no recomendado)

View File

@ -0,0 +1,72 @@
# Formatos de Datos
## NIF (Número de Identificación Fiscal)
### Personas Físicas
- 8 dígitos + 1 letra final
- Ejemplo: `53950250R`
```
^[A-Z0-9]\d{7}[A-Z]$
```
### CIF (Identificación Fiscal Empresas)
- 1 letra + 8 dígitos + 1 letra
- Ejemplo: `A12345678`
## Fechas
Formato nativo: `dd-mm-yyyy`
Ejemplo: `17-04-2026`
El formato Dolibarr acepta fechas ISO 8601 (`2024-09-13T00:00:00Z`) que se convierten automáticamente.
## Tipos de Factura
| Código | Descripción |
|--------|------------|
| F1 | Factura completa |
| F2 | Factura simplificada (ticket) |
| R1 | Rectificativa por diferencial |
| R2 | Rectificativa por sustitución |
| R3 | Rectificativa por descuento |
| R4 | Rectificativa por devolución |
| R5 | Rectificativa por otros motivos |
## Sistema Informático
| Campo | Descripción | Ejemplo |
|-------|------------|--------|
| Nombre | Nombre del sistema | Mi ERP |
| NIFProveedor | NIF del proveedor | 53950250R |
| Version | Versión del software | 1.0 |
| NombreSistema | Nombre técnico | Mi-ERP-v1 |
| NumeroInstalacion | Número de instalación | 1 |
| TipoUsoVerifactu | Tipo de uso VeriFactu | S |
## IVA
Cada entrada de IVA:
| Campo | Descripción |
|-------|------------|
| Base | Base imponible |
| Cuota | Cuota IVA |
| Tipo | Porcentaje (21.0, 10.0, 4.0) |
| ClaveRegimen | Clave de régimen (01=general) |
| Calificacion | Calificación (S1=sin inversa) |
## Formatos de Entrada
La API detecta automáticamente el formato. Ver [formatos.md](formatos.md) para la lista completa y cómo añadir nuevos.
## Hash Encadenado
Cada factura incluye el hash SHA-256 de la anterior:
```go
hashactual = SHA256(datos_factura + hash_anterior)
```
Esto crea una cadena inmutable de facturas.

View File

@ -0,0 +1,131 @@
# Formatos de Entrada
La API detecta automáticamente el formato de entrada. No hay que indicarlo.
## Endpoint
```
POST /api/v1/facturas → Formato detectado automáticamente
GET /api/v1/formats → Lista formatos soportados
```
---
## Formato: `native`
Formato propio de la API. Se detecta por la presencia del campo `factura`.
```json
{
"tipo": "alta",
"factura": {
"emisor_nif": "A12345678",
"emisor_nombre": "EMPRESA EJEMPLO SL",
"num_serie": "2024-001",
"fecha_expedicion": "13-09-2024",
"tipo_factura": "F1",
"descripcion": "Servicios de consultoría",
"destinatario": {
"nombre": "CLIENTE SL",
"nif": "B98765432"
},
"iva": [
{"base": 100.00, "cuota": 21.00, "tipo": 21.00}
],
"importe_total": 121.00
},
"sistema": {
"nombre": "Mi Software",
"nif_proveedor": "A12345678",
"version": "1.0.0"
}
}
```
---
## 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`.
```json
{
"invoice": {
"number": "FA2024/001",
"date": "2024-09-13T00:00:00Z",
"notePublic": "Servicios de consultoría",
"lines": [
{"description": "Servicio A", "quantity": 1, "unitPrice": 60, "taxRate": 21},
{"description": "Servicio B", "quantity": 2, "unitPrice": 40, "taxRate": 10}
]
},
"client": {
"name": "CLIENTE SL",
"vatNumber": "B98765432"
},
"emisor": {
"nif": "A12345678",
"nombre": "EMPRESA EJEMPLO SL"
},
"sistema": {
"nombre": "Mi Software",
"nif_proveedor": "A12345678",
"version": "1.0.0"
}
}
```
### Mapeo Dolibarr → VeriFactu
| Dolibarr | VeriFactu |
|---|---|
| `invoice.number` | `num_serie` |
| `invoice.date` | `fecha_expedicion` (ISO → dd-mm-yyyy) |
| `invoice.notePublic` | `descripcion` |
| `lines[].quantity × unitPrice` | calcula `base = total / (1 + rate/100)`, `cuota = total - base` |
| `lines[].taxRate` | agrupa por tipo → `iva[].tipo` |
| `client.name` | `destinatario.nombre` |
| `client.vatNumber` | `destinatario.nif` |
| suma de líneas | `importe_total` (calculado automáticamente) |
| `emisor.nif` | `emisor_nif` |
| `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
1. Crear carpeta `internal/formats/miformato/format.go`
2. Implementar la interfaz `formats.Transformer`:
```go
package miformato
import (
"encoding/json"
"VerifactuMidAPI/internal/formats"
)
func init() {
formats.Register(&Transformer{})
}
type Transformer struct{}
func (t *Transformer) Name() string { return "miformato" }
func (t *Transformer) Transform(raw json.RawMessage) (*formats.TransformResult, error) {
// Parsear JSON de entrada
// Validar
// Devolver TransformResult
}
```
3. Importar en `main.go`:
```go
import _ "VerifactuMidAPI/internal/formats/miformato"
```
La detección es automática: el primer formato que pueda parsear el JSON se usa.

View File

@ -0,0 +1,93 @@
# Seguridad y Certificados
## Certificados Digitales
### Requisitos
- Certificado cualificado de firma electrónica (eIDAS)
- Formato **.p12** o **.pfx**
- Contraseña válida
### Validación
La API valida nativamente (sin scripts externos):
1. **Formato PKCS#12 válido**
2. **Contraseña correcta**
3. **Fechas de validez** (no expirado, no futuro)
4. **Días hasta expiración**
### Almacenamiento
El certificado se envía como base64 en el JSON de registro:
```json
{
"cert_name": "mi-cert",
"cert_file": "BASE64_P12_CONTENT",
"password_encrypted": "..."
}
```
1. El cliente envía el certificado como base64
2. Se valida el PKCS#12 nativamente en Go
3. Se guarda en `data/certs/<cert_name>.p12`
4. Se genera un token de sesión
## Cifrado RSA
### Porqué RSA
- Las contraseñas de certificados no se envían en texto plano
- El cliente cifra la contraseña con la clave pública de la API
- Solo la API puede descifrarla (tiene la clave privada)
### Proceso
```
1. API genera par de claves RSA al inicio
2. Cliente pide /api/v1/auth/public-key
3. Cliente cifra password con clave pública
4. Cliente envía cifrada
5. API descifra con clave privada
```
### Generación de Claves
Las claves RSA se generan automáticamente en `./keys/`:
- `private.pem`: Clave privada (nunca expuesta)
- `public.pem`: Clave pública
## HTTPS
**Importante**: En producción, la API debe usar HTTPS.
```yaml
# config.yml
server:
port: 443
cert: ./ssl/server.crt
key: ./ssl/server.key
```
## Variables de Entorno
```bash
VERIFACTU_ENV=test # test o production
VERIFACTU_CERT_PATH=... # path al certificado
```
## Rate Limiting
No implementado actualmente, pero recomendado para producción.
## Logs
Los logs contienen:
- Requests recibidos
- Errores de validación
- Respuestas de AEAT (sin información sensible)
No contienen:
- Contraseñas
- Tokens
- Contenido de certificados

View File

@ -0,0 +1,102 @@
# Testing
## Tests de Certificados
Ubicación: `test/`
### Generar Certificados de Prueba
```bash
python test/generate_certs.py
```
Genera certificados en `test/certs/`:
- `valid_365days.p12` - Válido 365 días
- `valid_60days.p12` - Válido 60 días
- `expired.p12` - Expirado
- `expiring_soon.p12` - Caduca pronto
- `not_yet_valid.p12` - Aún no válido
### Ejecutar Tests
```bash
python test/run_tests.py
```
Expected output:
```
# Test Expected Result Status
------------------------------------------------------------
1 Valid 365 days PASS PASS [PASS]
2 Valid 60 days PASS PASS [PASS]
3 Expired FAIL FAIL [PASS]
4 Expiring soon PASS PASS [PASS]
5 Not yet valid FAIL FAIL [PASS]
------------------------------------------------------------
RESULTS: 5 passed, 0 failed
```
## Test de Facturas
### Factura de Ejemplo
Ubicación: `test/invoice.json`
```bash
python test_invoice.py
```
### Flujo Completo
1. **Iniciar API:**
```bash
go run main.go
```
2. **Registrar certificado:**
Ver `test_personal.py`
3. **Enviar factura:**
```bash
python test_invoice.py
```
Expected (fallback local):
```json
{
"success": true,
"csv": "0CE5F940CEA...",
"estado": "Correcto (local)"
}
```
## Depuración
### Ver Logs
Ejecutar API desde terminal:
```bash
go run main.go
```
### Limpiar Datos
```bash
# Eliminar certificados
Remove-Item -Recurse ./data/certs/*
# Eliminar hashes
Remove-Item -Recurse ./data/*
```
## Certificados Personales
Para usar tu certificado:
1. Copiar a `data/certs/personal.p12`
2. Ejecutar `test_personal.py`
3. Contraseña se envía cifrada con RSA público de la API
## Simulación
`test/simulate.py` contiene herramientas de simulación para testing sin AEAT real.

View File

@ -0,0 +1,82 @@
# Sistema de Tokens
## Descripción
El sistema de tokens permite autenticar las requests de facturas sin necesidad de pasar la contraseña del certificado en cada request.
## Flujo de Uso
### 1. Obtener Clave Pública
```bash
curl http://localhost:6789/api/v1/auth/public-key
```
Response:
```json
{"public_key": "base64..."}
```
### 2. Descifrar clave pública
El cliente debe descifrar la clave pública RSA (codificada en base64) y usarla para cifrar la contraseña.
### 3. Registrar Certificado
```bash
curl -X POST http://localhost:6789/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"cert_name": "personal",
"cert_path": "C:/ruta/al/cert.p12",
"password_encrypted": "base64_cifrada"
}'
```
Response:
```json
{
"success": true,
"cert": {
"subject": "...",
"days_until_expiry": 816
},
"token": "A1B2C3D4E5F6..."
}
```
### 4. Usar Token en Facturas (futuro)
El token se pasados en el header `Authorization`:
```bash
curl -X POST http://localhost:6789/api/v1/facturas \
-H "Content-Type: application/json" \
-H "Authorization: Bearer A1B2C3D4E5F6..." \
-d '{...}'
```
## Almacenamiento
Los tokens se almacenan en memoria (`map[string]*Certificate`) junto con:
- ID del certificado
- Ruta al archivo .p12
- Contraseña descifrada
## Seguridad
- Los tokens son strings aleatorios de 32 bytes codificados en hex mayúscula
- Solo se almacena en memoria (se pierde al reiniciar la API)
- La contraseña nunca se expone en responses
- El token permite ejecutar operaciones con el certificado registrado
## Consideraciones
1. **Sesiones efímeras**: Al reiniciar la API se pierden los tokens
2. **Un token por certificado**: Si registras el mismo cert, se genera nuevo token
3. **Varios certificados**: Se puede registrar más de un certificado, cada uno con su token
## Estado Actual
Actualmente los tokens se generan pero no se usan en las requests de facturas. El sistema usa el certificado registrado directamente.

View File

@ -0,0 +1,109 @@
# VeriFactu - Protocolo AEAT
## Qué es VeriFactu
Sistema mandatory de facturación electrónica de la AEAT (Agencia Estatal de Administración Tributaria) de España.
## Obligatoriedad
A partir de cierta fecha, todas las facturas emitidas deben registrarse en VeriFactu, independientemente del formato (digital).
## Operaciones
### Alta
Registrar una factura nueva en el sistema.
**Tipos de factura:**
- F1: Factura completa
- F2: Factura simplificada (ticket)
- R1-R5: Rectificativas
### Anulación
Cancelar una factura previamente registrada.
### Subsanación
Corregir errores en facturas ya registradas.
### Consulta
Buscar facturas previamente enviadas.
## Encadenamiento de Hash
Cada factura incluye el hash de la anterior. El formato exacto validado por la AEAT es:
```
SHA256("IDEmisorFactura=NIF&NumSerieFactura=SERIE&FechaExpedicionFactura=dd-mm-yyyy&TipoFactura=F1&CuotaTotal=21.00&ImporteTotal=121.00&Huella=HASH_ANTERIOR&FechaHoraHusoGenRegistro=yyyy-mm-ddThh:mm:ss+hh:mm")
```
Para el primer registro de un emisor, `Huella` se deja vacío y en el XML se envía `<PrimerRegistro>S</PrimerRegistro>`. Para el resto, se envía `<RegistroAnterior>` con los datos de la factura anterior (nunca ambos a la vez).
## URLs
### Testing (Preproducción)
```
https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP
```
### Producción
```
https://www1.agenciatributaria.gob.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP
```
## Formato
SOAP 1.1 sobre HTTPS con mTLS (certificado cliente cualificado). Namespaces requeridos:
- `xmlns:sum``https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd`
- `xmlns:sum1``https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd`
Campos específicos a tener en cuenta:
- `TipoHuella`: valor `01` (no `SHA-256`)
- `FechaHoraHusoGenRegistro`: formato ISO 8601 con huso horario (`yyyy-mm-ddThh:mm:ss+hh:mm`)
- `IdSistemaInformatico`: 2 caracteres (`01`, no `1`)
- `Encadenamiento`: usa `xs:choice` — o `PrimerRegistro` o `RegistroAnterior`, nunca los dos
## CSV
Código de Verificación asignado por la AEAT con formato `A-XXXXXXXXXXXXX`. Acredita el registro de la factura en Hacienda. Cuando la API opera en modo fallback local devuelve el hash SHA-256 en su lugar y el estado indica `Correcto (local)`.
## Fallback Local
Cuando AEAT no está disponible (error de red, servidor caído, etc), la API guarda la factura localmente:
- Genera el hash localmente
- Guarda en `./data/` (hash storage)
- Devuelve el hash como CSV temporal
- Estado: "Correcto (local)"
Esto permite seguir operando sin conexión a AEAT.
## Certificado Cliente
La AEAT requiere certificado cliente para aceptar requests (mTLS):
```yaml
certificates:
storage_path: ./data/certs/
cert_file: ./data/certs/personal.p12
cert_password: tu_contraseña
```
Sin certificado válido, la API cae en fallback local.
## Datos Obligatorios
- NIF del emisor
- Número de serie
- Fecha de expedición
- Tipo de factura
- Base imponible IVA
- Cuota IVA
- Importe total
## Límites
- Máximo 1000 facturas por request
- Rate limit: consultar documentación AEAT

9
VerifactuMidAPI/go.mod Normal file
View File

@ -0,0 +1,9 @@
module VerifactuMidAPI
go 1.26
require (
golang.org/x/crypto v0.52.0
gopkg.in/yaml.v3 v3.0.1
software.sslmate.com/src/go-pkcs12 v0.7.1
)

8
VerifactuMidAPI/go.sum Normal file
View File

@ -0,0 +1,8 @@
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8=
software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@ -0,0 +1,218 @@
package cert
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
type Storage struct {
basePath string
certs map[string]*Certificate
mu sync.RWMutex
}
type Certificate struct {
ID string `json:"id"`
StoredPath string `json:"stored_path"`
Token string `json:"token,omitempty"`
}
type TokenData struct {
Token string
CertID string
StoredPath string
Password string
}
func NewStorage(basePath string) *Storage {
if basePath == "" {
basePath = "./certs/"
}
return &Storage{
basePath: basePath,
certs: make(map[string]*Certificate),
}
}
func (s *Storage) Init() error {
if err := os.MkdirAll(s.basePath, 0700); err != nil {
return fmt.Errorf("creating cert storage directory: %w", err)
}
return s.loadFromDisk()
}
func (s *Storage) loadFromDisk() error {
entries, err := os.ReadDir(s.basePath)
if err != nil {
return nil
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := filepath.Ext(entry.Name())
if ext != ".p12" && ext != ".pfx" {
continue
}
id := entry.Name()[:len(entry.Name())-len(ext)]
storedPath := filepath.Join(s.basePath, entry.Name())
s.certs[id] = &Certificate{
ID: id,
StoredPath: storedPath,
}
}
return nil
}
func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) {
tmpDir := filepath.Join(s.basePath, "tmp")
if err := os.MkdirAll(tmpDir, 0700); err != nil {
return "", fmt.Errorf("creating tmp directory: %w", err)
}
der, err := base64.StdEncoding.DecodeString(base64Content)
if err != nil {
return "", fmt.Errorf("invalid base64: %w", err)
}
storedPath := filepath.Join(tmpDir, id+".p12")
if err := os.WriteFile(storedPath, der, 0600); err != nil {
return "", fmt.Errorf("writing certificate: %w", err)
}
return storedPath, nil
}
func (s *Storage) MoveToPerm(id, tempPath string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
storedPath := filepath.Join(s.basePath, id+".p12")
if _, err := os.Stat(storedPath); err == nil {
if err := os.Remove(storedPath); err != nil {
return "", fmt.Errorf("removing existing certificate: %w", err)
}
}
if err := os.Rename(tempPath, storedPath); err != nil {
return "", fmt.Errorf("moving certificate: %w", err)
}
s.certs[id] = &Certificate{
ID: id,
StoredPath: storedPath,
}
return storedPath, nil
}
func (s *Storage) DeleteTemp(tempPath string) error {
if err := os.Remove(tempPath); err != nil {
return fmt.Errorf("deleting temp certificate: %w", err)
}
return nil
}
func (s *Storage) Get(id string) (*Certificate, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cert, ok := s.certs[id]
if !ok {
return nil, fmt.Errorf("certificate not found")
}
return cert, nil
}
func (s *Storage) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
cert, ok := s.certs[id]
if !ok {
return fmt.Errorf("certificate not found")
}
if err := os.Remove(cert.StoredPath); err != nil {
return fmt.Errorf("removing certificate file: %w", err)
}
delete(s.certs, id)
return nil
}
func (s *Storage) List() []*Certificate {
s.mu.RLock()
defer s.mu.RUnlock()
certs := make([]*Certificate, 0, len(s.certs))
for _, cert := range s.certs {
certs = append(certs, cert)
}
return certs
}
func (s *Storage) Hash(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
func (s *Storage) generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating token: %w", err)
}
return strings.ToUpper(hex.EncodeToString(b)), nil
}
func (s *Storage) GenerateToken(id, storedPath, password string) (*TokenData, error) {
s.mu.Lock()
defer s.mu.Unlock()
token, err := s.generateToken()
if err != nil {
return nil, err
}
cert, ok := s.certs[id]
if !ok {
return nil, fmt.Errorf("certificate not found")
}
cert.Token = token
return &TokenData{
Token: token,
CertID: id,
StoredPath: storedPath,
Password: password,
}, nil
}
func (s *Storage) GetByToken(token string) (*TokenData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, cert := range s.certs {
if cert.Token == token {
return &TokenData{
Token: cert.Token,
CertID: cert.ID,
StoredPath: cert.StoredPath,
}, nil
}
}
return nil, fmt.Errorf("token not found")
}

View File

@ -0,0 +1,42 @@
import sys
import datetime
import json
from cryptography import x509
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.hazmat.backends import default_backend
try:
cert_path = sys.argv[1]
password = sys.argv[2].encode()
with open(cert_path, "rb") as f:
p12_data = f.read()
private_key, cert, additional_certs = pkcs12.load_key_and_certificates(
p12_data, password, default_backend()
)
now = datetime.datetime.now(datetime.timezone.utc)
not_after = cert.not_valid_after_utc.replace(tzinfo=datetime.timezone.utc)
not_before = cert.not_valid_before_utc.replace(tzinfo=datetime.timezone.utc)
if now > not_after:
print("EXPIRED")
sys.exit(1)
if now < not_before:
print("NOT_YET_VALID")
sys.exit(2)
days_until = (not_after - now).days
result = {
"subject": cert.subject.rfc4514_string(),
"issuer": cert.issuer.rfc4514_string(),
"not_after": not_after.isoformat(),
"days": days_until
}
print("VALID:" + str(days_until))
print(json.dumps(result))
except Exception as e:
print("ERROR:" + str(e))
sys.exit(3)

View File

@ -0,0 +1,213 @@
package cert
import (
"crypto/rsa"
"crypto/x509"
"math"
"os"
"time"
goPkcs12 "software.sslmate.com/src/go-pkcs12"
stdPkcs12 "golang.org/x/crypto/pkcs12"
)
type ValidationResult struct {
Valid bool `json:"valid"`
CertInfo *CertInfo `json:"cert_info,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Error string `json:"error,omitempty"`
}
type CertInfo struct {
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
Expired bool `json:"expired"`
ExpiringSoon bool `json:"expiring_soon"`
DaysUntilExpiry int `json:"days_until_expiry"`
}
const WarningDaysThreshold = 30
func ValidateP12(filePath, password string) *ValidationResult {
p12Data, err := os.ReadFile(filePath)
if err != nil {
result := &ValidationResult{Valid: true}
if os.IsNotExist(err) {
result.Valid = false
result.Error = "file_not_found"
return result
}
result.Valid = false
result.Error = "invalid_password_or_format"
return result
}
return ValidateP12Bytes(p12Data, password)
}
// ValidateP12Bytes valida un P12 a partir de sus bytes (util para tests sin fichero).
// Soporta tanto P12 simples (go-pkcs12) como P12 con cadena de certificados (x/crypto/pkcs12).
func ValidateP12Bytes(p12Data []byte, password string) *ValidationResult {
result := &ValidationResult{Valid: true}
x509Cert, err := decodeLeafCert(p12Data, password)
if err != nil {
result.Valid = false
result.Error = "invalid_password_or_format"
return result
}
now := time.Now()
if now.Before(x509Cert.NotBefore) {
result.Valid = false
result.Error = "certificate_not_yet_valid"
return result
}
if now.After(x509Cert.NotAfter) {
result.Valid = false
result.Error = "certificate_expired"
result.CertInfo = &CertInfo{
Subject: x509Cert.Subject.CommonName,
Issuer: x509Cert.Issuer.CommonName,
NotBefore: x509Cert.NotBefore.Format("2006-01-02"),
NotAfter: x509Cert.NotAfter.Format("2006-01-02"),
Expired: true,
}
return result
}
daysUntilExpiry := int(math.Ceil(x509Cert.NotAfter.Sub(now).Hours() / 24))
result.CertInfo = &CertInfo{
Subject: x509Cert.Subject.CommonName,
Issuer: x509Cert.Issuer.CommonName,
NotBefore: x509Cert.NotBefore.Format("2006-01-02"),
NotAfter: x509Cert.NotAfter.Format("2006-01-02"),
DaysUntilExpiry: daysUntilExpiry,
}
if daysUntilExpiry <= WarningDaysThreshold {
result.Warnings = append(result.Warnings, "certificate_expiring_soon")
result.CertInfo.ExpiringSoon = true
}
return result
}
// decodeLeafCert extrae el certificado hoja del P12.
// Intenta primero go-pkcs12 (P12 simples) y luego x/crypto/pkcs12 (P12 con cadena FNMT/ACCV).
func decodeLeafCert(p12Data []byte, password string) (*x509.Certificate, error) {
// Intento 1: go-pkcs12 — soporta P12 simples (1 clave + 1 cert)
_, cert, err := goPkcs12.Decode(p12Data, password)
if err == nil {
return cert, nil
}
// Intento 2: x/crypto/pkcs12 — soporta P12 con cadena de certificados (FNMT, ACCV, AEAT)
blocks, err := stdPkcs12.ToPEM(p12Data, password)
if err != nil {
return nil, err
}
var privateKey *rsa.PrivateKey
var certs []*x509.Certificate
for _, b := range blocks {
switch b.Type {
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(b.Bytes)
if err != nil {
key2, err2 := x509.ParsePKCS1PrivateKey(b.Bytes)
if err2 == nil {
privateKey = key2
}
} else if rsaKey, ok := key.(*rsa.PrivateKey); ok {
privateKey = rsaKey
}
case "CERTIFICATE":
cert, err := x509.ParseCertificate(b.Bytes)
if err == nil {
certs = append(certs, cert)
}
}
}
if privateKey == nil || len(certs) == 0 {
return nil, stdPkcs12.ErrIncorrectPassword
}
for _, c := range certs {
if rsaPub, ok := c.PublicKey.(*rsa.PublicKey); ok {
if rsaPub.N.Cmp(privateKey.PublicKey.N) == 0 {
return c, nil
}
}
}
return certs[0], nil
}
// DecodeLeafAndKey extrae la clave privada y el certificado hoja de un P12.
// Usado por el cliente TLS para establecer conexiones mTLS.
func DecodeLeafAndKey(p12Data []byte, password string) (interface{}, *x509.Certificate, []*x509.Certificate, error) {
key, cert, err := goPkcs12.Decode(p12Data, password)
if err == nil {
return key, cert, nil, nil
}
blocks, err := stdPkcs12.ToPEM(p12Data, password)
if err != nil {
return nil, nil, nil, err
}
var privateKey interface{}
var leafCert *x509.Certificate
var chain []*x509.Certificate
for _, b := range blocks {
switch b.Type {
case "PRIVATE KEY":
k, err := x509.ParsePKCS8PrivateKey(b.Bytes)
if err != nil {
k2, err2 := x509.ParsePKCS1PrivateKey(b.Bytes)
if err2 == nil {
privateKey = k2
}
} else {
privateKey = k
}
case "CERTIFICATE":
c, err := x509.ParseCertificate(b.Bytes)
if err == nil {
chain = append(chain, c)
}
}
}
if privateKey == nil || len(chain) == 0 {
return nil, nil, nil, stdPkcs12.ErrIncorrectPassword
}
if rsaKey, ok := privateKey.(*rsa.PrivateKey); ok {
for i, c := range chain {
if rsaPub, ok := c.PublicKey.(*rsa.PublicKey); ok {
if rsaPub.N.Cmp(rsaKey.PublicKey.N) == 0 {
leafCert = c
chain = append(chain[:i], chain[i+1:]...)
break
}
}
}
}
if leafCert == nil {
leafCert = chain[0]
chain = chain[1:]
}
return privateKey, leafCert, chain, nil
}

View File

@ -0,0 +1,201 @@
package cert
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"testing"
"time"
"software.sslmate.com/src/go-pkcs12"
)
const testPassword = "test-password-123"
// generarP12 crea un certificado autofirmado y lo empaqueta en P12.
// notBefore y notAfter controlan la validez del certificado.
func generarP12(t *testing.T, cn string, notBefore, notAfter time.Time) []byte {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generando clave RSA: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: cn},
NotBefore: notBefore,
NotAfter: notAfter,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("creando certificado: %v", err)
}
x509Cert, err := x509.ParseCertificate(certDER)
if err != nil {
t.Fatalf("parseando certificado: %v", err)
}
p12Data, err := pkcs12.Modern.Encode(key, x509Cert, nil, testPassword)
if err != nil {
t.Fatalf("codificando P12: %v", err)
}
return p12Data
}
func TestValidateP12Bytes_CertificadoValido365Dias(t *testing.T) {
now := time.Now()
p12 := generarP12(t, "Valid 365 days", now.Add(-time.Hour), now.Add(365*24*time.Hour))
result := ValidateP12Bytes(p12, testPassword)
if !result.Valid {
t.Errorf("esperado válido, obtuvo error: %s", result.Error)
}
if result.Error != "" {
t.Errorf("no debería haber error, obtuvo: %s", result.Error)
}
if len(result.Warnings) > 0 {
t.Errorf("no debería haber warnings para cert de 365 días, obtuvo: %v", result.Warnings)
}
if result.CertInfo == nil {
t.Fatal("CertInfo no debería ser nil para cert válido")
}
if result.CertInfo.DaysUntilExpiry < 360 {
t.Errorf("días hasta expiración: esperado ~365, obtuvo %d", result.CertInfo.DaysUntilExpiry)
}
}
func TestValidateP12Bytes_CertificadoValido60Dias(t *testing.T) {
now := time.Now()
p12 := generarP12(t, "Valid 60 days", now.Add(-time.Hour), now.Add(60*24*time.Hour))
result := ValidateP12Bytes(p12, testPassword)
if !result.Valid {
t.Errorf("esperado válido, obtuvo error: %s", result.Error)
}
if len(result.Warnings) > 0 {
t.Errorf("no debería haber warnings para cert de 60 días, obtuvo: %v", result.Warnings)
}
if result.CertInfo.DaysUntilExpiry < 55 || result.CertInfo.DaysUntilExpiry > 65 {
t.Errorf("días hasta expiración: esperado ~60, obtuvo %d", result.CertInfo.DaysUntilExpiry)
}
}
func TestValidateP12Bytes_CertificadoExpirado(t *testing.T) {
now := time.Now()
p12 := generarP12(t, "Expired", now.Add(-20*24*time.Hour), now.Add(-5*24*time.Hour))
result := ValidateP12Bytes(p12, testPassword)
if result.Valid {
t.Error("esperado inválido para certificado expirado")
}
if result.Error != "certificate_expired" {
t.Errorf("error esperado: certificate_expired, obtuvo: %s", result.Error)
}
if result.CertInfo == nil || !result.CertInfo.Expired {
t.Error("CertInfo.Expired debería ser true")
}
}
func TestValidateP12Bytes_CertificadoCaducaProximamente(t *testing.T) {
now := time.Now()
p12 := generarP12(t, "Expiring Soon", now.Add(-time.Hour), now.Add(15*24*time.Hour))
result := ValidateP12Bytes(p12, testPassword)
if !result.Valid {
t.Errorf("esperado válido con warning, obtuvo error: %s", result.Error)
}
if len(result.Warnings) == 0 {
t.Error("esperado warning certificate_expiring_soon para cert que caduca en 15 días")
}
if len(result.Warnings) > 0 && result.Warnings[0] != "certificate_expiring_soon" {
t.Errorf("warning esperado: certificate_expiring_soon, obtuvo: %s", result.Warnings[0])
}
if result.CertInfo == nil || !result.CertInfo.ExpiringSoon {
t.Error("CertInfo.ExpiringSoon debería ser true")
}
}
func TestValidateP12Bytes_CertificadoAunNoValido(t *testing.T) {
now := time.Now()
p12 := generarP12(t, "Not Yet Valid", now.Add(30*24*time.Hour), now.Add(395*24*time.Hour))
result := ValidateP12Bytes(p12, testPassword)
if result.Valid {
t.Error("esperado inválido para certificado aún no vigente")
}
if result.Error != "certificate_not_yet_valid" {
t.Errorf("error esperado: certificate_not_yet_valid, obtuvo: %s", result.Error)
}
}
func TestValidateP12Bytes_ContraseñaIncorrecta(t *testing.T) {
now := time.Now()
p12 := generarP12(t, "Valid Cert", now.Add(-time.Hour), now.Add(365*24*time.Hour))
result := ValidateP12Bytes(p12, "contraseña-incorrecta")
if result.Valid {
t.Error("esperado inválido con contraseña incorrecta")
}
if result.Error != "invalid_password_or_format" {
t.Errorf("error esperado: invalid_password_or_format, obtuvo: %s", result.Error)
}
}
func TestValidateP12Bytes_BytesCorruptos(t *testing.T) {
result := ValidateP12Bytes([]byte("esto no es un P12 valido"), testPassword)
if result.Valid {
t.Error("esperado inválido para bytes corruptos")
}
if result.Error != "invalid_password_or_format" {
t.Errorf("error esperado: invalid_password_or_format, obtuvo: %s", result.Error)
}
}
func TestValidateP12Bytes_UmbralWarning30Dias(t *testing.T) {
now := time.Now()
// Exactamente en el umbral: 30 días → debe dar warning
p12Umbral := generarP12(t, "At threshold", now.Add(-time.Hour), now.Add(30*24*time.Hour))
resultUmbral := ValidateP12Bytes(p12Umbral, testPassword)
if !resultUmbral.Valid {
t.Errorf("cert en umbral debe ser válido, obtuvo error: %s", resultUmbral.Error)
}
if len(resultUmbral.Warnings) == 0 {
t.Error("cert con exactamente 30 días debe tener warning")
}
// 31 días → NO debe dar warning
p12Ok := generarP12(t, "Just above threshold", now.Add(-time.Hour), now.Add(31*24*time.Hour))
resultOk := ValidateP12Bytes(p12Ok, testPassword)
if !resultOk.Valid {
t.Errorf("cert con 31 días debe ser válido, obtuvo error: %s", resultOk.Error)
}
if len(resultOk.Warnings) > 0 {
t.Error("cert con 31 días NO debe tener warning")
}
}
func TestValidateP12_ArchivoNoExiste(t *testing.T) {
result := ValidateP12("/ruta/que/no/existe.p12", testPassword)
if result.Valid {
t.Error("esperado inválido para archivo inexistente")
}
if result.Error != "file_not_found" {
t.Errorf("error esperado: file_not_found, obtuvo: %s", result.Error)
}
}

View File

@ -0,0 +1,61 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
VeriFactu VeriFactuConfig `yaml:"verifactu"`
Certificates CertificateConfig `yaml:"certificates"`
Crypto CryptoConfig `yaml:"crypto"`
}
type ServerConfig struct {
Port int `yaml:"port"`
}
type VeriFactuConfig struct {
Production bool `yaml:"production"`
Mock bool `yaml:"mock"`
}
type CertificateConfig struct {
StoragePath string `yaml:"storage_path"`
}
type CryptoConfig struct {
KeysPath string `yaml:"keys_path"`
Name string `yaml:"name"`
Email string `yaml:"email"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if !cfg.VeriFactu.Production {
cfg.VeriFactu.Production = false
}
if cfg.Certificates.StoragePath == "" {
cfg.Certificates.StoragePath = "./certs/"
}
if cfg.Crypto.KeysPath == "" {
cfg.Crypto.KeysPath = "./keys/"
}
return &cfg, nil
}

View File

@ -0,0 +1,139 @@
package crypto
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
)
const (
DefaultKeyBits = 2048
DefaultKeyDir = "./keys"
)
type KeyPair struct {
PublicKey *rsa.PublicKey
PrivateKey *rsa.PrivateKey
}
func GenerateKeyPair(bits int) (*KeyPair, error) {
priv, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, fmt.Errorf("generating RSA key: %w", err)
}
return &KeyPair{
PublicKey: &priv.PublicKey,
PrivateKey: priv,
}, nil
}
func (k *KeyPair) PublicKeyPEM() ([]byte, error) {
pubBytes, err := x509.MarshalPKIXPublicKey(k.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshaling public key: %w", err)
}
block := &pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}
return pem.EncodeToMemory(block), nil
}
func (k *KeyPair) PrivateKeyPEM() ([]byte, error) {
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k.PrivateKey)}
return pem.EncodeToMemory(block), nil
}
func LoadKeyPair(pubPath, privPath string) (*KeyPair, error) {
pubData, err := os.ReadFile(pubPath)
if err != nil {
return nil, fmt.Errorf("reading public key: %w", err)
}
privData, err := os.ReadFile(privPath)
if err != nil {
return nil, fmt.Errorf("reading private key: %w", err)
}
block, _ := pem.Decode(pubData)
if block == nil {
return nil, fmt.Errorf("invalid public key PEM")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing public key: %w", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an RSA public key")
}
block, _ = pem.Decode(privData)
if block == nil {
return nil, fmt.Errorf("invalid private key PEM")
}
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing private key: %w", err)
}
return &KeyPair{
PublicKey: rsaPub,
PrivateKey: priv,
}, nil
}
func LoadOrCreateKeyPair(keyDir string) (*KeyPair, error) {
if keyDir == "" {
keyDir = DefaultKeyDir
}
pubPath := filepath.Join(keyDir, "public.pem")
privPath := filepath.Join(keyDir, "private.pem")
if _, err := os.Stat(pubPath); err == nil {
if _, err := os.Stat(privPath); err == nil {
return LoadKeyPair(pubPath, privPath)
}
}
if err := os.MkdirAll(keyDir, 0700); err != nil {
return nil, fmt.Errorf("creating key directory: %w", err)
}
keyPair, err := GenerateKeyPair(DefaultKeyBits)
if err != nil {
return nil, err
}
pubPEM, err := keyPair.PublicKeyPEM()
if err != nil {
return nil, err
}
if err := os.WriteFile(pubPath, pubPEM, 0644); err != nil {
return nil, fmt.Errorf("saving public key: %w", err)
}
privPEM, err := keyPair.PrivateKeyPEM()
if err != nil {
return nil, err
}
if err := os.WriteFile(privPath, privPEM, 0600); err != nil {
return nil, fmt.Errorf("saving private key: %w", err)
}
return keyPair, nil
}
func Encrypt(plain []byte, pub *rsa.PublicKey) ([]byte, error) {
return rsa.EncryptPKCS1v15(rand.Reader, pub, plain)
}
func Decrypt(cipher []byte, priv *rsa.PrivateKey) ([]byte, error) {
return rsa.DecryptPKCS1v15(rand.Reader, priv, cipher)
}
func (k *KeyPair) Decrypt(cipher []byte) ([]byte, error) {
return rsa.DecryptPKCS1v15(rand.Reader, k.PrivateKey, cipher)
}

View File

@ -0,0 +1,331 @@
package internal
import (
"encoding/json"
"fmt"
"log"
"time"
"VerifactuMidAPI/internal/formats"
"VerifactuMidAPI/verifactu"
)
type FacturaService struct {
hashStorage LastRecordStorage
verifactu *verifactu.Client
}
func NewFacturaService(storage LastRecordStorage) *FacturaService {
return &FacturaService{
hashStorage: storage,
}
}
func (s *FacturaService) SetVerifactuClient(client *verifactu.Client) {
s.verifactu = client
}
func (s *FacturaService) ReloadCertificate(path, password string) error {
if s.verifactu == nil {
return fmt.Errorf("verifactu client not initialized")
}
return s.verifactu.SetCertificate(path, password)
}
type AltaOutput struct {
Success bool `json:"success"`
CSV string `json:"csv,omitempty"`
Estado string `json:"estado,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *FacturaService) ProcessAlta(raw json.RawMessage) (*AltaOutput, error) {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC: %v", r)
}
}()
result, formatName, err := formats.TransformAuto(raw)
if err != nil {
return &AltaOutput{
Success: false,
Error: "transform_error: " + err.Error(),
}, nil
}
log.Printf("[Transform] formato=%s NumSerie=%q Fecha=%q Total=%.2f IVA=%d tramos",
formatName, result.NumSerie, result.FechaExpedicion, result.ImporteTotal, len(result.IVA))
if s.verifactu != nil {
if result.EmisorNIF == "" {
nif, nombre := s.verifactu.CertSubject()
result.EmisorNIF = nif
if result.EmisorNombre == "" {
result.EmisorNombre = nombre
}
}
if result.Sistema.NIFProveedor == "" {
result.Sistema.NIFProveedor = result.EmisorNIF
}
}
if result.Sistema.Nombre == "" {
result.Sistema.Nombre = "VerifactuMidAPI"
}
if result.Sistema.Version == "" {
result.Sistema.Version = "1.0"
}
log.Printf("[Emisor] NIF=%q Nombre=%q", result.EmisorNIF, result.EmisorNombre)
log.Printf("[Sistema] Nombre=%q NIFProveedor=%q Version=%q", result.Sistema.Nombre, result.Sistema.NIFProveedor, result.Sistema.Version)
return s.processInvoiceData(result)
}
func (s *FacturaService) processInvoiceData(result *formats.TransformResult) (*AltaOutput, error) {
fecha, _ := time.Parse("02-01-2006", result.FechaExpedicion)
ivaList := make([]IVARegularizacion, len(result.IVA))
for i, v := range result.IVA {
ivaList[i] = IVARegularizacion{
Base: v.Base,
Cuota: v.Cuota,
Tipo: v.Tipo,
ClaveRegimen: "01",
Calificacion: "S1",
}
}
cuotaTotal := 0.0
for _, v := range result.IVA {
cuotaTotal += v.Cuota
}
var dest *Destinatario
if result.Destinatario != nil {
dest = &Destinatario{
Nombre: result.Destinatario.Nombre,
NIF: result.Destinatario.NIF,
}
}
data := &InvoiceData{
Tipo: "alta",
EmisorNIF: result.EmisorNIF,
NumSerie: result.NumSerie,
Fecha: fecha,
TipoFactura: result.TipoFactura,
Descripcion: result.Descripcion,
Destinatario: dest,
IVA: ivaList,
CuotaTotal: cuotaTotal,
ImporteTotal: result.ImporteTotal,
Sistema: Sistema{
Nombre: result.Sistema.Nombre,
NIFProveedor: result.Sistema.NIFProveedor,
NombreSistema: result.Sistema.Nombre,
IDSistema: "01",
Version: result.Sistema.Version,
NumeroInstalacion: "1",
TipoUsoVerifactu: "S",
TipoUsoMultiOT: "N",
IndicadorMultiOT: "N",
},
FechaGen: time.Now(),
}
var prevHash, prevNumSerie string
var prevFecha time.Time
if s.hashStorage != nil {
record, err := s.hashStorage.GetLastRecord(result.EmisorNIF)
if err != nil {
return &AltaOutput{
Success: false,
Error: "hash_storage_error",
}, nil
}
if record != nil {
prevHash = record.Huella
prevNumSerie = record.NumSerie
prevFecha = record.Fecha
}
}
hashService := NewHashService()
currentHash := hashService.CalculateHash(data, prevHash)
now := time.Now()
lastRecord := &LastRecord{
EmisorNIF: data.EmisorNIF,
NumSerie: data.NumSerie,
Fecha: data.Fecha,
Huella: currentHash,
FechaGen: now,
}
data.Huella = currentHash
data.FechaGen = now
altaData := verifactu.AltaData{
EmisorNombre: result.EmisorNombre,
EmisorNIF: data.EmisorNIF,
NumSerie: data.NumSerie,
FechaExpedicion: data.Fecha,
TipoFactura: data.TipoFactura,
Descripcion: data.Descripcion,
DestinatarioNombre: destNombre(data.Destinatario),
DestinatarioNIF: destNIF(data.Destinatario),
IVA: toIVAData(data.IVA),
CuotaTotal: data.CuotaTotal,
ImporteTotal: data.ImporteTotal,
Sistema: verifactu.SistemaData{
Nombre: data.Sistema.Nombre,
NIFProveedor: data.Sistema.NIFProveedor,
NombreSistema: data.Sistema.NombreSistema,
Version: data.Sistema.Version,
NumeroInstalacion: data.Sistema.NumeroInstalacion,
TipoUsoVerifactu: data.Sistema.TipoUsoVerifactu,
},
Huella: currentHash,
PrevHash: prevHash,
PrevNumSerie: prevNumSerie,
PrevFecha: prevFecha,
FechaGen: data.FechaGen,
}
if s.verifactu == nil {
return &AltaOutput{
Success: false,
Error: "verifactu_not_configured: no hay certificado registrado",
}, nil
}
log.Printf("Sending to AEAT...")
resp, err := s.verifactu.SendAlta(altaData)
if err != nil {
log.Printf("AEAT error: %v", err)
return &AltaOutput{
Success: false,
Error: "aeat_connection_error: " + err.Error(),
}, nil
}
if resp.Body.Fault != nil {
log.Printf("AEAT fault: %v", resp.Body.Fault.FaultString)
return &AltaOutput{
Success: false,
Error: "aeat_fault: " + resp.Body.Fault.FaultString,
}, nil
}
if resp.Body.Respuesta == nil {
return &AltaOutput{
Success: false,
Error: "aeat_error: respuesta vacía",
}, nil
}
estado := resp.Body.Respuesta.EstadoEnvio
csv := resp.Body.Respuesta.CSV
log.Printf("AEAT EstadoEnvio: %s CSV: %s", estado, csv)
if estado == "Correcto" {
if s.hashStorage != nil {
if err := s.hashStorage.SaveLastRecord(lastRecord); err != nil {
return &AltaOutput{
Success: false,
Error: "hash_save_error",
}, nil
}
}
return &AltaOutput{
Success: true,
CSV: csv,
Estado: "Correcto",
}, nil
}
errMsg := estado
if len(resp.Body.Respuesta.RespuestaLineas) > 0 {
l := resp.Body.Respuesta.RespuestaLineas[0]
errMsg = fmt.Sprintf("%s [%s] %s", estado, l.CodigoError, l.DescripcionError)
}
log.Printf("AEAT error: %s", errMsg)
return &AltaOutput{
Success: false,
Error: "aeat_error: " + errMsg,
}, nil
}
func destNombre(d *Destinatario) string {
if d == nil {
return ""
}
return d.Nombre
}
func destNIF(d *Destinatario) string {
if d == nil {
return ""
}
return d.NIF
}
func toIVAData(list []IVARegularizacion) []verifactu.IVARegularizacionData {
out := make([]verifactu.IVARegularizacionData, len(list))
for i, v := range list {
out[i] = verifactu.IVARegularizacionData{
Base: v.Base,
Cuota: v.Cuota,
Tipo: v.Tipo,
ClaveRegimen: v.ClaveRegimen,
Calificacion: v.Calificacion,
}
}
return out
}
type AnulacionInput struct {
InvoiceInput
EmisorNombre string
}
type AnulacionOutput struct {
Success bool `json:"success"`
CSV string `json:"csv,omitempty"`
Estado string `json:"estado,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *FacturaService) ProcessAnulacion(input AnulacionInput) (*AnulacionOutput, error) {
if input.Factura.EmisorNIF == "" && s.verifactu != nil {
nif, nombre := s.verifactu.CertSubject()
input.Factura.EmisorNIF = nif
if input.EmisorNombre == "" {
input.EmisorNombre = nombre
}
}
if input.Factura.EmisorNIF == "" {
return &AnulacionOutput{
Success: false,
Error: "emisor_nif_required",
}, nil
}
if input.Factura.NumSerie == "" {
return &AnulacionOutput{
Success: false,
Error: "num_serie_required",
}, nil
}
if input.Factura.FechaExpedicion == "" {
return &AnulacionOutput{
Success: false,
Error: "fecha_expedicion_required",
}, nil
}
return &AnulacionOutput{
Success: true,
Estado: "Anulada",
}, nil
}

View File

@ -0,0 +1,149 @@
package dolibarr
import (
"encoding/json"
"fmt"
"math"
"time"
"VerifactuMidAPI/internal/formats"
)
func init() {
formats.Register(&Transformer{})
}
type Transformer struct{}
func (t *Transformer) Name() string { return "dolibarr" }
type Input struct {
Invoice InvoiceInput `json:"invoice"`
Client *ClientInput `json:"client,omitempty"`
Emisor EmisorInput `json:"emisor"`
Sistema SistemaInput `json:"sistema"`
}
type InvoiceInput struct {
Number string `json:"number"`
Date string `json:"date"`
NotePublic string `json:"notePublic,omitempty"`
Lines []LineInput `json:"lines"`
}
type LineInput struct {
Description string `json:"description"`
Quantity float64 `json:"quantity"`
UnitPrice float64 `json:"unitPrice"`
TaxRate float64 `json:"taxRate"`
}
type ClientInput struct {
Name string `json:"name"`
VatNumber string `json:"vatNumber"`
}
type EmisorInput struct {
NIF string `json:"nif,omitempty"`
Nombre string `json:"nombre,omitempty"`
}
type SistemaInput struct {
Nombre string `json:"nombre,omitempty"`
NIFProveedor string `json:"nif_proveedor,omitempty"`
Version string `json:"version,omitempty"`
}
func (t *Transformer) Transform(raw json.RawMessage) (*formats.TransformResult, error) {
var in Input
if err := json.Unmarshal(raw, &in); err != nil {
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)
if err != nil {
return nil, fmt.Errorf("invalid invoice date: %w", err)
}
ivaMap := make(map[float64]*formats.IVAData)
for _, line := range in.Invoice.Lines {
rate := line.TaxRate
lineTotal := line.Quantity * line.UnitPrice
base := lineTotal / (1 + rate/100)
cuota := lineTotal - base
if existing, ok := ivaMap[rate]; ok {
existing.Base += base
existing.Cuota += cuota
} else {
ivaMap[rate] = &formats.IVAData{
Base: base,
Cuota: cuota,
Tipo: rate,
}
}
}
iva := make([]formats.IVAData, 0, len(ivaMap))
var importeTotal float64
for _, v := range ivaMap {
v.Base = round2(v.Base)
v.Cuota = round2(v.Cuota)
importeTotal += v.Base + v.Cuota
iva = append(iva, *v)
}
var dest *formats.DestinatarioData
if in.Client != nil && in.Client.VatNumber != "" {
dest = &formats.DestinatarioData{
Nombre: in.Client.Name,
NIF: in.Client.VatNumber,
}
}
desc := in.Invoice.NotePublic
if desc == "" {
desc = "Factura"
}
return &formats.TransformResult{
EmisorNIF: in.Emisor.NIF,
EmisorNombre: in.Emisor.Nombre,
NumSerie: in.Invoice.Number,
FechaExpedicion: date,
TipoFactura: "F1",
Descripcion: desc,
Destinatario: dest,
IVA: iva,
ImporteTotal: round2(importeTotal),
Sistema: formats.SistemaData{
Nombre: in.Sistema.Nombre,
NIFProveedor: in.Sistema.NIFProveedor,
Version: in.Sistema.Version,
},
}, nil
}
func parseDate(s string) (string, error) {
formats := []string{
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, f := range formats {
t, err := time.Parse(f, s)
if err == nil {
return t.Format("02-01-2006"), nil
}
}
return "", fmt.Errorf("cannot parse date %q", s)
}
func round2(v float64) float64 {
return math.Round(v*100) / 100
}

View File

@ -0,0 +1,94 @@
package native
import (
"encoding/json"
"fmt"
"time"
"VerifactuMidAPI/internal/formats"
)
func init() {
formats.Register(&Transformer{})
}
type Transformer struct{}
func (t *Transformer) Name() string { return "native" }
type Input struct {
Tipo string `json:"tipo"`
Factura FacturaInput `json:"factura"`
Sistema SistemaInput `json:"sistema"`
}
type FacturaInput struct {
EmisorNIF string `json:"emisor_nif"`
EmisorNombre string `json:"emisor_nombre"`
NumSerie string `json:"num_serie"`
FechaExpedicion string `json:"fecha_expedicion"`
TipoFactura string `json:"tipo_factura"`
Descripcion string `json:"descripcion"`
Destinatario *DestinatarioInput `json:"destinatario,omitempty"`
IVA []IVAInput `json:"iva"`
ImporteTotal float64 `json:"importe_total"`
}
type DestinatarioInput struct {
Nombre string `json:"nombre"`
NIF string `json:"nif"`
}
type IVAInput struct {
Base float64 `json:"base"`
Cuota float64 `json:"cuota"`
Tipo float64 `json:"tipo"`
}
type SistemaInput struct {
Nombre string `json:"nombre"`
NIFProveedor string `json:"nif_proveedor"`
Version string `json:"version"`
}
func (t *Transformer) Transform(raw json.RawMessage) (*formats.TransformResult, error) {
var in Input
if err := json.Unmarshal(raw, &in); err != nil {
return nil, fmt.Errorf("invalid native format: %w", err)
}
var dest *formats.DestinatarioData
if in.Factura.Destinatario != nil {
dest = &formats.DestinatarioData{
Nombre: in.Factura.Destinatario.Nombre,
NIF: in.Factura.Destinatario.NIF,
}
}
iva := make([]formats.IVAData, len(in.Factura.IVA))
for i, v := range in.Factura.IVA {
iva[i] = formats.IVAData{Base: v.Base, Cuota: v.Cuota, Tipo: v.Tipo}
}
_, err := time.Parse("02-01-2006", in.Factura.FechaExpedicion)
if err != nil {
return nil, fmt.Errorf("invalid fecha_expedicion: %w", err)
}
return &formats.TransformResult{
EmisorNIF: in.Factura.EmisorNIF,
EmisorNombre: in.Factura.EmisorNombre,
NumSerie: in.Factura.NumSerie,
FechaExpedicion: in.Factura.FechaExpedicion,
TipoFactura: in.Factura.TipoFactura,
Descripcion: in.Factura.Descripcion,
Destinatario: dest,
IVA: iva,
ImporteTotal: in.Factura.ImporteTotal,
Sistema: formats.SistemaData{
Nombre: in.Sistema.Nombre,
NIFProveedor: in.Sistema.NIFProveedor,
Version: in.Sistema.Version,
},
}, nil
}

View File

@ -0,0 +1,71 @@
package formats
import (
"encoding/json"
"fmt"
"sort"
)
type Transformer interface {
Name() string
Transform(raw json.RawMessage) (*TransformResult, error)
}
type TransformResult struct {
EmisorNIF string
EmisorNombre string
NumSerie string
FechaExpedicion string
TipoFactura string
Descripcion string
Destinatario *DestinatarioData
IVA []IVAData
ImporteTotal float64
Sistema SistemaData
}
type DestinatarioData struct {
Nombre string
NIF string
}
type IVAData struct {
Base float64
Cuota float64
Tipo float64
}
type SistemaData struct {
Nombre string
NIFProveedor string
Version string
}
var registry = make(map[string]Transformer)
var order []string
func Register(t Transformer) {
name := t.Name()
if _, exists := registry[name]; !exists {
order = append(order, name)
}
registry[name] = t
}
func TransformAuto(raw json.RawMessage) (*TransformResult, string, error) {
for _, name := range order {
t := registry[name]
result, err := t.Transform(raw)
if err == nil {
return result, name, nil
}
}
return nil, "", fmt.Errorf("no matching format found (available: %v)", Available())
}
func Available() []string {
names := make([]string, len(order))
copy(names, order)
sort.Strings(names)
return names
}

View File

@ -0,0 +1,130 @@
package internal
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type HashService struct {
lastRecord *LastRecord
}
type LastRecord struct {
EmisorNIF string
NumSerie string
Fecha time.Time
Huella string
FechaGen time.Time
}
func NewHashService() *HashService {
return &HashService{}
}
func (s *HashService) SetLastRecord(r *LastRecord) {
s.lastRecord = r
}
func (s *HashService) GetLastRecord() *LastRecord {
return s.lastRecord
}
func (s *HashService) CalculateHash(data *InvoiceData, previousHash string) string {
fechaGen := data.FechaGen.Format("2006-01-02T15:04:05-07:00")
fields := fmt.Sprintf(
"IDEmisorFactura=%s&NumSerieFactura=%s&FechaExpedicionFactura=%s&TipoFactura=%s&CuotaTotal=%.2f&ImporteTotal=%.2f&Huella=%s&FechaHoraHusoGenRegistro=%s",
data.EmisorNIF,
data.NumSerie,
data.Fecha.Format("02-01-2006"),
data.TipoFactura,
data.CuotaTotal,
data.ImporteTotal,
previousHash,
fechaGen,
)
hash := sha256.Sum256([]byte(fields))
return strings.ToUpper(hex.EncodeToString(hash[:]))
}
func (s *HashService) IsFirstRecord() bool {
return s.lastRecord == nil
}
func (s *HashService) GetPreviousHash() string {
if s.lastRecord == nil {
return ""
}
return s.lastRecord.Huella
}
type LastRecordStorage interface {
GetLastRecord(emisorNIF string) (*LastRecord, error)
SaveLastRecord(r *LastRecord) error
}
type FileLastRecordStorage struct {
basePath string
}
func NewFileLastRecordStorage(basePath string) *FileLastRecordStorage {
return &FileLastRecordStorage{basePath: basePath}
}
func (s *FileLastRecordStorage) GetLastRecord(emisorNIF string) (*LastRecord, error) {
if s.basePath == "" {
s.basePath = "./data/"
}
filePath := filepath.Join(s.basePath, sanitizeFilename(emisorNIF)+".json")
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading last record: %w", err)
}
var record LastRecord
if err := json.Unmarshal(data, &record); err != nil {
return nil, fmt.Errorf("unmarshaling last record: %w", err)
}
return &record, nil
}
func (s *FileLastRecordStorage) SaveLastRecord(r *LastRecord) error {
if s.basePath == "" {
s.basePath = "./data/"
}
if err := os.MkdirAll(s.basePath, 0750); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
filePath := filepath.Join(s.basePath, sanitizeFilename(r.EmisorNIF)+".json")
data, err := json.Marshal(r)
if err != nil {
return fmt.Errorf("marshaling last record: %w", err)
}
if err := os.WriteFile(filePath, data, 0640); err != nil {
return fmt.Errorf("writing last record: %w", err)
}
return nil
}
func sanitizeFilename(name string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9]`)
return re.ReplaceAllString(name, "_")
}

View File

@ -0,0 +1,94 @@
package internal
import (
"testing"
"time"
)
func TestCalculateHash_DeterministicoConFechaFija(t *testing.T) {
svc := NewHashService()
fechaGen, _ := time.Parse("2006-01-02T15:04:05-07:00", "2026-05-28T10:00:00+02:00")
fecha, _ := time.Parse("02-01-2006", "28-05-2026")
data := &InvoiceData{
EmisorNIF: "A12345678",
NumSerie: "INV-001",
Fecha: fecha,
TipoFactura: "F1",
CuotaTotal: 21.00,
ImporteTotal: 121.00,
FechaGen: fechaGen,
}
hash1 := svc.CalculateHash(data, "")
hash2 := svc.CalculateHash(data, "")
if hash1 != hash2 {
t.Errorf("el hash no es determinístico: %s != %s", hash1, hash2)
}
if len(hash1) != 64 {
t.Errorf("hash SHA-256 debe tener 64 caracteres hex, tiene %d", len(hash1))
}
}
func TestCalculateHash_CambiaConDiferenteHashPrevio(t *testing.T) {
svc := NewHashService()
fechaGen, _ := time.Parse("2006-01-02T15:04:05-07:00", "2026-05-28T10:00:00+02:00")
fecha, _ := time.Parse("02-01-2006", "28-05-2026")
data := &InvoiceData{
EmisorNIF: "A12345678",
NumSerie: "INV-001",
Fecha: fecha,
TipoFactura: "F1",
CuotaTotal: 21.00,
ImporteTotal: 121.00,
FechaGen: fechaGen,
}
hashSinPrev := svc.CalculateHash(data, "")
hashConPrev := svc.CalculateHash(data, "ABCDEF1234567890")
if hashSinPrev == hashConPrev {
t.Error("el hash debería cambiar cuando cambia el hash previo")
}
}
func TestCalculateHash_PrimerRegistroHashVacio(t *testing.T) {
svc := NewHashService()
if !svc.IsFirstRecord() {
t.Error("un HashService nuevo debería indicar que es el primer registro")
}
if svc.GetPreviousHash() != "" {
t.Error("el hash previo del primer registro debe ser cadena vacía")
}
}
func TestCalculateHash_EncadenamientoHashPrevio(t *testing.T) {
svc := NewHashService()
fechaGen, _ := time.Parse("2006-01-02T15:04:05-07:00", "2026-05-28T10:00:00+02:00")
fecha, _ := time.Parse("02-01-2006", "28-05-2026")
data := &InvoiceData{
EmisorNIF: "A12345678",
NumSerie: "INV-001",
Fecha: fecha,
TipoFactura: "F1",
CuotaTotal: 21.00,
ImporteTotal: 121.00,
FechaGen: fechaGen,
}
hash1 := svc.CalculateHash(data, "")
svc.SetLastRecord(&LastRecord{Huella: hash1})
if svc.IsFirstRecord() {
t.Error("después de SetLastRecord no debería ser primer registro")
}
if svc.GetPreviousHash() != hash1 {
t.Errorf("GetPreviousHash devolvió %q, esperaba %q", svc.GetPreviousHash(), hash1)
}
}

View File

@ -0,0 +1,158 @@
package internal
import (
"errors"
"fmt"
"regexp"
"strconv"
"time"
)
var (
ErrEmptyNIF = errors.New("NIF cannot be empty")
ErrInvalidNIF = errors.New("invalid NIF format")
ErrEmptyNumSerie = errors.New("number series cannot be empty")
ErrInvalidFecha = errors.New("invalid date format, expected dd-mm-yyyy")
ErrInvalidTipo = errors.New("invalid invoice type")
ErrInvalidImporte = errors.New("invalid amount format")
ErrEmptyNombre = errors.New("business name cannot be empty")
ErrInvalidIVACampo = errors.New("invalid IVA field: must be numeric")
)
// Empresa/CIF: letra + 7 dígitos + letra o dígito (A1234567B, B12345678)
// Autónomo/NIF: 8 dígitos + letra (53950250R)
var nifRegex = regexp.MustCompile(`^([A-Z]\d{7}[A-Z0-9]|\d{8}[A-Z])$`)
var tipoFacturaValidos = map[string]bool{
"F1": true, "F2": true,
"R1": true, "R2": true, "R3": true, "R4": true, "R5": true,
}
type InvoiceInput struct {
Tipo string `json:"tipo"`
Factura FacturaInput `json:"factura"`
Sistema SistemaInput `json:"sistema"`
}
type FacturaInput struct {
EmisorNIF string `json:"emisor_nif"`
EmisorNombre string `json:"emisor_nombre"`
NumSerie string `json:"num_serie"`
FechaExpedicion string `json:"fecha_expedicion"`
TipoFactura string `json:"tipo_factura"`
Descripcion string `json:"descripcion"`
Destinatario *DestinatarioInput `json:"destinatario,omitempty"`
IVA []IVAInput `json:"iva"`
ImporteTotal float64 `json:"importe_total"`
}
type DestinatarioInput struct {
Nombre string `json:"nombre"`
NIF string `json:"nif"`
}
type IVAInput struct {
Base float64 `json:"base"`
Cuota float64 `json:"cuota"`
Tipo float64 `json:"tipo"`
}
type SistemaInput struct {
Nombre string `json:"nombre"`
NIFProveedor string `json:"nif_proveedor"`
Version string `json:"version"`
}
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func ValidateInvoiceInput(in *InvoiceInput) []error {
var errs []error
if in.Tipo == "" {
errs = append(errs, &ValidationError{"tipo", "operation type is required"})
} else if in.Tipo != "alta" && in.Tipo != "anulacion" {
errs = append(errs, &ValidationError{"tipo", "must be 'alta' or 'anulacion'"})
}
if in.Factura.EmisorNIF == "" {
errs = append(errs, &ValidationError{"factura.emisor_nif", "cannot be empty"})
} else if !isValidNIF(in.Factura.EmisorNIF) {
errs = append(errs, &ValidationError{"factura.emisor_nif", "invalid NIF format"})
}
if in.Factura.NumSerie == "" {
errs = append(errs, &ValidationError{"factura.num_serie", "cannot be empty"})
}
if in.Factura.FechaExpedicion == "" {
errs = append(errs, &ValidationError{"factura.fecha_expedicion", "cannot be empty"})
} else if !isValidDate(in.Factura.FechaExpedicion) {
errs = append(errs, &ValidationError{"factura.fecha_expedicion", "invalid format, expected dd-mm-yyyy"})
}
if in.Factura.TipoFactura == "" {
errs = append(errs, &ValidationError{"factura.tipo_factura", "cannot be empty"})
} else if !tipoFacturaValidos[in.Factura.TipoFactura] {
errs = append(errs, &ValidationError{"factura.tipo_factura", "invalid invoice type"})
}
if len(in.Factura.IVA) == 0 {
errs = append(errs, &ValidationError{"factura.iva", "at least one IVA entry is required"})
}
for i, iva := range in.Factura.IVA {
if iva.Base < 0 {
errs = append(errs, &ValidationError{fmt.Sprintf("factura.iva[%d].base", i), "must be positive"})
}
if iva.Cuota < 0 {
errs = append(errs, &ValidationError{fmt.Sprintf("factura.iva[%d].cuota", i), "must be positive"})
}
if iva.Tipo < 0 {
errs = append(errs, &ValidationError{fmt.Sprintf("factura.iva[%d].tipo", i), "must be positive"})
}
}
if in.Factura.ImporteTotal <= 0 {
errs = append(errs, &ValidationError{"factura.importe_total", "must be greater than 0"})
}
// Destinatario es opcional, no se valida NIF
_ = in.Factura.Destinatario
if in.Sistema.Nombre == "" {
errs = append(errs, &ValidationError{"sistema.nombre", "cannot be empty"})
}
if in.Sistema.NIFProveedor == "" {
errs = append(errs, &ValidationError{"sistema.nif_proveedor", "cannot be empty"})
}
if in.Sistema.Version == "" {
errs = append(errs, &ValidationError{"sistema.version", "cannot be empty"})
}
return errs
}
func isValidNIF(nif string) bool {
if len(nif) != 9 {
return false
}
return nifRegex.MatchString(nif)
}
func isValidDate(date string) bool {
_, err := time.Parse("02-01-2006", date)
return err == nil
}
func ParseFloat(s string) (float64, error) {
if s == "" {
return 0, ErrInvalidImporte
}
return strconv.ParseFloat(s, 64)
}

View File

@ -0,0 +1,173 @@
package internal
import (
"testing"
)
func facturaValida() *InvoiceInput {
return &InvoiceInput{
Tipo: "alta",
Factura: FacturaInput{
EmisorNIF: "A1234567B", // [A-Z0-9] + 7 dígitos + [A-Z]
EmisorNombre: "Empresa Test SL",
NumSerie: "INV-2026-001",
FechaExpedicion: "28-05-2026",
TipoFactura: "F1",
Descripcion: "Servicios",
IVA: []IVAInput{{Base: 100, Cuota: 21, Tipo: 21}},
ImporteTotal: 121.0,
},
Sistema: SistemaInput{
Nombre: "MiSistema",
NIFProveedor: "B9876543C",
Version: "1.0",
},
}
}
func TestValidateInvoiceInput_EntradaValida(t *testing.T) {
errs := ValidateInvoiceInput(facturaValida())
if len(errs) != 0 {
t.Errorf("entrada válida no debería tener errores, obtuvo: %v", errs)
}
}
func TestValidateInvoiceInput_TipoVacio(t *testing.T) {
in := facturaValida()
in.Tipo = ""
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "tipo") {
t.Error("debería fallar con tipo vacío")
}
}
func TestValidateInvoiceInput_TipoInvalido(t *testing.T) {
in := facturaValida()
in.Tipo = "modificacion"
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "tipo") {
t.Error("debería fallar con tipo inválido")
}
}
func TestValidateInvoiceInput_NIFVacio(t *testing.T) {
in := facturaValida()
in.Factura.EmisorNIF = ""
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "emisor_nif") {
t.Error("debería fallar con NIF vacío")
}
}
func TestValidateInvoiceInput_NIFFormatoInvalido(t *testing.T) {
casos := []string{
"12345678", // 8 dígitos sin letra final
"ABCDEFGHI", // todo letras
"A1234567", // empresa sin último carácter
"123456789", // 9 dígitos sin letra
"1234567R", // 7 dígitos + letra (autónomo necesita 8 dígitos)
"A123456BC", // empresa con dos letras al final
"",
}
for _, nif := range casos {
in := facturaValida()
in.Factura.EmisorNIF = nif
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "emisor_nif") {
t.Errorf("NIF %q debería ser inválido", nif)
}
}
}
func TestValidateInvoiceInput_NIFFormatos_Validos(t *testing.T) {
casos := []string{
"A1234567B", // empresa, letra final letra
"B9876543C", // empresa, letra final letra
"A12345678", // empresa, letra final dígito
"53950250R", // autónomo (8 dígitos + letra)
"12345678Z", // autónomo genérico
"X1234567L", // NIE (letra inicial + 7 dígitos + letra)
}
for _, nif := range casos {
in := facturaValida()
in.Factura.EmisorNIF = nif
errs := ValidateInvoiceInput(in)
if contieneError(errs, "emisor_nif") {
t.Errorf("NIF %q debería ser válido", nif)
}
}
}
func TestValidateInvoiceInput_FechaFormatoInvalido(t *testing.T) {
casos := []string{"2026-05-28", "28/05/2026", "2026/05/28", "28-5-2026"}
for _, fecha := range casos {
in := facturaValida()
in.Factura.FechaExpedicion = fecha
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "fecha_expedicion") {
t.Errorf("fecha %q debería ser inválida (esperado dd-mm-yyyy)", fecha)
}
}
}
func TestValidateInvoiceInput_TipoFacturaInvalido(t *testing.T) {
in := facturaValida()
in.Factura.TipoFactura = "X9"
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "tipo_factura") {
t.Error("debería fallar con tipo de factura inválido")
}
}
func TestValidateInvoiceInput_TiposFacturaValidos(t *testing.T) {
for _, tipo := range []string{"F1", "F2", "R1", "R2", "R3", "R4", "R5"} {
in := facturaValida()
in.Factura.TipoFactura = tipo
errs := ValidateInvoiceInput(in)
if contieneError(errs, "tipo_factura") {
t.Errorf("tipo de factura %q debería ser válido", tipo)
}
}
}
func TestValidateInvoiceInput_IVAVacio(t *testing.T) {
in := facturaValida()
in.Factura.IVA = nil
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "iva") {
t.Error("debería fallar sin líneas de IVA")
}
}
func TestValidateInvoiceInput_ImporteNegativo(t *testing.T) {
in := facturaValida()
in.Factura.ImporteTotal = -1.0
errs := ValidateInvoiceInput(in)
if !contieneError(errs, "importe_total") {
t.Error("debería fallar con importe negativo")
}
}
func TestValidateInvoiceInput_SistemaVacio(t *testing.T) {
in := facturaValida()
in.Sistema = SistemaInput{}
errs := ValidateInvoiceInput(in)
if len(errs) < 3 {
t.Errorf("debería fallar con sistema vacío, obtuvo %d errores", len(errs))
}
}
func contieneError(errs []error, campo string) bool {
for _, e := range errs {
if ve, ok := e.(*ValidationError); ok && ve.Field != "" {
if len(ve.Field) >= len(campo) {
for i := 0; i <= len(ve.Field)-len(campo); i++ {
if ve.Field[i:i+len(campo)] == campo {
return true
}
}
}
}
}
return false
}

View File

@ -0,0 +1,105 @@
package internal
import "time"
type InvoiceData struct {
Tipo string
EmisorNIF string
NumSerie string
Fecha time.Time
TipoFactura string
Descripcion string
Destinatario *Destinatario
IVA []IVARegularizacion
CuotaTotal float64
ImporteTotal float64
Sistema Sistema
FechaGen time.Time
Huella string
}
type Destinatario struct {
Nombre string
NIF string
}
type IVARegularizacion struct {
Base float64
Cuota float64
Tipo float64
ClaveRegimen string
Calificacion string
}
type Sistema struct {
Nombre string
NIFProveedor string
NombreSistema string
IDSistema string
Version string
NumeroInstalacion string
TipoUsoVerifactu string
TipoUsoMultiOT string
IndicadorMultiOT string
}
func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) {
fecha, _ := time.Parse("02-01-2006", in.Factura.FechaExpedicion)
destinatario := in.Factura.Destinatario
var dest *Destinatario
if destinatario != nil {
dest = &Destinatario{
Nombre: destinatario.Nombre,
NIF: destinatario.NIF,
}
}
ivaList := make([]IVARegularizacion, len(in.Factura.IVA))
for i, iva := range in.Factura.IVA {
ivaList[i] = IVARegularizacion{
Base: iva.Base,
Cuota: iva.Cuota,
Tipo: iva.Tipo,
ClaveRegimen: "01",
Calificacion: "S1",
}
}
cuotaTotal := 0.0
for _, iva := range in.Factura.IVA {
cuotaTotal += iva.Cuota
}
descripcion := in.Factura.Descripcion
if descripcion == "" {
descripcion = "Factura"
}
sistema := Sistema{
Nombre: in.Sistema.Nombre,
NIFProveedor: in.Sistema.NIFProveedor,
NombreSistema: in.Sistema.Nombre,
IDSistema: "1",
Version: in.Sistema.Version,
NumeroInstalacion: "1",
TipoUsoVerifactu: "S",
TipoUsoMultiOT: "N",
IndicadorMultiOT: "N",
}
return &InvoiceData{
Tipo: in.Tipo,
EmisorNIF: in.Factura.EmisorNIF,
NumSerie: in.Factura.NumSerie,
Fecha: fecha,
TipoFactura: in.Factura.TipoFactura,
Descripcion: descripcion,
Destinatario: dest,
IVA: ivaList,
CuotaTotal: cuotaTotal,
ImporteTotal: in.Factura.ImporteTotal,
Sistema: sistema,
FechaGen: time.Now(),
}, nil
}

View File

@ -0,0 +1,131 @@
package internal
import (
"testing"
"time"
)
func TestTransformToInvoiceData_CamposBasicos(t *testing.T) {
in := facturaValida()
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("TransformToInvoiceData devolvió error inesperado: %v", err)
}
if data.EmisorNIF != "A1234567B" {
t.Errorf("EmisorNIF: esperado A1234567B, obtuvo %s", data.EmisorNIF)
}
if data.NumSerie != "INV-2026-001" {
t.Errorf("NumSerie: esperado INV-2026-001, obtuvo %s", data.NumSerie)
}
if data.TipoFactura != "F1" {
t.Errorf("TipoFactura: esperado F1, obtuvo %s", data.TipoFactura)
}
if data.ImporteTotal != 121.0 {
t.Errorf("ImporteTotal: esperado 121.0, obtuvo %f", data.ImporteTotal)
}
}
func TestTransformToInvoiceData_FechaParsedaCorrecta(t *testing.T) {
in := facturaValida()
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
esperada := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC)
if !data.Fecha.Equal(esperada) {
t.Errorf("Fecha: esperado %v, obtuvo %v", esperada, data.Fecha)
}
}
func TestTransformToInvoiceData_CuotaTotalSumada(t *testing.T) {
in := facturaValida()
in.Factura.IVA = []IVAInput{
{Base: 100, Cuota: 21, Tipo: 21},
{Base: 200, Cuota: 10, Tipo: 5},
}
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if data.CuotaTotal != 31.0 {
t.Errorf("CuotaTotal: esperado 31.0, obtuvo %f", data.CuotaTotal)
}
}
func TestTransformToInvoiceData_DescripcionPorDefecto(t *testing.T) {
in := facturaValida()
in.Factura.Descripcion = ""
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if data.Descripcion != "Factura" {
t.Errorf("descripción por defecto: esperado 'Factura', obtuvo %q", data.Descripcion)
}
}
func TestTransformToInvoiceData_SinDestinatario(t *testing.T) {
in := facturaValida()
in.Factura.Destinatario = nil
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if data.Destinatario != nil {
t.Error("sin destinatario en entrada, data.Destinatario debería ser nil")
}
}
func TestTransformToInvoiceData_ConDestinatario(t *testing.T) {
in := facturaValida()
in.Factura.Destinatario = &DestinatarioInput{Nombre: "Cliente SA", NIF: "B11111118"}
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if data.Destinatario == nil {
t.Fatal("destinatario no debería ser nil")
}
if data.Destinatario.Nombre != "Cliente SA" {
t.Errorf("nombre destinatario: esperado 'Cliente SA', obtuvo %q", data.Destinatario.Nombre)
}
}
func TestTransformToInvoiceData_IVAClaveRegimenDefecto(t *testing.T) {
in := facturaValida()
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
for i, iva := range data.IVA {
if iva.ClaveRegimen != "01" {
t.Errorf("IVA[%d].ClaveRegimen: esperado '01', obtuvo %q", i, iva.ClaveRegimen)
}
if iva.Calificacion != "S1" {
t.Errorf("IVA[%d].Calificacion: esperado 'S1', obtuvo %q", i, iva.Calificacion)
}
}
}
func TestTransformToInvoiceData_SistemaDefecto(t *testing.T) {
in := facturaValida()
data, err := TransformToInvoiceData(in)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if data.Sistema.TipoUsoVerifactu != "S" {
t.Errorf("TipoUsoVerifactu: esperado 'S', obtuvo %q", data.Sistema.TipoUsoVerifactu)
}
if data.Sistema.TipoUsoMultiOT != "N" {
t.Errorf("TipoUsoMultiOT: esperado 'N', obtuvo %q", data.Sistema.TipoUsoMultiOT)
}
if data.Sistema.IDSistema != "1" {
t.Errorf("IDSistema: esperado '1', obtuvo %q", data.Sistema.IDSistema)
}
}

76
VerifactuMidAPI/main.go Normal file
View File

@ -0,0 +1,76 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
"VerifactuMidAPI/api"
"VerifactuMidAPI/internal"
_ "VerifactuMidAPI/internal/formats/dolibarr"
_ "VerifactuMidAPI/internal/formats/native"
"VerifactuMidAPI/internal/cert"
"VerifactuMidAPI/internal/config"
"VerifactuMidAPI/internal/crypto"
"VerifactuMidAPI/verifactu"
)
func main() {
cfg, err := config.Load("config.yml")
if err != nil {
log.Fatalf("loading config: %v", err)
}
certStorage := cert.NewStorage(cfg.Certificates.StoragePath)
if err := certStorage.Init(); err != nil {
log.Fatalf("initializing cert storage: %v", err)
}
keyPair, err := crypto.LoadOrCreateKeyPair(cfg.Crypto.KeysPath)
if err != nil {
log.Fatalf("loading/creating key pair: %v", err)
}
hashStorage := internal.NewFileLastRecordStorage("./data")
facturaSvc := internal.NewFacturaService(hashStorage)
envURL := verifactu.GetTestURL()
if cfg.VeriFactu.Production {
envURL = verifactu.GetProdURL()
log.Println("VeriFactu: PRODUCTION environment")
} else {
log.Println("VeriFactu: TEST environment")
}
verifactuCfg := verifactu.ClientConfig{
BaseURL: envURL,
Timeout: 30 * time.Second,
}
client, err := verifactu.NewClient(verifactuCfg)
if err != nil {
log.Printf("warning: verifactu client initialization failed: %v", err)
} else {
facturaSvc.SetVerifactuClient(client)
log.Printf("VeriFactu: Connected to %s", envURL)
}
handler := api.New(cfg, certStorage, keyPair, facturaSvc)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
addr := fmt.Sprintf(":%d", cfg.Server.Port)
log.Printf("Starting server on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
}
func init() {
if _, err := os.Stat("config.yml"); os.IsNotExist(err) {
log.Println("Warning: config.yml not found, using defaults")
}
}

View File

@ -0,0 +1,207 @@
"""
Demo VeriFactu flujo desde el front
======================================
Simula lo que haría el front al pulsar "Registrar":
1. Login en el backend (dolibarr-bff) para obtener JWT
2. Consulta el token VeriFactu almacenado en el backend
3. Envía una factura en formato Dolibarr directamente a VerifactuMidAPI
Uso:
python demo.py
python demo.py --backend http://localhost:5269 --api http://localhost:6789
python demo.py --usuario admin --password 12345678
"""
import sys
import json
import argparse
import urllib.request
import urllib.error
# ── Configuración por defecto ──────────────────────────────────────────────────
BACKEND_URL = "http://localhost:5269"
API_URL = "http://localhost:6789"
USUARIO = "admin"
PASSWORD = "12345678"
# ── Factura de ejemplo ─────────────────────────────────────────────────────────
# Mismo formato que el front recibe del backend (formato Dolibarr).
# Los campos emisor.nif y emisor.nombre los rellena la API desde el certificado.
FACTURA = {
"format": "dolibarr",
"invoice": {
"number": "FAC-DEMO-001",
"date": "2026-05-29",
"notePublic": "Servicios de consultoría tecnológica",
"lines": [
{
"description": "Desarrollo integración VeriFactu",
"quantity": 1,
"unitPrice": 121.00,
"taxRate": 21
},
{
"description": "Soporte técnico mensual",
"quantity": 2,
"unitPrice": 60.50,
"taxRate": 21
}
]
},
"client": {
"name": "Empresa Cliente S.L.",
"vatNumber": "B12345678"
},
"emisor": {
"nif": "",
"nombre": ""
},
"sistema": {
"nombre": "BYolivia Suite",
"nif_proveedor": "B87654321",
"version": "1.0.0"
}
}
# ── Helpers ────────────────────────────────────────────────────────────────────
def _color(text, code):
return f"\033[{code}m{text}\033[0m"
def ok(msg): print(_color(f"{msg}", "32"))
def err(msg): print(_color(f"{msg}", "31"))
def info(msg): print(_color(f"{msg}", "36"))
def titulo(msg): print(_color(f"\n{''*60}\n {msg}\n{''*60}", "34"))
def hacer_request(method, url, headers=None, body=None):
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read())
except Exception:
body = {"error": e.reason}
return e.code, body
except Exception as e:
return None, str(e)
# ── Pasos ──────────────────────────────────────────────────────────────────────
def paso1_login(backend, usuario, password):
titulo("PASO 1 — Login en el backend (dolibarr-bff)")
url = f"{backend}/api/auth/login"
info(f"POST {url}")
info(f"Usuario: {usuario}")
status, data = hacer_request("POST", url, body={"username": usuario, "password": password})
if status is None:
err(f"Error de red: {data}")
return None
print(f"\n HTTP {status}")
print(" " + json.dumps(data, indent=4, ensure_ascii=False).replace("\n", "\n "))
token = data.get("token") or data.get("Token")
if status != 200 or not token:
err("Login fallido. Comprueba usuario, contraseña y que el backend esté arrancado.")
return None
ok("Login correcto. JWT obtenido.")
return token
def paso2_obtener_token_verifactu(backend, jwt):
titulo("PASO 2 — Consultar token VeriFactu al backend")
url = f"{backend}/api/verifactu/token"
info(f"GET {url}")
status, data = hacer_request("GET", url, headers={"Authorization": f"Bearer {jwt}"})
if status is None:
err(f"Error de red: {data}")
return None
print(f"\n HTTP {status}")
print(" " + json.dumps(data, indent=4, ensure_ascii=False).replace("\n", "\n "))
token = data.get("token") or data.get("Token")
if status != 200 or not token:
err("No se pudo obtener el token VeriFactu. ¿Está el certificado registrado?")
return None
ok("Token VeriFactu obtenido.")
return token
def paso3_mostrar_factura():
titulo("PASO 3 — Factura preparada (formato Dolibarr)")
info("Misma estructura que el front recibe del backend.")
print()
print(" " + json.dumps(FACTURA, indent=4, ensure_ascii=False).replace("\n", "\n "))
def paso4_enviar_factura(api_url, token):
titulo("PASO 4 — Enviar factura a VerifactuMidAPI")
url = f"{api_url}/api/v1/facturas"
info(f"POST {url}")
info(f"Token: {token[:40]}" if len(token) > 40 else f"Token: {token}")
status, data = hacer_request(
"POST", url,
headers={"Authorization": f"Bearer {token}"},
body=FACTURA
)
if status is None:
err(f"Error de red: {data}")
return
print(f"\n HTTP {status}")
if data.get("success"):
ok("Factura procesada correctamente por la API.")
if data.get("csv"):
ok(f"Hash de cadena: {data['csv']}")
else:
err(f"La API devolvió error: {data.get('error', 'desconocido')}")
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Demo flujo VeriFactu")
parser.add_argument("--backend", default=BACKEND_URL, help="URL del backend dolibarr-bff")
parser.add_argument("--api", default=API_URL, help="URL de VerifactuMidAPI")
parser.add_argument("--usuario", default=USUARIO, help="Usuario del backend")
parser.add_argument("--password", default=PASSWORD, help="Contraseña del backend")
args = parser.parse_args()
print(_color("\n VeriFactu Demo — flujo front → API", "1;37"))
print(_color(" Simula lo que haría el front al pulsar 'Registrar'\n", "2;37"))
# Paso 1: login → JWT
jwt = paso1_login(args.backend, args.usuario, args.password)
if not jwt:
sys.exit(1)
# Paso 2: token VeriFactu del backend
token_verifactu = paso2_obtener_token_verifactu(args.backend, jwt)
if not token_verifactu:
sys.exit(1)
# Paso 3: mostrar la factura
paso3_mostrar_factura()
# Paso 4: enviar a la API
paso4_enviar_factura(args.api, token_verifactu)
print()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>VeriFactu — Presentación del proyecto</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', sans-serif; background: #0f1117; color: #e8eaf0; overflow: hidden; }
.slide {
display: none;
width: 100vw; height: 100vh;
padding: 60px 80px;
flex-direction: column;
justify-content: center;
}
.slide.active { display: flex; }
h1 { font-size: 2.6rem; font-weight: 700; color: #fff; margin-bottom: 12px; }
h2 { font-size: 1.9rem; font-weight: 600; color: #7eb8f7; margin-bottom: 28px; border-bottom: 2px solid #2a3050; padding-bottom: 12px; }
h3 { font-size: 1.2rem; color: #a0b4cc; margin-bottom: 10px; }
p { font-size: 1.15rem; line-height: 1.7; color: #c8d0dc; max-width: 860px; }
ul { list-style: none; padding: 0; }
ul li { font-size: 1.1rem; padding: 7px 0 7px 22px; color: #c8d0dc; position: relative; line-height: 1.5; }
ul li::before { content: "→"; position: absolute; left: 0; color: #7eb8f7; }
.subtitle { font-size: 1.3rem; color: #7eb8f7; margin-bottom: 40px; }
.tag { display: inline-block; background: #1e2d45; color: #7eb8f7; border: 1px solid #2e4a6e; border-radius: 6px; padding: 3px 12px; font-size: 0.85rem; margin: 3px; }
.tag.green { background: #1a2e1a; color: #6fcf97; border-color: #2e6e2e; }
.tag.amber { background: #2e2208; color: #f2c94c; border-color: #6e4e08; }
.tag.red { background: #2e1010; color: #eb5757; border-color: #6e1010; }
.cols { display: grid; gap: 40px; }
.cols-2 { grid-template-columns: 1fr 1fr; }
.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
.card {
background: #181d2e;
border: 1px solid #2a3050;
border-radius: 12px;
padding: 24px 28px;
}
.card h3 { color: #7eb8f7; margin-bottom: 12px; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }
.flow {
display: flex;
align-items: center;
gap: 0;
margin: 30px 0;
flex-wrap: wrap;
}
.flow-step {
background: #181d2e;
border: 1px solid #2a3050;
border-radius: 10px;
padding: 16px 22px;
font-size: 1rem;
color: #e8eaf0;
text-align: center;
min-width: 130px;
}
.flow-arrow {
font-size: 1.6rem;
color: #7eb8f7;
padding: 0 10px;
}
table { border-collapse: collapse; width: 100%; margin-top: 10px; }
th { text-align: left; padding: 10px 16px; background: #181d2e; color: #7eb8f7; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 10px 16px; border-top: 1px solid #1e2535; font-size: 1rem; color: #c8d0dc; }
td code { background: #1e2535; padding: 2px 8px; border-radius: 4px; font-size: 0.9rem; color: #9ecbff; }
.status-ok { color: #6fcf97; font-weight: 600; }
.status-wip { color: #f2c94c; font-weight: 600; }
.status-no { color: #eb5757; font-weight: 600; }
.nav {
position: fixed;
bottom: 24px; right: 32px;
display: flex; gap: 12px; align-items: center;
}
.nav span { color: #4a5568; font-size: 0.9rem; }
#counter { color: #7eb8f7; font-size: 0.95rem; min-width: 60px; text-align: right; }
.note {
margin-top: 28px;
background: #1a1e2e;
border-left: 3px solid #f2c94c;
padding: 14px 20px;
border-radius: 0 8px 8px 0;
font-size: 0.95rem;
color: #b0b8c8;
max-width: 860px;
}
/* Slide 1 — portada */
#s1 { justify-content: center; align-items: flex-start; background: radial-gradient(ellipse at 70% 40%, #0d2140 0%, #0f1117 60%); }
#s1 h1 { font-size: 3.2rem; }
#s1 .meta { margin-top: 48px; font-size: 0.95rem; color: #4a5568; }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════ SLIDE 1 — PORTADA -->
<div class="slide active" id="s1">
<div class="tag">BYolivia · 2025</div>
<br>
<h1>Integración VeriFactu</h1>
<p class="subtitle">Facturación electrónica obligatoria con la AEAT</p>
<p>Presentación del sistema de envío de facturas al sistema VeriFactu de la Agencia Tributaria, integrado en la suite de gestión empresarial.</p>
</div>
<!-- ═══════════════════════════════════════════════════ SLIDE 2 — CONTEXTO -->
<div class="slide" id="s2">
<h2>¿Por qué VeriFactu?</h2>
<div class="cols cols-2">
<div class="card">
<h3>Obligación legal</h3>
<ul>
<li>Reglamento de facturación electrónica (RD 1007/2023)</li>
<li>Obligatorio para empresas y autónomos en España</li>
<li>Cada factura debe enviarse a la AEAT en tiempo real</li>
<li>Las facturas se encadenan con hash para garantizar integridad</li>
</ul>
</div>
<div class="card">
<h3>El reto técnico</h3>
<ul>
<li>La AEAT usa SOAP/XML con firma digital</li>
<li>Requiere certificado digital de empresa (FNMT/ACCV)</li>
<li>TLS mútuo (mTLS) para autenticarse</li>
<li>El software ERP (Dolibarr) no lo soporta de forma nativa</li>
</ul>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════ SLIDE 3 — ECOSISTEMA -->
<div class="slide" id="s3">
<h2>El ecosistema del proyecto</h2>
<!-- Flujo principal -->
<div class="flow" style="font-size:0.9rem; margin-bottom:8px; margin-top:16px">
<div class="flow-step">Dolibarr ERP<br><small style="color:#4a5568">datos de facturas</small></div>
<div class="flow-arrow"></div>
<div class="flow-step">Backend<br><small style="color:#4a5568">dolibarr-bff :5269</small></div>
<div class="flow-arrow"></div>
<div class="flow-step">Front<br><small style="color:#4a5568">doli-front :5173</small></div>
<div class="flow-arrow"></div>
<div class="flow-step">VerifactuMidAPI<br><small style="color:#4a5568">Go :6789</small></div>
<div class="flow-arrow" style="color:#f2c94c">- - →</div>
<div class="flow-step" style="border-color:#f2c94c; color:#f2c94c">AEAT<br><small style="color:#4a5568">VeriFactu</small></div>
</div>
<div style="margin-top:20px; display:flex; flex-direction:column; gap:10px; max-width:860px">
<div style="display:flex; align-items:flex-start; gap:10px">
<span class="tag green" style="white-space:nowrap">Implementado</span>
<span style="font-size:0.9rem; color:#c8d0dc">Dolibarr → Backend → Front → VerifactuMidAPI. El backend almacena el token que devuelve la API tras registrar el certificado.</span>
</div>
<div style="display:flex; align-items:flex-start; gap:10px">
<span class="tag amber" style="white-space:nowrap">Sin probar</span>
<span style="font-size:0.9rem; color:#c8d0dc">VerifactuMidAPI → AEAT. El envío real a VeriFactu no ha podido validarse por el tema del certificado. Es el único tramo pendiente de verificar.</span>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════ SLIDE 4 — VERIFACTU API -->
<div class="slide" id="s4">
<h2>VerifactuMidAPI — Qué hace</h2>
<div class="cols cols-2">
<ul style="gap:4px">
<li>Recibe la factura en JSON (formato nativo o Dolibarr)</li>
<li>Calcula el hash SHA-256 encadenado (norma AEAT)</li>
<li>Genera el XML SOAP con todos los campos requeridos</li>
<li>Firma digitalmente con el certificado de empresa</li>
<li>Envía a la AEAT por SOAP con mTLS (TLS 1.2)</li>
<li>Si la AEAT no responde → guarda en fallback local</li>
<li>Devuelve el resultado al BFF</li>
</ul>
<div>
<div class="card">
<h3>Endpoints</h3>
<table>
<tr><td><code>GET /health</code></td><td>Estado</td></tr>
<tr><td><code>GET /auth/public-key</code></td><td>Clave RSA pública</td></tr>
<tr><td><code>POST /auth/register</code></td><td>Subir certificado P12</td></tr>
<tr><td><code>GET /formats</code></td><td>Formatos aceptados</td></tr>
<tr><td><code>POST /facturas</code></td><td>Alta de factura</td></tr>
<tr><td><code>POST /facturas/anular</code></td><td>Anulación</td></tr>
</table>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════ SLIDE 5 — SEGURIDAD CERTS -->
<div class="slide" id="s5">
<h2>Seguridad — Certificados y tokens</h2>
<div class="cols cols-2">
<div class="card">
<h3>Registro del certificado</h3>
<ul>
<li>El front cifra la contraseña del .p12 con RSA (clave pública de la API)</li>
<li>La API descifra, valida el P12 y lo almacena</li>
<li>Soporta certificados FNMT y ACCV (multi-cert)</li>
<li>La API extrae automáticamente los datos del emisor del propio certificado</li>
<li>Devuelve un <strong>token</strong> de sesión para usar en los envíos</li>
</ul>
</div>
<div class="card">
<h3>Flujo de registro</h3>
<div class="flow" style="flex-direction:column; align-items:flex-start; gap:8px; margin:0">
<div class="flow-step" style="width:100%">Front: selecciona .p12 + contraseña</div>
<div class="flow-arrow" style="padding:0 0 0 20px"></div>
<div class="flow-step" style="width:100%">Front: cifra pass con RSA pública</div>
<div class="flow-arrow" style="padding:0 0 0 20px"></div>
<div class="flow-step" style="width:100%">API: valida, almacena, genera token</div>
<div class="flow-arrow" style="padding:0 0 0 20px"></div>
<div class="flow-step" style="width:100%">BFF: guarda el token para futuros envíos</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════ SLIDE 6 — ESTADO -->
<div class="slide" id="s6">
<h2>Estado actual del proyecto</h2>
<div class="cols cols-2">
<div class="card">
<h3>VerifactuMidAPI (este repo — rama main)</h3>
<table>
<tr><td class="status-ok"></td><td>Alta de facturas con hash encadenado</td></tr>
<tr><td class="status-ok"></td><td>Anulación de facturas</td></tr>
<tr><td class="status-ok"></td><td>Registro y validación de certificados P12</td></tr>
<tr><td class="status-ok"></td><td>Soporte multi-cert (FNMT / ACCV)</td></tr>
<tr><td class="status-ok"></td><td>Fallback local si AEAT no responde</td></tr>
<tr><td class="status-ok"></td><td>TLS 1.2 + auto-fill datos emisor del cert</td></tr>
<tr><td class="status-no"></td><td>Consultas y subsanación (pendiente)</td></tr>
</table>
</div>
<div class="card">
<h3>Integración en el ecosistema (rama verifactu)</h3>
<table>
<tr><td class="status-ok"></td><td>Front: subida del certificado P12</td></tr>
<tr><td class="status-ok"></td><td>Front: cifrado RSA de contraseña</td></tr>
<tr><td class="status-ok"></td><td>Front: recepción y uso del token</td></tr>
<tr><td class="status-ok"></td><td>BFF: guardado del token en backend</td></tr>
<tr><td class="status-wip"></td><td>Pendiente de merge a main</td></tr>
</table>
<div class="note" style="margin-top:16px">La integración completa estaba bloqueada por el problema del certificado en la API, ya resuelto.</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════ SLIDE 8 — PRÓXIMOS PASOS -->
<div class="slide" id="s8">
<h2>Próximos pasos</h2>
<div class="cols cols-2">
<div>
<h3 style="color:#6fcf97; margin-bottom:12px">Inmediatos</h3>
<ul>
<li>Merge de rama <code>verifactu</code> a main en front y BFF</li>
<li>Pruebas end-to-end con el ecosistema completo</li>
<li>Validar con certificado real en entorno de pruebas AEAT</li>
</ul>
</div>
<div>
<h3 style="color:#f2c94c; margin-bottom:12px">Siguientes funcionalidades</h3>
<ul>
<li>Consultas a la AEAT (estado de facturas enviadas)</li>
<li>Subsanación de facturas incorrectas</li>
<li>Panel de estado VeriFactu en el front</li>
</ul>
</div>
</div>
<div class="note" style="margin-top:36px">El núcleo de la integración está completo y probado. El bloqueo original (gestión del certificado) está resuelto.</div>
</div>
<!-- NAV -->
<div class="nav">
<span id="counter">1 / 7</span>
</div>
<script>
const slides = document.querySelectorAll('.slide');
let cur = 0;
function show(n) {
slides[cur].classList.remove('active');
cur = Math.max(0, Math.min(n, slides.length - 1));
slides[cur].classList.add('active');
document.getElementById('counter').textContent = (cur + 1) + ' / ' + slides.length;
}
document.addEventListener('keydown', e => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ') show(cur + 1);
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') show(cur - 1);
if (e.key === 'f' || e.key === 'F') document.documentElement.requestFullscreen?.();
if (e.key === 'Escape' && document.fullscreenElement) document.exitFullscreen?.();
});
document.addEventListener('click', e => {
if (e.target.closest('a, button, code')) return;
const mitad = window.innerWidth / 2;
if (e.clientX >= mitad) show(cur + 1);
else show(cur - 1);
});
</script>
</body>
</html>

View File

@ -0,0 +1,26 @@
import sys
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.hazmat.backends import default_backend
# Try multiple password variants
passwords = [
"Mecedora12@",
"MECEDORA12@",
"mecedora12@",
"53950250R",
"1752317947215",
"MECEDORA12",
]
cert_path = r"D:\Importante\53950250R_JOSEP VICENT_MESTRE__1752317947215 - copia.p12"
for pw in passwords:
try:
with open(cert_path, "rb") as f:
pkcs12.load_key_and_certificates(f.read(), pw.encode(), default_backend())
print(f"OK: Password is '{pw}'")
sys.exit(0)
except Exception as e:
print(f"FAIL: '{pw}' - {e}")
print("None of the passwords worked!")

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Script to generate test certificates for VeriFactu API testing.
Each certificate has a DIFFERENT password for testing purposes.
"""
import datetime
import json
import os
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
PASSWORDS = {
"valid_365days": "password365",
"valid_60days": "password60",
"expired": "password_expired",
"expiring_soon": "password_expiring",
"not_yet_valid": "password_future",
}
base_dir = os.path.join(os.path.dirname(__file__), "certs")
os.makedirs(base_dir, exist_ok=True)
def generate_cert(output_path, password, days_offset, test_name):
private_key = rsa.generate_private_key(65537, 2048, default_backend())
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, test_name),
])
now = datetime.datetime.utcnow()
not_valid_before = now + datetime.timedelta(days=days_offset[0])
not_valid_after = now + datetime.timedelta(days=days_offset[1])
cert = x509.CertificateBuilder().subject_name(subject).issuer_name(
issuer
).public_key(private_key.public_key()).serial_number(
x509.random_serial_number()
).not_valid_before(not_valid_before).not_valid_after(
not_valid_after
).sign(private_key, hashes.SHA256(), default_backend())
from cryptography.hazmat.primitives.serialization import pkcs12
p12_data = pkcs12.serialize_key_and_certificates(
name=test_name.encode(),
key=private_key,
cert=cert,
cas=None,
encryption_algorithm=serialization.BestAvailableEncryption(password.encode())
)
with open(output_path, "wb") as f:
f.write(p12_data)
print(f"[OK] Generated: {os.path.basename(output_path)}")
print(f" Password: {password}")
print(f" Days valid: {days_offset[1] - days_offset[0]}")
return password
print("=" * 60)
print("Generating Certificates with UNIQUE passwords")
print("=" * 60)
print()
generate_cert(os.path.join(base_dir, "valid_365days.p12"), PASSWORDS["valid_365days"], (0, 365), "Valid 365 days")
print()
generate_cert(os.path.join(base_dir, "valid_60days.p12"), PASSWORDS["valid_60days"], (0, 60), "Valid 60 days")
print()
generate_cert(os.path.join(base_dir, "expired.p12"), PASSWORDS["expired"], (-20, -5), "Expired")
print()
generate_cert(os.path.join(base_dir, "expiring_soon.p12"), PASSWORDS["expiring_soon"], (0, 15), "Expiring Soon")
print()
generate_cert(os.path.join(base_dir, "not_yet_valid.p12"), PASSWORDS["not_yet_valid"], (30, 395), "Not Yet Valid")
print()
print("=" * 60)
print("Password Reference:")
print("=" * 60)
for k, v in PASSWORDS.items():
print(f" {k}: {v}")
print()
with open("test_passwords.json", "w") as f:
json.dump(PASSWORDS, f, indent=2)
print("Saved: test_passwords.json")

View File

@ -0,0 +1,27 @@
{
"tipo": "alta",
"factura": {
"emisor_nif": "53950250R",
"num_serie": "FV2026/001",
"fecha_expedicion": "17-04-2026",
"tipo_factura": "F1",
"descripcion": "Factura de prueba",
"destinatario": {
"nombre": "Cliente Test SL",
"nif": "B12345678"
},
"iva": [
{
"base": 100.00,
"cuota": 21.00,
"tipo": 21.0
}
],
"importe_total": 121.00
},
"sistema": {
"nombre": "VeriFactu API",
"nif_proveedor": "53950250R",
"version": "1.0"
}
}

View File

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Main test runner for VeriFactu API certificate validation.
Each test case has its own certificate and password.
"""
import json
import os
import shutil
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(__file__))
from test_simulate import VeriFactuTester
def clear_cert_storage():
"""Clear certificate storage before tests."""
storage_path = "data/certs"
if os.path.exists(storage_path):
try:
shutil.rmtree(storage_path)
except:
pass
os.makedirs(storage_path, exist_ok=True)
def main():
print("=" * 70)
print("VeriFactu API - Certificate Validation Tests")
print("=" * 70)
print()
with open("test_passwords.json", "r") as f:
passwords = json.load(f)
tester = VeriFactuTester()
tests = [
{
"name": "Valid 365 days",
"cert_file": "test/certs/valid_365days.p12",
"password": passwords["valid_365days"],
"expected_success": True,
"expected_error": None,
"expected_warning": False,
"description": "Certificado valido, caduca en 365 dias"
},
{
"name": "Valid 60 days",
"cert_file": "test/certs/valid_60days.p12",
"password": passwords["valid_60days"],
"expected_success": True,
"expected_error": None,
"expected_warning": False,
"description": "Certificado valido, caduca en 60 dias"
},
{
"name": "Expired",
"cert_file": "test/certs/expired.p12",
"password": passwords["expired"],
"expected_success": False,
"expected_error": "certificate_expired",
"expected_warning": False,
"description": "Certificado expirado"
},
{
"name": "Expiring soon",
"cert_file": "test/certs/expiring_soon.p12",
"password": passwords["expiring_soon"],
"expected_success": True,
"expected_error": None,
"expected_warning": True,
"description": "Certificado caduca en menos de 30 dias"
},
{
"name": "Not yet valid",
"cert_file": "test/certs/not_yet_valid.p12",
"password": passwords["not_yet_valid"],
"expected_success": False,
"expected_error": "certificate_not_yet_valid",
"expected_warning": False,
"description": "Certificado no valido aun (fecha futura)"
},
]
passed = 0
failed = 0
print(f"{'#':<3} {'Test':<20} {'Expected':<10} {'Result':<10} {'Status'}")
print("-" * 60)
for i, test in enumerate(tests, 1):
clear_cert_storage()
time.sleep(0.5)
result = tester.test_certificate(
test["cert_file"],
test["password"],
test["expected_success"] and "PASS" or "FAIL",
test["name"]
)
actual_success = result.get("success", False)
actual_error = result.get("error", "")
actual_warning = len(result.get("warnings", [])) > 0
expected_str = "PASS" if test["expected_success"] else "FAIL"
actual_str = "PASS" if actual_success else "FAIL"
passed_test = True
if actual_success != test["expected_success"]:
passed_test = False
if test["expected_error"] and actual_error != test["expected_error"]:
passed_test = False
if test["expected_warning"] != actual_warning:
passed_test = False
if passed_test:
status = "[PASS]"
passed += 1
else:
status = "[FAIL]"
failed += 1
print(f"{i:<3} {test['name']:<20} {expected_str:<10} {actual_str:<10} {status}")
if not passed_test:
print(f" Error: {actual_error or 'none'}")
print(f" Warning: {actual_warning}")
print("-" * 60)
print(f"RESULTS: {passed} passed, {failed} failed")
print("=" * 70)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
Simulation script for VeriFactu API certificate validation.
Simulates a real user making API calls to register certificates.
"""
# ==================== CONFIGURABLE PASSWORD ====================
# THIS MUST MATCH THE PASSWORD IN generate_certs.py
CERT_PASSWORD = "Mecedora12@"
# RUTA DEL CERTIFICADO REAL
REAL_CERT_PATH = r"D:\Importante\53950250R_JOSEP VICENT_MESTRE__1752317947215 - copia.p12"
# ============================================================
import os
import sys
import base64
import json
import datetime
import subprocess
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError
API_URL = "http://localhost:6789"
CERTS_DIR = Path(__file__).parent / "certs"
# Try to import cryptography for RSA encryption
try:
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
def call_api(endpoint, data=None, method="GET"):
"""Make HTTP call to API."""
url = f"{API_URL}{endpoint}"
try:
if method == "GET":
req = Request(url, method="GET")
else:
req = Request(url, data=json.dumps(data).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=10) as response:
return json.loads(response.read().decode())
except URLError as e:
return {"error": str(e)}
except Exception as e:
return {"error": f"API call failed: {e}"}
def get_public_key():
"""Step 1: Get public key from API."""
print("\n" + "=" * 60)
print("STEP 1: Get Public Key from API")
print("=" * 60)
result = call_api("/api/v1/health")
print(f"Health check: {result}")
if "error" in result:
print(f"ERROR: API not running - {result}")
return None
result = call_api("/api/v1/auth/public-key")
if "public_key" not in result:
print(f"ERROR: No public key in response")
return None
pub_key_b64 = result["public_key"]
pub_key = base64.b64decode(pub_key_b64)
print(f"Public Key received (length: {len(pub_key)} bytes)")
return pub_key
def encrypt_password(public_key_pem, password):
"""Step 2: Encrypt password with public key (RSA)."""
print("\n" + "=" * 60)
print("STEP 2: Encrypt Password")
print("=" * 60)
if not HAS_CRYPTO:
print("WARNING: cryptography not available, using base64 (NOT SECURE!)")
return base64.b64encode(password.encode()).decode()
try:
public_key = serialization.load_pem_public_key(public_key_pem, default_backend())
encrypted = public_key.encrypt(
password.encode(),
padding.PKCS1v15()
)
encrypted_b64 = base64.b64encode(encrypted).decode()
print(f"Password encrypted (RSA)")
return encrypted_b64
except Exception as e:
print(f"ERROR encrypting: {e}")
return None
def register_certificate(cert_path, encrypted_password, test_name="default"):
"""Step 3: Register certificate via API."""
print("\n" + "=" * 60)
print("STEP 3: Register Certificate")
print("=" * 60)
print(f"Certificate path: {cert_path}")
print(f"Password (encrypted): {encrypted_password[:40]}...")
data = {
"cert_name": test_name.replace(" ", "_").replace("(", "").replace(")", ""),
"cert_path": cert_path,
"password_encrypted": encrypted_password
}
result = call_api("/api/v1/auth/register", data, method="POST")
return result
def test_certificate(cert_file, password, expected_result, test_name):
"""Test a single certificate via real API calls."""
print(f"\n{'#' * 60}")
print(f"# TEST: {test_name}")
print(f"# Expected: {expected_result}")
print(f"{'#' * 60}")
# Step 1: Get public key
pub_key = get_public_key()
if not pub_key:
print("[X] Cannot get public key - is API running?")
return
# Step 2: Encrypt password
enc_password = encrypt_password(pub_key, password)
if not enc_password:
print("[X] Cannot encrypt password")
return
# Step 3: Register certificate
cert_path = str(CERTS_DIR / cert_file)
result = register_certificate(cert_path, enc_password, test_name)
print(f"\nAPI Response:")
print(json.dumps(result, indent=2))
# Validate result
success = result.get("success", False)
error = result.get("error", "")
warnings = result.get("warnings", [])
if expected_result == "PASS" and success:
print(f"\n[OK] TEST PASSED")
elif expected_result == "FAIL" and not success:
print(f"\n[OK] TEST PASSED (expected failure: {error})")
elif expected_result == "PASS with WARNING" and success and warnings:
print(f"\n[OK] TEST PASSED (with warnings: {warnings})")
else:
print(f"\n[FAIL] TEST FAILED (success={success}, error={error})")
def main():
"""Main test runner."""
print("=" * 60)
print("VeriFactu API - Real User Simulation")
print("=" * 60)
print(f"\nAPI URL: {API_URL}")
print(f"Certs Directory: {CERTS_DIR}")
# Check if API is running
print("\nChecking if API is running...")
result = call_api("/api/v1/health")
if "error" in result:
print("ERROR: API not running!")
print("Start with: ./verifactu.exe")
return
print(f"API is running!")
print("\n" + "=" * 60)
print("Running Tests via Real API Calls")
print("=" * 60)
test_cases = [
("valid_365days.p12", CERT_PASSWORD, "PASS", "Valid certificate (365 days)"),
("valid_60days.p12", CERT_PASSWORD, "PASS", "Valid certificate (60 days)"),
("expired.p12", CERT_PASSWORD, "FAIL", "Expired certificate"),
("expiring_soon.p12", CERT_PASSWORD, "PASS with WARNING", "Expiring soon (15 days)"),
("not_yet_valid.p12", CERT_PASSWORD, "FAIL", "Not yet valid certificate"),
("wrong_password.p12", CERT_PASSWORD, "FAIL", "Wrong password"),
(REAL_CERT_PATH, CERT_PASSWORD, "PASS", "REAL certificate with REAL password"),
]
for cert_file, password, expected, test_name in test_cases:
test_certificate(cert_file, password, expected, test_name)
print("\n" + "=" * 60)
print("ALL TESTS COMPLETED")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,68 @@
import subprocess
import os
p12 = "data/certs/personal.p12"
pwd = "Mecedora12"
cmd = ["python", "convert_cert.py", p12, pwd]
result = subprocess.run(cmd, capture_output=True, text=True)
print(f"Convert: {result.returncode} {result.stdout}")
key = "data/certs/cert_key.pem"
cert = "data/certs/cert_cert.pem"
if os.path.exists(key) and os.path.exists(cert):
print(f"Key size: {os.path.getsize(key)}")
print(f"Cert size: {os.path.getsize(cert)}")
cmd = ["go", "run", "main.go"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
import time
time.sleep(3)
import urllib.request
import json
try:
req = urllib.request.Request("http://localhost:6789/api/v1/health")
with urllib.request.urlopen(req) as resp:
print(f"Health: {resp.read()}")
except Exception as e:
print(f"Health error: {e}")
invoice = {
"tipo": "alta",
"factura": {
"emisor_nif": "53950250R",
"num_serie": "FV2026/FINAL",
"fecha_expedicion": "17-04-2026",
"tipo_factura": "F1",
"descripcion": "Final test",
"iva": [{"base": 100, "cuota": 21, "tipo": 21}],
"importe_total": 121
},
"sistema": {
"nombre": "Test",
"nif_proveedor": "53950250R",
"version": "1.0"
}
}
req = urllib.request.Request(
"http://localhost:6789/api/v1/facturas",
data=json.dumps(invoice).encode(),
method="POST"
)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
print(f"Invoice: {resp.read()}")
except Exception as e:
print(f"Invoice error: {e}")
proc.terminate()
proc.wait()
else:
print("Files not found")

View File

@ -0,0 +1,79 @@
import urllib.request
import urllib.parse
URL = "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP"
soap_request = """<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header/>
<soapenv:Body>
<RegFactuSistemaFacturacion xmlns:sum="SuministroLR">
<sum:Cabecera>
<sum1:ObligadoEmision xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd">
<sum1:NombreRazon>TEST</sum1:NombreRazon>
<sum1:NIF>53950250R</sum1:NIF>
</sum1:ObligadoEmision>
</sum:Cabecera>
<sum:RegistroFactura>
<sum1:RegistroAlta xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd">
<sum1:IDVersion>1.0</sum1:IDVersion>
<sum1:IDFactura>
<sum1:IDEmisorFactura>53950250R</sum1:IDEmisorFactura>
<sum1:NumSerieFactura>FV2026/001</sum1:NumSerieFactura>
<sum1:FechaExpedicionFactura>17-04-2026</sum1:FechaExpedicionFactura>
</sum1:IDFactura>
<sum1:NombreRazonEmisor>TEST EMPRESA</sum1:NombreRazonEmisor>
<sum1:TipoFactura>F1</sum1:TipoFactura>
<sum1:DescripcionOperacion>Factura de prueba</sum1:DescripcionOperacion>
<sum1:Desglose>
<sum1:DetalleDesglose>
<sum1:ClaveRegimen>01</sum1:ClaveRegimen>
<sum1:CalificacionOperacion>S1</sum1:CalificacionOperacion>
<sum1:TipoImpositivo>01</sum1:TipoImpositivo>
<sum1:BaseImponibleOimporteNoSujeto>100.00</sum1:BaseImponibleOimporteNoSujeto>
<sum1:CuotaRepercutida>21.00</sum1:CuotaRepercutida>
</sum1:DetalleDesglose>
</sum1:Desglose>
<sum1:CuotaTotal>21.00</sum1:CuotaTotal>
<sum1:ImporteTotal>121.00</sum1:ImporteTotal>
<sum1:Encadenamiento>
<sum1:PrimerRegistro>S</sum1:PrimerRegistro>
</sum1:Encadenamiento>
<sum1:SistemaInformatico>
<sum1:NombreRazon>TEST</sum1:NombreRazon>
<sum1:NIF>53950250R</sum1:NIF>
<sum1:NombreSistemaInformatico>TEST</sum1:NombreSistemaInformatico>
<sum1:IdSistemaInformatico>1</sum1:IdSistemaInformatico>
<sum1:Version>1.0</sum1:Version>
<sum1:NumeroInstalacion>1</sum1:NumeroInstalacion>
<sum1:TipoUsoPosibleSoloVerifactu>S</sum1:TipoUsoPosibleSoloVerifactu>
</sum1:SistemaInformatico>
<sum1:FechaHoraHusoGenRegistro>17-04-2026T12:00:00</sum1:FechaHoraHusoGenRegistro>
<sum1:TipoHuella>SHA-256</sum1:TipoHuella>
<sum1:Huella>TESTHASH</sum1:Huella>
</sum1:RegistroAlta>
</sum:RegistroFactura>
</RegFactuSistemaFacturacion>
</soapenv:Body>
</soapenv:Envelope>"""
headers = {
"Content-Type": "text/xml; charset=utf-8",
"SOAPAction": ""
}
print("Enviando a AEAT test...")
print(f"URL: {URL}")
req = urllib.request.Request(URL, data=soap_request.encode('utf-8'), headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as response:
print(f"\nStatus: {response.status}")
content = response.read().decode('utf-8')
print(f"\nResponse:\n{content[:1500]}")
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code}")
print(f"Response: {e.read().decode('utf-8')[:1500]}")
except urllib.error.URLError as e:
print(f"URL Error: {e.reason}")

View File

@ -0,0 +1,83 @@
import urllib.request
import urllib.parse
import urllib.error
URL = "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP"
soap_request = """<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header/>
<soapenv:Body>
<RegFactuSistemaFacturacion xmlns:sum="SuministroLR">
<sum:Cabecera>
<sum1:ObligadoEmision xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd">
<sum1:NombreRazon>TEST EMPRESA SL</sum1:NombreRazon>
<sum1:NIF>53950250R</sum1:NIF>
</sum1:ObligadoEmision>
</sum:Cabecera>
<sum:RegistroFactura>
<sum1:RegistroAlta xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd">
<sum1:IDVersion>1.0</sum1:IDVersion>
<sum1:IDFactura>
<sum1:IDEmisorFactura>53950250R</sum1:IDEmisorFactura>
<sum1:NumSerieFactura>FV2026/TEST001</sum1:NumSerieFactura>
<sum1:FechaExpedicionFactura>17-04-2026</sum1:FechaExpedicionFactura>
</sum1:IDFactura>
<sum1:NombreRazonEmisor>TEST EMPRESA SL</sum1:NombreRazonEmisor>
<sum1:TipoFactura>F1</sum1:TipoFactura>
<sum1:DescripcionOperacion>Factura de prueba test</sum1:DescripcionOperacion>
<sum1:Desglose>
<sum1:DetalleDesglose>
<sum1:ClaveRegimen>01</sum1:ClaveRegimen>
<sum1:CalificacionOperacion>S1</sum1:CalificacionOperacion>
<sum1:TipoImpositivo>01</sum1:TipoImpositivo>
<sum1:BaseImponibleOimporteNoSujeto>100.00</sum1:BaseImponibleOimporteNoSujeto>
<sum1:CuotaRepercutida>21.00</sum1:CuotaRepercutida>
</sum1:DetalleDesglose>
</sum1:Desglose>
<sum1:CuotaTotal>21.00</sum1:CuotaTotal>
<sum1:ImporteTotal>121.00</sum1:ImporteTotal>
<sum1:Encadenamiento>
<sum1:PrimerRegistro>S</sum1:PrimerRegistro>
</sum1:Encadenamiento>
<sum1:SistemaInformatico>
<sum1:NombreRazon>TEST API</sum1:NombreRazon>
<sum1:NIF>53950250R</sum1:NIF>
<sum1:NombreSistemaInformatico>TEST-API</sum1:NombreSistemaInformatico>
<sum1:IdSistemaInformatico>1</sum1:IdSistemaInformatico>
<sum1:Version>1.0</sum1:Version>
<sum1:NumeroInstalacion>1</sum1:NumeroInstalacion>
<sum1:TipoUsoPosibleSoloVerifactu>S</sum1:TipoUsoPosibleSoloVerifactu>
</sum1:SistemaInformatico>
<sum1:FechaHoraHusoGenRegistro>17-04-2026T12:00:00</sum1:FechaHoraHusoGenRegistro>
<sum1:TipoHuella>SHA-256</sum1:TipoHuella>
<sum1:Huella>0A1B2C3D4E5F6</sum1:Huella>
</sum1:RegistroAlta>
</sum:RegistroFactura>
</RegFactuSistemaFacturacion>
</soapenv:Body>
</soapenv:Envelope>"""
ctx = urllib.request.ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = urllib.request.ssl.CERT_NONE
headers = {
"Content-Type": "text/xml; charset=utf-8",
"SOAPAction": ""
}
print("Testing AEAT without certificate (just headers)...")
req = urllib.request.Request(URL, data=soap_request.encode('utf-8'), headers=headers)
try:
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx))
response = opener.open(req, timeout=30)
print(f"Status: {response.status}")
print(f"Response: {response.read().decode('utf-8')[:1500]}")
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code}")
print(f"Body: {e.read().decode('utf-8')[:1500]}")
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")

View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Peticion directa al endpoint VeriFactu de la AEAT (sin pasar por la API).
Usa mTLS con los PEM ya convertidos en data/certs/.
"""
import hashlib
from datetime import datetime, timezone
import requests
# Certificado (los PEM no tienen contrasena)
CERT_PEM = "data/certs/cert_cert.pem"
KEY_PEM = "data/certs/cert_key.pem"
# Identidad
NIF = "53950250R"
NOMBRE = "JOSEP VICENT MESTRE LLOBELL"
# Endpoint de pruebas AEAT
URL = "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP"
# Datos de la factura de prueba
NUM_SERIE = "TEST-DIRECTO-003"
FECHA_EXP = "13-05-2026"
TIPO_FACTURA = "F1"
BASE = 100.00
CUOTA = 21.00
TOTAL = 121.00
# Registro anterior (encadenamiento)
PREV_NUM_SERIE = "TEST-DIRECTO-002"
PREV_FECHA_EXP = "13-05-2026"
PREV_HUELLA = "B4F12C2C6407438501BBB5C81A8443E78860CD2D736D614C032CEDB4CC521D90"
# Timestamp generacion (mismo formato para hash y XML)
_now = datetime.now(timezone.utc)
FECHA_GEN = _now.strftime("%Y-%m-%dT%H:%M:%S+00:00")
def calcular_huella(nif, num_serie, fecha_exp, tipo, cuota, total, prev_hash, fecha_gen):
"""SHA-256 segun la especificacion VeriFactu (formato key=value&)."""
campos = (
f"IDEmisorFactura={nif}&"
f"NumSerieFactura={num_serie}&"
f"FechaExpedicionFactura={fecha_exp}&"
f"TipoFactura={tipo}&"
f"CuotaTotal={cuota:.2f}&"
f"ImporteTotal={total:.2f}&"
f"Huella={prev_hash}&"
f"FechaHoraHusoGenRegistro={fecha_gen}"
)
print(f" Campos huella : {campos}")
return hashlib.sha256(campos.encode()).hexdigest().upper()
huella = calcular_huella(NIF, NUM_SERIE, FECHA_EXP, TIPO_FACTURA, CUOTA, TOTAL, PREV_HUELLA, FECHA_GEN)
SOAP = f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:sum="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd"
xmlns:sum1="https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd">
<soapenv:Header/>
<soapenv:Body>
<sum:RegFactuSistemaFacturacion>
<sum:Cabecera>
<sum1:ObligadoEmision>
<sum1:NombreRazon>{NOMBRE}</sum1:NombreRazon>
<sum1:NIF>{NIF}</sum1:NIF>
</sum1:ObligadoEmision>
</sum:Cabecera>
<sum:RegistroFactura>
<sum1:RegistroAlta>
<sum1:IDVersion>1.0</sum1:IDVersion>
<sum1:IDFactura>
<sum1:IDEmisorFactura>{NIF}</sum1:IDEmisorFactura>
<sum1:NumSerieFactura>{NUM_SERIE}</sum1:NumSerieFactura>
<sum1:FechaExpedicionFactura>{FECHA_EXP}</sum1:FechaExpedicionFactura>
</sum1:IDFactura>
<sum1:NombreRazonEmisor>{NOMBRE}</sum1:NombreRazonEmisor>
<sum1:TipoFactura>{TIPO_FACTURA}</sum1:TipoFactura>
<sum1:DescripcionOperacion>Factura de prueba directa</sum1:DescripcionOperacion>
<sum1:Destinatarios>
<sum1:IDDestinatario>
<sum1:NombreRazon>{NOMBRE}</sum1:NombreRazon>
<sum1:NIF>{NIF}</sum1:NIF>
</sum1:IDDestinatario>
</sum1:Destinatarios>
<sum1:Desglose>
<sum1:DetalleDesglose>
<sum1:ClaveRegimen>01</sum1:ClaveRegimen>
<sum1:CalificacionOperacion>S1</sum1:CalificacionOperacion>
<sum1:TipoImpositivo>21.00</sum1:TipoImpositivo>
<sum1:BaseImponibleOimporteNoSujeto>{BASE:.2f}</sum1:BaseImponibleOimporteNoSujeto>
<sum1:CuotaRepercutida>{CUOTA:.2f}</sum1:CuotaRepercutida>
</sum1:DetalleDesglose>
</sum1:Desglose>
<sum1:CuotaTotal>{CUOTA:.2f}</sum1:CuotaTotal>
<sum1:ImporteTotal>{TOTAL:.2f}</sum1:ImporteTotal>
<sum1:Encadenamiento>
<sum1:RegistroAnterior>
<sum1:IDEmisorFactura>{NIF}</sum1:IDEmisorFactura>
<sum1:NumSerieFactura>{PREV_NUM_SERIE}</sum1:NumSerieFactura>
<sum1:FechaExpedicionFactura>{PREV_FECHA_EXP}</sum1:FechaExpedicionFactura>
<sum1:Huella>{PREV_HUELLA}</sum1:Huella>
</sum1:RegistroAnterior>
</sum1:Encadenamiento>
<sum1:SistemaInformatico>
<sum1:NombreRazon>{NOMBRE}</sum1:NombreRazon>
<sum1:NIF>{NIF}</sum1:NIF>
<sum1:NombreSistemaInformatico>VerifactuMidAPI</sum1:NombreSistemaInformatico>
<sum1:IdSistemaInformatico>01</sum1:IdSistemaInformatico>
<sum1:Version>1.0.0</sum1:Version>
<sum1:NumeroInstalacion>1</sum1:NumeroInstalacion>
<sum1:TipoUsoPosibleSoloVerifactu>S</sum1:TipoUsoPosibleSoloVerifactu>
<sum1:TipoUsoPosibleMultiOT>N</sum1:TipoUsoPosibleMultiOT>
<sum1:IndicadorMultiplesOT>N</sum1:IndicadorMultiplesOT>
</sum1:SistemaInformatico>
<sum1:FechaHoraHusoGenRegistro>{FECHA_GEN}</sum1:FechaHoraHusoGenRegistro>
<sum1:TipoHuella>01</sum1:TipoHuella>
<sum1:Huella>{huella}</sum1:Huella>
</sum1:RegistroAlta>
</sum:RegistroFactura>
</sum:RegFactuSistemaFacturacion>
</soapenv:Body>
</soapenv:Envelope>"""
print(f"\n{'='*60}")
print(f"Endpoint : {URL}")
print(f"NIF : {NIF}")
print(f"Num serie : {NUM_SERIE}")
print(f"Huella : {huella}")
print(f"Fecha gen : {FECHA_GEN}")
print(f"{'='*60}\n")
try:
resp = requests.post(
URL,
data=SOAP.encode("utf-8"),
headers={"Content-Type": "text/xml; charset=utf-8", "SOAPAction": ""},
cert=(CERT_PEM, KEY_PEM),
verify=True,
timeout=30,
)
print(f"HTTP Status : {resp.status_code}")
print(f"\nRespuesta AEAT:\n{resp.text}")
except requests.exceptions.SSLError as e:
print(f"[ERROR SSL] Problema con el certificado: {e}")
except requests.exceptions.ConnectionError as e:
print(f"[ERROR CONEXION] No se pudo conectar: {e}")
except Exception as e:
print(f"[ERROR] {type(e).__name__}: {e}")

View File

@ -0,0 +1,51 @@
import base64
import json
from urllib.request import urlopen, Request
from urllib.error import URLError
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
API_URL = "http://localhost:6789"
print("=" * 60)
print("Test: Enviar Factura")
print("=" * 60)
invoice = {
"tipo": "alta",
"factura": {
"emisor_nif": "53950250R",
"num_serie": "FV2026/001",
"fecha_expedicion": "17-04-2026",
"tipo_factura": "F1",
"descripcion": "Factura de prueba",
"iva": [
{"base": 100.00, "cuota": 21.00, "tipo": 21.0}
],
"importe_total": 121.00
},
"sistema": {
"nombre": "VeriFactu API",
"nif_proveedor": "53950250R",
"version": "1.0"
}
}
print("\nEnviando factura...")
req = Request(
f"{API_URL}/api/v1/facturas",
data=json.dumps(invoice).encode(),
method="POST"
)
req.add_header("Content-Type", "application/json")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
print(json.dumps(result, indent=2))
except URLError as e:
print(f"Error: {e}")
except Exception as e:
print(f"Error: {e}")

View File

@ -0,0 +1,20 @@
import subprocess
import os
p12 = "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\data\\certs\\personal.p12"
pwd = "Mecedora12"
out = "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\data\\certs\\combined.pem"
cmd = f'openssl pkcs12 -in "{p12}" -passin pass:{pwd} -nodes -out "{out}"'
print(f"Running: {cmd}")
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
print(f"Return code: {result.returncode}")
if result.returncode != 0:
print(f"Error: {result.stderr}")
else:
print(f"Success! Output: {out}")
print(f"File size: {os.path.getsize(out)}")
except Exception as e:
print(f"Exception: {e}")

View File

@ -0,0 +1,41 @@
import base64
import json
from urllib.request import urlopen, Request
from urllib.error import URLError
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
API_URL = "http://localhost:6789"
# Get public key
req = Request(f"{API_URL}/api/v1/auth/public-key", method="GET")
with urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
pub_key_pem = base64.b64decode(result["public_key"])
# Encrypt password
public_key = serialization.load_pem_public_key(pub_key_pem, default_backend())
password = "Mecedora12"
encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15())
encrypted_b64 = base64.b64encode(encrypted).decode()
# Register certificate
data = {
"cert_name": "personal",
"cert_path": "C:/Users/jmest/GolandProjects/VerifactuMidAPI/data/certs/personal.p12",
"password_encrypted": encrypted_b64
}
req = Request(
f"{API_URL}/api/v1/auth/register",
data=json.dumps(data).encode(),
method="POST"
)
req.add_header("Content-Type", "application/json")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
print(json.dumps(result, indent=2))
except URLError as e:
print(f"Error: {e}")

View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Test infrastructure for VeriFactu API certificate validation.
"""
import base64
import json
import os
import sys
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError
API_URL = "http://localhost:6789"
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
class VeriFactuTester:
def __init__(self):
self.api_url = API_URL
self.certs_dir = Path(__file__).parent / "certs"
def check_health(self):
"""Check if API is running."""
try:
req = Request(f"{self.api_url}/api/v1/health", method="GET")
with urlopen(req, timeout=5) as response:
return json.loads(response.read().decode())
except:
return None
def get_public_key(self):
"""Get public key from API."""
try:
req = Request(f"{self.api_url}/api/v1/auth/public-key", method="GET")
with urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return base64.b64decode(result["public_key"])
except Exception as e:
print(f"ERROR getting public key: {e}")
return None
def encrypt_password(self, public_key_pem, password):
"""Encrypt password with public key."""
if not HAS_CRYPTO:
print("WARNING: cryptography not available")
return base64.b64encode(password.encode()).decode()
try:
public_key = serialization.load_pem_public_key(public_key_pem, default_backend())
encrypted = public_key.encrypt(
password.encode(),
padding.PKCS1v15()
)
return base64.b64encode(encrypted).decode()
except Exception as e:
print(f"ERROR encrypting password: {e}")
return None
def register_certificate(self, cert_path, encrypted_password, cert_name):
"""Register certificate via API."""
data = {
"cert_name": cert_name,
"cert_path": cert_path,
"password_encrypted": encrypted_password
}
try:
req = Request(
f"{self.api_url}/api/v1/auth/register",
data=json.dumps(data).encode(),
method="POST"
)
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=30) as response:
return json.loads(response.read().decode())
except URLError as e:
return {"error": str(e), "success": False}
except Exception as e:
return {"error": str(e), "success": False}
def test_certificate(self, cert_file, password, expected_result, test_name):
"""Test a single certificate."""
print(f"\n--- Testing: {test_name} ---")
pub_key = self.get_public_key()
if not pub_key:
print("ERROR: Cannot get public key")
return False
enc_password = self.encrypt_password(pub_key, password)
if not enc_password:
print("ERROR: Cannot encrypt password")
return False
result = self.register_certificate(cert_file, enc_password, test_name)
print(f"API Response: {json.dumps(result, indent=2)}")
return result
if __name__ == "__main__":
print("This module should be imported, not run directly.")
print("Use: python test/run_tests.py")

View File

@ -0,0 +1,55 @@
import base64
import json
from urllib.request import urlopen, Request
from urllib.error import URLError
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
API_URL = "http://localhost:6789"
print("=" * 60)
print("Test: Enviar Factura con Validation")
print("=" * 60)
invoice = {
"tipo": "alta",
"factura": {
"emisor_nif": "53950250R",
"num_serie": "FV2026/001",
"fecha_expedicion": "17-04-2026",
"tipo_factura": "F1",
"descripcion": "Factura de prueba",
"destinatario": {
"nombre": "Cliente Test SL",
"nif": "B12345678"
},
"iva": [
{"base": 100.00, "cuota": 21.00, "tipo": 21.0}
],
"importe_total": 121.00
},
"sistema": {
"nombre": "VeriFactu API",
"nif_proveedor": "53950250R",
"version": "1.0"
}
}
print("\nEnviando factura...")
req = Request(
f"{API_URL}/api/v1/facturas",
data=json.dumps(invoice).encode(),
method="POST"
)
req.add_header("Content-Type", "application/json")
try:
with urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
print(json.dumps(result, indent=2))
except URLError as e:
print(f"Error HTTP: {e}")
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")

View File

@ -0,0 +1,44 @@
import sys
import datetime
import os
from cryptography import x509
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.hazmat.backends import default_backend
cert_path = sys.argv[1]
password = sys.argv[2]
try:
if not os.path.exists(cert_path):
print("NOT_FOUND")
sys.exit(1)
with open(cert_path, "rb") as f:
p12_data = f.read()
private_key, cert, additional_certs = pkcs12.load_key_and_certificates(
p12_data, password.encode(), default_backend()
)
now = datetime.datetime.now(datetime.timezone.utc)
not_after = cert.not_valid_after_utc.replace(tzinfo=datetime.timezone.utc)
not_before = cert.not_valid_before_utc.replace(tzinfo=datetime.timezone.utc)
if now > not_after:
print("EXPIRED")
sys.exit(1)
if now < not_before:
print("NOT_YET_VALID")
sys.exit(1)
days_until = (not_after - now).days
print(f"OK:{days_until}")
except FileNotFoundError:
print("NOT_FOUND")
sys.exit(1)
except Exception as e:
print("INVALID")
sys.exit(1)

View File

@ -0,0 +1,259 @@
package verifactu
import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"VerifactuMidAPI/internal/cert"
)
type Client struct {
BaseURL string
HTTPClient *http.Client
Certificate *tls.Certificate
}
type ClientConfig struct {
BaseURL string
Timeout time.Duration
CertificatePath string
CertificatePassword string
}
func NewClient(cfg ClientConfig) (*Client, error) {
httpClient := &http.Client{
Timeout: cfg.Timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: false,
},
},
}
var tlsCert *tls.Certificate
if cfg.CertificatePath != "" {
c, err := LoadCertificate(cfg.CertificatePath, cfg.CertificatePassword)
if err != nil {
return nil, fmt.Errorf("loading certificate: %w", err)
}
tlsCert = c
}
return &Client{
BaseURL: cfg.BaseURL,
HTTPClient: httpClient,
Certificate: tlsCert,
}, nil
}
func LoadCertificate(certPath, password string) (*tls.Certificate, error) {
der, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("reading cert file: %w", err)
}
return LoadCertificateFromBytes(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, x509Cert, chain, err := cert.DecodeLeafAndKey(der, password)
if err != nil {
return nil, fmt.Errorf("decoding PKCS#12: %w", err)
}
if x509Cert == nil {
return nil, fmt.Errorf("no certificate found in PKCS#12")
}
tlsCert := &tls.Certificate{
Certificate: [][]byte{x509Cert.Raw},
PrivateKey: key,
Leaf: x509Cert,
}
for _, c := range chain {
tlsCert.Certificate = append(tlsCert.Certificate, c.Raw)
}
log.Printf("cert loaded: subject=%s, expires=%s, chain=%d", x509Cert.Subject.CommonName, x509Cert.NotAfter.Format("2006-01-02"), len(chain))
return tlsCert, nil
}
func (c *Client) SetCertificate(certPath, password string) error {
tlsCert, err := LoadCertificate(certPath, password)
if err != nil {
return err
}
c.Certificate = tlsCert
return nil
}
func (c *Client) SendAlta(data AltaData) (*Response, error) {
env, err := BuildAltaSOAPRequest(data)
if err != nil {
return nil, fmt.Errorf("building Alta request: %w", err)
}
if xmlBytes, err2 := env.ToBytes(); err2 == nil {
log.Printf("[SOAP-Alta] XML generado (%d bytes):\n%s", len(xmlBytes), string(xmlBytes))
}
return c.SendRequest(env)
}
func (c *Client) SendAnulacion(data AltaData) (*Response, error) {
env, err := BuildAnulacionSOAPRequest(data)
if err != nil {
return nil, fmt.Errorf("building Anulacion request: %w", err)
}
return c.SendRequest(env)
}
func (c *Client) SendRequest(env *SOAPEnvelope) (*Response, error) {
body, err := env.ToBytes()
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
req, err := http.NewRequest("POST", c.BaseURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "text/xml; charset=utf-8")
req.Header.Set("SOAPAction", "")
if c.Certificate != nil {
log.Printf("Using client certificate with TLS")
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{*c.Certificate},
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12,
}
c.HTTPClient.Transport = &http.Transport{
TLSClientConfig: tlsConfig,
}
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
log.Printf("AEAT response status: %d", resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP error: %d - %s", resp.StatusCode, string(respBody))
}
response, err := ParseResponse(respBody)
if err != nil {
log.Printf("[AEAT] Respuesta no es SOAP:\n%s", string(respBody))
return nil, fmt.Errorf("parsing response: %w", err)
}
return response, nil
}
// CertSubject extrae el NIF y nombre del emisor del certificado cargado.
func (c *Client) CertSubject() (nif, nombre string) {
if c.Certificate == nil || c.Certificate.Leaf == nil {
return "", ""
}
subj := c.Certificate.Leaf.Subject
nif = normalizeNIF(subj.SerialNumber)
// Fallback: buscar NIF: en el CommonName (ej. ACCV: "NOMBRE - NIF:12345678A")
if nif == "" {
nif = extractNIFFromCN(subj.CommonName)
}
if len(subj.Organization) > 0 {
nombre = subj.Organization[0]
} else {
// Usar la parte del CN antes del separador, sin el NIF
nombre = cleanNombre(subj.CommonName)
}
return
}
func normalizeNIF(serial string) string {
s := strings.ToUpper(strings.TrimSpace(serial))
for _, prefix := range []string{"IDCES-", "VATES-", "ES"} {
if strings.HasPrefix(s, prefix) {
return s[len(prefix):]
}
}
return s
}
// extractNIFFromCN busca patrones como "NIF:12345678A" o "- 12345678A" en el CN.
func extractNIFFromCN(cn string) string {
upper := strings.ToUpper(cn)
// Patrón "NIF:XXXXXXXXX"
if idx := strings.Index(upper, "NIF:"); idx != -1 {
rest := strings.TrimSpace(cn[idx+4:])
end := strings.IndexAny(rest, " ,;)")
if end == -1 {
end = len(rest)
}
return strings.ToUpper(rest[:end])
}
return ""
}
// cleanNombre devuelve el CN sin el fragmento del NIF.
func cleanNombre(cn string) string {
if idx := strings.Index(strings.ToUpper(cn), " - NIF:"); idx != -1 {
return strings.TrimSpace(cn[:idx])
}
return cn
}
func (c *Client) SetRootCA(caPath string) error {
caData, err := os.ReadFile(caPath)
if err != nil {
return fmt.Errorf("reading CA file: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caData)
if transport, ok := c.HTTPClient.Transport.(*http.Transport); ok {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.RootCAs = pool
}
return nil
}
func GetTestURL() string {
return "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP"
}
func GetProdURL() string {
return "https://www1.agenciatributaria.gob.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP"
}

View File

@ -0,0 +1,169 @@
package verifactu
import (
"bytes"
"encoding/xml"
"fmt"
"strings"
"time"
)
type SOAPEnvelope struct {
XMLName xml.Name `xml:"soap:Envelope"`
XmlnsSOAP string `xml:"xmlns:soap,attr"`
XmlnsSUM string `xml:"xmlns:sum,attr"`
XmlnsSUM1 string `xml:"xmlns:sum1,attr"`
Header SOAPHeader
Body SOAPBody
}
type SOAPHeader struct {
XMLName xml.Name `xml:"soap:Header"`
Security *SecurityHeader
Transaction string `xml:"wsa:Action,omitempty"`
}
type SecurityHeader struct {
XMLName xml.Name `xml:"wsse:Security"`
XmlnsWSSE string `xml:"xmlns:wsse,attr"`
XmlnsWSSEDSIG string `xml:"xmlns:wssedsig,attr"`
BinarySecurityToken *BinarySecurityToken
Signature *Signature
}
type BinarySecurityToken struct {
XMLName xml.Name `xml:"wsse:BinarySecurityToken"`
EncodingType string `xml:"wsse:EncodingType,attr"`
ValueType string `xml:"wsse:ValueType,attr"`
Id string `xml:"wsu:Id,attr"`
Content string `xml:",chardata"`
}
type Signature struct {
XMLName xml.Name `xml:"ds:Signature"`
XmlnsDS string `xml:"xmlns:ds,attr"`
Id string `xml:"Id,attr"`
SignedInfo SignedInfo
SignatureValue string `xml:"ds:SignatureValue"`
KeyInfo KeyInfo
}
type SignedInfo struct {
XMLName xml.Name `xml:"ds:SignedInfo"`
CanonicalizationMethod CanonicalMethod
SignatureMethod SignatureMethod
References []Reference
}
type CanonicalMethod struct {
XMLName xml.Name `xml:"ds:CanonicalizationMethod"`
Algorithm string `xml:"Algorithm,attr"`
}
type SignatureMethod struct {
XMLName xml.Name `xml:"ds:SignatureMethod"`
Algorithm string `xml:"Algorithm,attr"`
}
type Reference struct {
XMLName xml.Name `xml:"ds:Reference"`
URI string `xml:"URI,attr"`
DigestMethod DigestMethod
DigestValue string `xml:"ds:DigestValue"`
}
type DigestMethod struct {
XMLName xml.Name `xml:"ds:DigestMethod"`
Algorithm string `xml:"Algorithm,attr"`
}
type KeyInfo struct {
XMLName xml.Name `xml:"ds:KeyInfo"`
SecurityTokenRef SecurityTokenRef
}
type SecurityTokenRef struct {
XMLName xml.Name `xml:"wsse:SecurityTokenReference"`
Id string `xml:"Id,attr"`
Reference Reference2
}
type Reference2 struct {
XMLName xml.Name `xml:"wsse:Reference"`
URI string `xml:"URI,attr"`
ValueType string `xml:"ValueType,attr"`
}
type SOAPBody struct {
XMLName xml.Name `xml:"soap:Body"`
Content interface{} `xml:",any"`
}
func BuildSOAPEnvelope(payload interface{}) *SOAPEnvelope {
return &SOAPEnvelope{
XmlnsSOAP: "http://schemas.xmlsoap.org/soap/envelope/",
XmlnsSUM: "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroLR.xsd",
XmlnsSUM1: "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd",
Body: SOAPBody{
Content: payload,
},
}
}
func (e *SOAPEnvelope) ToXML() (string, error) {
var buf bytes.Buffer
enc := xml.NewEncoder(&buf)
err := enc.Encode(e)
if err != nil {
return "", fmt.Errorf("encoding SOAP envelope: %w", err)
}
return buf.String(), nil
}
func (e *SOAPEnvelope) ToBytes() ([]byte, error) {
return xml.Marshal(e)
}
type EnviarFacturaRequest struct {
AltaReq *AltaRequest
}
type EnviarAnulacionRequest struct {
AnulReq *AnulacionRequest
}
func BuildAltaSOAPRequest(data AltaData) (*SOAPEnvelope, error) {
return BuildSOAPEnvelope(BuildAltaRequest(data)), nil
}
func BuildAnulacionSOAPRequest(data AltaData) (*SOAPEnvelope, error) {
return BuildSOAPEnvelope(BuildAnulacionRequest(data)), nil
}
func ParseResponse(data []byte) (*Response, error) {
var env Response
err := xml.Unmarshal(data, &env)
if err != nil {
return nil, fmt.Errorf("unmarshaling response: %w", err)
}
return &env, nil
}
func FormatDateDDMMYYYY(date time.Time) string {
return date.Format("02-01-2006")
}
func FormatDateTimeDDMMYYYYHHMM(date time.Time) string {
return date.Format("02-01-2006T15:04:05")
}
func StripXMLNamespace(data []byte) []byte {
str := string(data)
str = strings.ReplaceAll(str, "xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"", "")
str = strings.ReplaceAll(str, "xmlns:sum=\"https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/information.xsd\"", "")
str = strings.ReplaceAll(str, "xmlns:sum1=\"https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/suministroInformacion.xsd\"", "")
return []byte(str)
}

View File

@ -0,0 +1,322 @@
package verifactu
import (
"encoding/xml"
"fmt"
"time"
)
type AltaRequest struct {
XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"`
Cabecera Cabecera `xml:"sum:Cabecera"`
RegistroFactura AltaRegistroFactura `xml:"sum:RegistroFactura"`
}
type AltaRegistroFactura struct {
RegistroAlta RegistroAlta `xml:"sum1:RegistroAlta"`
}
type Cabecera struct {
ObligadoEmision ObligadoEmision `xml:"sum1:ObligadoEmision"`
}
type ObligadoEmision struct {
NombreRazon string `xml:"sum1:NombreRazon"`
NIF string `xml:"sum1:NIF"`
}
type RegistroAlta struct {
IDVersion string `xml:"sum1:IDVersion"`
IDFactura IDFactura `xml:"sum1:IDFactura"`
NombreRazonEmisor string `xml:"sum1:NombreRazonEmisor"`
TipoFactura string `xml:"sum1:TipoFactura"`
DescripcionOperacion string `xml:"sum1:DescripcionOperacion"`
Destinatarios *Destinatarios `xml:"sum1:Destinatarios,omitempty"`
Desglose Desglose `xml:"sum1:Desglose"`
CuotaTotal string `xml:"sum1:CuotaTotal"`
ImporteTotal string `xml:"sum1:ImporteTotal"`
Encadenamiento Encadenamiento `xml:"sum1:Encadenamiento"`
SistemaInformatico SistemaInformatico `xml:"sum1:SistemaInformatico"`
FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"`
TipoHuella string `xml:"sum1:TipoHuella"`
Huella string `xml:"sum1:Huella"`
}
type IDFactura struct {
IDEmisorFactura string `xml:"sum1:IDEmisorFactura"`
NumSerieFactura string `xml:"sum1:NumSerieFactura"`
FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"`
}
type Destinatarios struct {
IDDestinatario []IDDestinatario `xml:"sum1:IDDestinatario"`
}
type IDDestinatario struct {
NombreRazon string `xml:"sum1:NombreRazon"`
NIF string `xml:"sum1:NIF"`
}
type Desglose struct {
DetalleDesglose []DetalleDesglose `xml:"sum1:DetalleDesglose"`
}
type DetalleDesglose struct {
ClaveRegimen string `xml:"sum1:ClaveRegimen"`
CalificacionOperacion string `xml:"sum1:CalificacionOperacion"`
TipoImpositivo string `xml:"sum1:TipoImpositivo"`
BaseImponibleOimporteNoSujeto string `xml:"sum1:BaseImponibleOimporteNoSujeto"`
CuotaRepercutida string `xml:"sum1:CuotaRepercutida"`
}
type Encadenamiento struct {
PrimerRegistro string `xml:"sum1:PrimerRegistro,omitempty"`
RegistroAnterior *RegistroAnterior `xml:"sum1:RegistroAnterior,omitempty"`
}
type RegistroAnterior struct {
IDEmisorFactura string `xml:"sum1:IDEmisorFactura"`
NumSerieFactura string `xml:"sum1:NumSerieFactura"`
FechaExpedicionFactura string `xml:"sum1:FechaExpedicionFactura"`
Huella string `xml:"sum1:Huella"`
}
type SistemaInformatico struct {
NombreRazon string `xml:"sum1:NombreRazon"`
NIF string `xml:"sum1:NIF"`
NombreSistemaInformatico string `xml:"sum1:NombreSistemaInformatico"`
IdSistemaInformatico string `xml:"sum1:IdSistemaInformatico"`
Version string `xml:"sum1:Version"`
NumeroInstalacion string `xml:"sum1:NumeroInstalacion"`
TipoUsoPosibleSoloVerifactu string `xml:"sum1:TipoUsoPosibleSoloVerifactu"`
TipoUsoPosibleMultiOT string `xml:"sum1:TipoUsoPosibleMultiOT"`
IndicadorMultiplesOT string `xml:"sum1:IndicadorMultiplesOT"`
}
type AnulacionRequest struct {
XMLName xml.Name `xml:"sum:RegFactuSistemaFacturacion"`
Cabecera Cabecera `xml:"sum:Cabecera"`
RegistroFactura AnulacionRegistroFactura `xml:"sum:RegistroFactura"`
}
type AnulacionRegistroFactura struct {
RegistroAnulacion RegistroAnulacion `xml:"sum1:RegistroAnulacion"`
}
type RegistroAnulacion struct {
IDVersion string `xml:"sum1:IDVersion"`
IDFacturaAnulada IDFacturaAnulada `xml:"sum1:IDFacturaAnulada"`
Encadenamiento Encadenamiento `xml:"sum1:Encadenamiento"`
SistemaInformatico SistemaInformatico `xml:"sum1:SistemaInformatico"`
FechaHoraHusoGenRegistro string `xml:"sum1:FechaHoraHusoGenRegistro"`
TipoHuella string `xml:"sum1:TipoHuella"`
Huella string `xml:"sum1:Huella"`
}
type IDFacturaAnulada struct {
IDEmisorFacturaAnulada string `xml:"sum1:IDEmisorFacturaAnulada"`
NumSerieFacturaAnulada string `xml:"sum1:NumSerieFacturaAnulada"`
FechaExpedicionFacturaAnulada string `xml:"sum1:FechaExpedicionFacturaAnulada"`
}
type Response struct {
XMLName xml.Name `xml:"Envelope"`
Body ResponseBody
}
type ResponseBody struct {
XMLName xml.Name `xml:"Body"`
Fault *Fault `xml:"Fault"`
Respuesta *RespuestaRegFactu `xml:"RespuestaRegFactuSistemaFacturacion"`
}
type Fault struct {
FaultCode string `xml:"faultcode"`
FaultString string `xml:"faultstring"`
}
type RespuestaRegFactu struct {
CSV string `xml:"CSV"`
EstadoEnvio string `xml:"EstadoEnvio"`
RespuestaLineas []RespuestaLinea `xml:"RespuestaLinea"`
}
type RespuestaLinea struct {
EstadoRegistro string `xml:"EstadoRegistro"`
CodigoError string `xml:"CodigoErrorRegistro"`
DescripcionError string `xml:"DescripcionErrorRegistro"`
}
type AltaData struct {
EmisorNombre string
EmisorNIF string
NumSerie string
FechaExpedicion time.Time
TipoFactura string
Descripcion string
DestinatarioNombre string
DestinatarioNIF string
IVA []IVARegularizacionData
CuotaTotal float64
ImporteTotal float64
Sistema SistemaData
Huella string
FechaGen time.Time
PrevHash string
PrevNumSerie string
PrevFecha time.Time
}
type IVARegularizacionData struct {
Base float64
Cuota float64
Tipo float64
ClaveRegimen string
Calificacion string
}
type SistemaData struct {
Nombre string
NIFProveedor string
NombreSistema string
Version string
NumeroInstalacion string
TipoUsoVerifactu string
}
func BuildAltaRequest(data AltaData) *AltaRequest {
fechaExp := data.FechaExpedicion.Format("02-01-2006")
fechaGen := data.FechaGen.Format("2006-01-02T15:04:05-07:00")
ivaList := make([]DetalleDesglose, len(data.IVA))
for i, iva := range data.IVA {
ivaList[i] = DetalleDesglose{
ClaveRegimen: iva.ClaveRegimen,
CalificacionOperacion: iva.Calificacion,
TipoImpositivo: fmt.Sprintf("%.2f", iva.Tipo),
BaseImponibleOimporteNoSujeto: fmt.Sprintf("%.2f", iva.Base),
CuotaRepercutida: fmt.Sprintf("%.2f", iva.Cuota),
}
}
var enc Encadenamiento
if data.PrevHash == "" {
enc = Encadenamiento{PrimerRegistro: "S"}
} else {
enc = Encadenamiento{
RegistroAnterior: &RegistroAnterior{
IDEmisorFactura: data.EmisorNIF,
NumSerieFactura: data.PrevNumSerie,
FechaExpedicionFactura: data.PrevFecha.Format("02-01-2006"),
Huella: data.PrevHash,
},
}
}
var destinatarios *Destinatarios
if data.DestinatarioNIF != "" {
destinatarios = &Destinatarios{
IDDestinatario: []IDDestinatario{
{NombreRazon: data.DestinatarioNombre, NIF: data.DestinatarioNIF},
},
}
}
req := &AltaRequest{
Cabecera: Cabecera{
ObligadoEmision: ObligadoEmision{
NombreRazon: data.EmisorNombre,
NIF: data.EmisorNIF,
},
},
RegistroFactura: AltaRegistroFactura{
RegistroAlta: RegistroAlta{
IDVersion: "1.0",
IDFactura: IDFactura{
IDEmisorFactura: data.EmisorNIF,
NumSerieFactura: data.NumSerie,
FechaExpedicionFactura: fechaExp,
},
NombreRazonEmisor: data.EmisorNombre,
TipoFactura: data.TipoFactura,
DescripcionOperacion: data.Descripcion,
Destinatarios: destinatarios,
Desglose: Desglose{
DetalleDesglose: ivaList,
},
CuotaTotal: fmt.Sprintf("%.2f", data.CuotaTotal),
ImporteTotal: fmt.Sprintf("%.2f", data.ImporteTotal),
Encadenamiento: enc,
SistemaInformatico: SistemaInformatico{
NombreRazon: data.Sistema.Nombre,
NIF: data.Sistema.NIFProveedor,
NombreSistemaInformatico: data.Sistema.NombreSistema,
IdSistemaInformatico: "01",
Version: data.Sistema.Version,
NumeroInstalacion: data.Sistema.NumeroInstalacion,
TipoUsoPosibleSoloVerifactu: data.Sistema.TipoUsoVerifactu,
TipoUsoPosibleMultiOT: "N",
IndicadorMultiplesOT: "N",
},
FechaHoraHusoGenRegistro: fechaGen,
TipoHuella: "01",
Huella: data.Huella,
},
},
}
return req
}
func BuildAnulacionRequest(data AltaData) *AnulacionRequest {
fechaExp := data.FechaExpedicion.Format("02-01-2006")
fechaGen := data.FechaGen.Format("2006-01-02T15:04:05-07:00")
var enc Encadenamiento
if data.PrevHash == "" {
enc = Encadenamiento{PrimerRegistro: "S"}
} else {
enc = Encadenamiento{
RegistroAnterior: &RegistroAnterior{
IDEmisorFactura: data.EmisorNIF,
NumSerieFactura: data.PrevNumSerie,
FechaExpedicionFactura: data.PrevFecha.Format("02-01-2006"),
Huella: data.PrevHash,
},
}
}
req := &AnulacionRequest{
Cabecera: Cabecera{
ObligadoEmision: ObligadoEmision{
NombreRazon: data.EmisorNombre,
NIF: data.EmisorNIF,
},
},
RegistroFactura: AnulacionRegistroFactura{RegistroAnulacion: RegistroAnulacion{
IDVersion: "1.0",
IDFacturaAnulada: IDFacturaAnulada{
IDEmisorFacturaAnulada: data.EmisorNIF,
NumSerieFacturaAnulada: data.NumSerie,
FechaExpedicionFacturaAnulada: fechaExp,
},
Encadenamiento: enc,
SistemaInformatico: SistemaInformatico{
NombreRazon: data.Sistema.Nombre,
NIF: data.Sistema.NIFProveedor,
NombreSistemaInformatico: data.Sistema.NombreSistema,
IdSistemaInformatico: "01",
Version: data.Sistema.Version,
NumeroInstalacion: data.Sistema.NumeroInstalacion,
TipoUsoPosibleSoloVerifactu: data.Sistema.TipoUsoVerifactu,
TipoUsoPosibleMultiOT: "N",
IndicadorMultiplesOT: "N",
},
FechaHoraHusoGenRegistro: fechaGen,
TipoHuella: "01",
Huella: data.Huella,
}},
}
return req
}

@ -1 +0,0 @@
Subproject commit 7c3b05f2dae87dddb3ca9d704539dc7a063e9c0e

26
doli-front/.gitignore vendored Executable file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
bun.lock

103
doli-front/README.md Normal file
View File

@ -0,0 +1,103 @@
# doli-front
SPA (Single Page Application) para la gestión empresarial integrada con Dolibarr.
Desarrollada en Vanilla JS con Vite 7, sin frameworks frontend.
## Tecnologías
- **Vanilla JS** con ES Modules
- **Vite 7** — bundler y servidor de desarrollo
- **Chart.js** — gráficas del dashboard
- **@huggingface/transformers** — asistente de voz (Whisper, ejecución local en el navegador)
## Requisitos
- Node.js 18+
- pnpm
## Instalación y desarrollo
```bash
pnpm install
pnpm dev # http://localhost:5173
```
## Build de producción
```bash
pnpm build # genera la carpeta dist/
pnpm preview # previsualización del build
```
## Configuración
Crea un archivo `.env` en la raíz del proyecto:
```env
VITE_API_BASE_URL=http://localhost:5001
```
Por defecto apunta al BFF en `localhost:5001`.
## Páginas disponibles
| Ruta hash | Página | Descripción |
|-----------|--------|-------------|
| `#/` | Dashboard | Métricas generales y gráficas |
| `#/facturas` | Facturas | Facturas de clientes: crear, editar, anular |
| `#/facturas-proveedores` | Facturas proveedores | CRUD completo, líneas, pagos y cambio de estado |
| `#/clientes` | Clientes | Listado y gestión de clientes |
| `#/contacts` | Contactos | Listado y gestión de contactos |
| `#/banco` | Banco | Movimientos bancarios |
| `#/settings` | Configuración | Tema, sesión JWT, webhook y registro VeriFactu |
## Estructura del proyecto
```
doli-front/
├── src/
│ ├── main.js # punto de entrada
│ ├── router.js # router hash-based
│ ├── pages/ # una página por módulo de negocio
│ │ ├── pagesRegistry.js # registro central de rutas
│ │ ├── DashboardPage.js
│ │ ├── Facturas.js
│ │ ├── FacturasProveedores.js
│ │ ├── ClientesPage.js
│ │ ├── ContactsPage.js
│ │ ├── BancoPage.js
│ │ └── SettingsPage.js
│ ├── services/ # clientes HTTP por entidad
│ │ ├── apiClient.js # fetch base con JWT automático
│ │ ├── auth.js
│ │ ├── invoices.js
│ │ ├── supplierInvoices.js
│ │ ├── clients.js
│ │ ├── contacts.js
│ │ ├── verifactu.js
│ │ └── ...
│ ├── components/ # componentes reutilizables
│ │ ├── Sidebar.js
│ │ ├── InvoiceModal.js
│ │ ├── VoiceAssistant.js
│ │ └── ...
│ └── styles/ # CSS modular
├── index.html
└── vite.config.js
```
## Arquitectura
La aplicación sigue un patrón de **SPA con router hash** sin dependencias de framework:
1. `main.js` inicializa el router y monta el layout principal (sidebar + área de contenido).
2. `router.js` escucha cambios en `window.location.hash` y renderiza la página correspondiente.
3. Cada página es una función que devuelve un `HTMLElement` con toda su lógica encapsulada.
4. `services/apiClient.js` centraliza todas las llamadas HTTP, adjunta el token JWT y gestiona errores 401.
## Credenciales de acceso (desarrollo)
- **Usuario:** `admin`
- **Contraseña:** `12345678`
> Estas son las credenciales del BFF/Dolibarr por defecto.

View File

@ -0,0 +1,11 @@
Dashboard:
1.Ver compras y ventas
2. Facturacion trimestral -> balance Trimestral
3. Ultimas facturas recibidas y realizadas
4. Dashboard hacerlo bonito
Facturas:
1. Poner nombre en vez de id
General:
1. SOLO UNA EMPRESA (todos en una misma)

23
doli-front/index.html Executable file
View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/doli-favicon.svg" />
<link rel="apple-touch-icon" href="/doli-favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<meta name="description" content="Doli — Gestión de facturación" />
<title>Doli</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/styles/modal.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

19
doli-front/package.json Executable file
View File

@ -0,0 +1,19 @@
{
"name": "doli-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^7.2.4"
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"chart.js": "^4.5.1",
"node-forge": "^1.4.0"
}
}

1338
doli-front/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
allowBuilds:
esbuild: false
onnxruntime-node: false
protobufjs: false
sharp: false

View File

@ -0,0 +1,95 @@
# Guion de presentación — Frontend (doli-front)
> Tiempo estimado de esta parte: ~15 min dentro de los 50 min totales del equipo.
---
## 1. Introducción al frontend (2 min)
- Qué es `doli-front`: SPA que consume el BFF para mostrar y gestionar los datos de Dolibarr.
- Decisión técnica: **Vanilla JS** sin frameworks → sin dependencias de React/Vue, control total del DOM.
- Herramienta de build: **Vite 7** — HMR instantáneo en desarrollo, build optimizado con ES Modules.
---
## 2. Arquitectura interna (3 min)
Mostrar la estructura de carpetas en el IDE:
```
src/
├── main.js ← punto de entrada, monta layout
├── router.js ← escucha hash, carga la página correcta
├── pages/ ← cada página es una función que devuelve un HTMLElement
├── services/ ← apiClient.js centraliza el fetch + JWT
└── components/ ← sidebar, modales reutilizables
```
**Puntos clave a explicar:**
- El router es hash-based (`#/facturas`, `#/clientes`…) → no necesita servidor con rutas configuradas.
- `apiClient.js`: adjunta el token JWT automáticamente en cada petición, redirige al login en 401.
- Cada página encapsula su propio estado, listeners y cleanup (sin estado global compartido).
---
## 3. Demo en vivo — recorrido por las páginas (8 min)
Abrir la app en el navegador y mostrar:
### Dashboard
- Gráficas de facturas con **Chart.js**.
- Métricas en tarjetas (total clientes, facturas pendientes…).
### Facturas de proveedores ← parte más completa
- Listar facturas con búsqueda en tiempo real.
- Abrir detalle: ver líneas, importes, estado.
- Cambiar estado: Borrador → Validada → Pagada (botones contextuales).
- Añadir línea a una factura en borrador.
- Registrar un pago (seleccionar tipo, importe, fecha).
- Eliminar factura en borrador con confirmación.
### Contactos
- Búsqueda en tiempo real sobre la tabla.
- Ver detalle / editar inline.
- Crear nuevo contacto con selector de empresa.
### Configuración
- Toggle tema claro/oscuro (persiste en localStorage).
- Temporizador de expiración del JWT en tiempo real.
- Configurar URL de webhook (Teams/Slack).
- Registro de certificado VeriFactu: selector de archivo `.p12` → se convierte a base64 → se envía al BFF.
---
## 4. Decisiones técnicas destacables (2 min)
- **Sin framework**: cada página es una función `render*()` → fácil de leer, sin magia.
- **FileReader API**: el certificado `.p12` se lee en el navegador y se convierte a base64 antes de enviarlo, sin exponer rutas del servidor.
- **Asistente de voz**: Whisper ejecutándose en local en el navegador con `@huggingface/transformers` (sin APIs externas de voz).
- **Toast notifications**: sistema propio ligero sin librerías externas.
- **Cleanup de páginas**: cada página registra un método `cleanup()` que el router llama al navegar, evitando memory leaks de intervals/listeners.
---
## Capturas recomendadas para incluir en slides
- Captura del dashboard con las gráficas.
- Captura del modal de detalle de factura de proveedor (con líneas y pagos).
- Captura de la sección VeriFactu en ajustes.
- Diagrama simple del flujo: `Página → apiClient → BFF`.
---
## Posibles preguntas del profesor
**¿Por qué Vanilla JS y no React?**
→ Para demostrar conocimiento de las APIs del navegador sin abstracciones. El proyecto es de tamaño manejable y no requería el overhead de un framework.
**¿Cómo gestionas el estado?**
→ Cada página tiene su propio estado local (variables en el closure). No hay estado global; el router destruye la página anterior antes de montar la nueva.
**¿Cómo funciona el JWT?**
→ El BFF devuelve un token al hacer login. El frontend lo guarda en `localStorage` y `apiClient.js` lo adjunta en el header `Authorization: Bearer` de cada petición.
**¿Cómo se integra con VeriFactu?**
→ La página de ajustes lee el `.p12` como base64 con `FileReader`, lo envía al BFF, y el BFF lo reenvía al servicio Go que valida y almacena el certificado.

View File

@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>doli-front — Frontend SPA para Dolibarr</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/black.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/monokai.css">
<style>
.reveal h1,.reveal h2,.reveal h3{text-transform:none;letter-spacing:0}
.reveal section{padding:18px 42px;box-sizing:border-box;overflow:hidden}
.reveal h1{font-size:1.6em;font-weight:800;margin-bottom:.1em}
.reveal h2{font-size:1.05em;margin-bottom:.4em;color:#fff;font-weight:700}
.reveal p{font-size:.76em;line-height:1.5;margin:.3em 0}
.reveal ul{font-size:.74em;line-height:1.6;margin:.2em 0 .2em 1.2em}
.reveal pre{width:100%;margin:.4em 0;border-radius:5px}
.reveal pre code{font-size:.58em;line-height:1.2}
body,.reveal{background:#111;color:#eee}
.cols{display:flex;gap:12px;align-items:stretch}
.cols>*{flex:1}
.center{text-align:center}
.card{
background:#181818;
border:1px solid #333;
border-radius:6px;
padding:10px 14px;
margin:3px 0;
}
.callout{
background:#121c14;
border:1px solid #3fb95044;
padding:10px 16px;
border-radius:6px;
margin:.5em 0;
}
.callout p{margin:0;font-size:.73em;line-height:1.45}
.flow{display:flex;align-items:center;justify-content:center;gap:12px;margin:.7em 0;font-size:.8em}
.flow-box{padding:10px 18px;border-radius:6px;font-weight:700;text-align:center;font-size:.9em}
.flow-arrow{color:#3b82f6;font-size:1.3em;font-weight:800}
.sub{font-size:.63em;color:#888;line-height:1.45}
.accent{color:#3b82f6}
.green{color:#4ade80}
.red{color:#f87171}
.amber{color:#fbbf24}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:6px 16px}
.grid1{display:grid;grid-template-columns:1fr;gap:5px}
.file-tree{
font-family:'SF Mono',Consolas,monospace;
font-size:.44em;line-height:1.6;
color:#ccc;background:#181818;
border-radius:6px;padding:10px 14px;
border:1px solid #333;
white-space:pre;
overflow:hidden
}
.file-tree .dir{color:#3b82f6;font-weight:700}
.file-tree .comment{color:#555}
</style>
</head>
<body>
<div class="reveal"><div class="slides">
<!-- PORTADA -->
<section class="center">
<p style="font-size:.62em;color:#888;margin-bottom:.5em">2DAM — Proyecto Grupal — 2026</p>
<h1 style="color:#fff">doli-front</h1>
<p style="font-size:.9em;color:#3b82f6;margin:.2em 0 .4em">SPA Vanilla JS · Vite · Sin frameworks</p>
<p class="sub" style="margin-top:.8em">El frontend que Dolibarr nunca tuvo</p>
</section>
<!-- EL PROBLEMA -->
<section>
<h2>Dolibarr tiene todo. Menos una interfaz decente.</h2>
<p>El ERP funciona, pero su interfaz tiene 20 años. El objetivo: dar una experiencia moderna sin tocar el backend del ERP.</p>
<div class="cols" style="margin-top:.7em">
<div class="card" style="border-top:3px solid #f87171">
<p style="font-size:.75em;color:#f87171;font-weight:700;margin:0 0 6px">Antes</p>
<div class="sub" style="line-height:1.75">
Interfaz PHP de 2004<br>Sin dark mode<br>Sin búsqueda en tiempo real<br>Sin notificaciones<br>Imposible de personalizar
</div>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.75em;color:#4ade80;font-weight:700;margin:0 0 6px">Ahora</p>
<div class="sub" style="line-height:1.75">
SPA moderna con dark mode<br>Búsqueda y filtros reactivos<br>Notificaciones Teams / Slack<br>Asistente de voz integrado<br>Soporte VeriFactu
</div>
</div>
</div>
</section>
<!-- ¿POR QUÉ VANILLA JS? -->
<section>
<h2>Sin React. Sin Vue. Sin Angular.</h2>
<p>Una SPA no necesita un framework. Necesita un router, componentes y control del DOM. El proyecto tiene ~7.700 líneas de JS puro sin contar dependencias.</p>
<div class="cols" style="margin-top:.7em">
<div class="card" style="border-top:3px solid #3b82f6">
<p style="font-size:.74em;color:#3b82f6;font-weight:700;margin:0 0 3px">Control total</p>
<p class="sub">Cada elemento del DOM lo creamos nosotros. Sin magia, sin diffing invisible.</p>
</div>
<div class="card" style="border-top:3px solid #3b82f6">
<p style="font-size:.74em;color:#3b82f6;font-weight:700;margin:0 0 3px">Dependencias mínimas</p>
<p class="sub">Solo 3: Chart.js (gráficas), @huggingface/transformers (voz) y node-forge (cifrado RSA).</p>
</div>
<div class="card" style="border-top:3px solid #3b82f6">
<p style="font-size:.74em;color:#3b82f6;font-weight:700;margin:0 0 3px">Aprendizaje real</p>
<p class="sub">Entendemos qué hace el navegador. Un framework encima de esto es trivial; al revés, no tanto.</p>
</div>
</div>
</section>
<!-- ARQUITECTURA -->
<section>
<h2>Estructura del proyecto</h2>
<div class="cols" style="align-items:flex-start;margin-top:.4em;gap:14px">
<div class="file-tree"><span class="dir">src/</span>
├── <span class="dir">pages/</span> <span class="comment">← render*() → HTMLElement</span>
│ ├── DashboardPage.js
│ ├── FacturasPage.js
│ └── pagesRegistry.js <span class="comment">← auto-glob</span>
├── <span class="dir">components/</span> <span class="comment">← reutilizables</span>
│ ├── InvoiceModal.js
│ └── Sidebar.js
├── <span class="dir">services/</span> <span class="comment">← lógica</span>
│ ├── apiClient.js <span class="comment">← fetch+JWT</span>
│ └── pagesConfig.js
├── <span class="dir">styles/</span>
├── main.js <span class="comment">← entrada</span>
└── router.js <span class="comment">← hash routing</span></div>
<div>
<div class="card" style="border-top:3px solid #3b82f6;margin-bottom:7px">
<p style="font-size:.72em;color:#3b82f6;font-weight:700;margin:0 0 3px">Una página = una función</p>
<p class="sub"><code>render*()</code> devuelve un <code>HTMLElement</code>.<br>Estado local en closure.<br>El router la monta y destruye.</p>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.72em;color:#4ade80;font-weight:700;margin:0 0 3px">Sin estado global</p>
<p class="sub">No hay store ni contexto.<br>Cada página habla con el BFF<br>de forma independiente.</p>
</div>
</div>
</div>
</section>
<!-- ROUTER -->
<section>
<h2>Routing sin servidor</h2>
<p>El router escucha el hash de la URL. Navegar a <code>#invoices</code> desmonta la página actual y monta la nueva.</p>
<pre><code class="javascript">// router.js — lo esencial
window.addEventListener('hashchange', () => {
const route = location.hash.replace('#', '') || 'dashboard';
const page = getPageByRoute(route);
currentContainer?.cleanup?.(); // cancela timers e intervals
currentContainer = page.render(); // monta la nueva página
appRoot.replaceChildren(currentContainer);
});</code></pre>
<div class="callout" style="margin-top:.5em">
<p><code>cleanup()</code> evita memory leaks. Si una página tiene un <code>setInterval</code> (ej.: countdown del JWT en Configuración), se cancela al navegar.</p>
</div>
</section>
<!-- REGISTRO DINÁMICO -->
<section>
<h2>Añadir una página es crear un archivo</h2>
<p>Con <code>import.meta.glob</code> de Vite, cualquier <code>*Page.js</code> en <code>/pages</code> se registra solo en el sidebar.</p>
<div class="cols" style="margin-top:.6em;align-items:flex-start">
<pre><code class="javascript">// pagesRegistry.js
const modules = import.meta.glob(
'./*Page.js', { eager: true }
);
// filtra las declaradas en pagesConfig
// el resto se auto-registran con ruta
// derivada del nombre del archivo</code></pre>
<div>
<div class="card" style="border-top:3px solid #3b82f6;margin-bottom:7px">
<p style="font-size:.72em;color:#3b82f6;font-weight:700;margin:0 0 3px">Páginas explícitas</p>
<p class="sub">En <code>pagesConfig.js</code>: ruta, nombre, icono, voice patterns.</p>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.72em;color:#4ade80;font-weight:700;margin:0 0 3px">Páginas nuevas</p>
<p class="sub">Crear <code>MiPaginaPage.js</code> → aparece en el sidebar con ruta <code>#mi-pagina</code> automáticamente.</p>
</div>
</div>
</div>
</section>
<!-- PÁGINAS PRINCIPALES -->
<section>
<h2>Lo que puede hacer el usuario</h2>
<div class="grid2" style="margin-top:.35em;gap:5px 12px">
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Dashboard</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">KPIs, gráfica trimestral, últimos movimientos, tabla con colores.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Facturas</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">CRUD completo. Plantillas, líneas editables, pagos, cambio de estado.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Fact. Proveedores</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Gestión de compras. Total por línea en tiempo real.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Terceros</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Clientes y proveedores con rol. Contactos anidados.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Banco</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Cuentas bancarias y movimientos. Alta con selector de país.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Configuración</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Tema, JWT countdown, webhook Teams/Slack, VeriFactu.</p>
</div>
</div>
</section>
<!-- ASISTENTE DE VOZ -->
<section>
<h2>Whisper en el navegador</h2>
<p>El asistente de voz ejecuta <strong>Whisper base</strong> (<code>Xenova/whisper-base</code>) directamente en el navegador con <code>@huggingface/transformers</code>. Sin API key, sin servidor de voz.</p>
<div class="flow" style="margin:.8em 0">
<div class="flow-box" style="background:#1a1a2e;color:#a78bfa;border:2px solid #a78bfa88">Micrófono</div>
<span class="flow-arrow"></span>
<div class="flow-box" style="background:#1a1a2e;color:#a78bfa;border:2px solid #a78bfa88">Web Audio API</div>
<span class="flow-arrow"></span>
<div class="flow-box" style="background:#1a1a2e;color:#a78bfa;border:2px solid #a78bfa88">Whisper (WASM)</div>
<span class="flow-arrow"></span>
<div class="flow-box" style="background:#1a2e1a;color:#4ade80;border:2px solid #4ade8088">Navegación</div>
</div>
<div class="callout">
<p>"Ir a facturas", "abrir banco", "dashboard" → el router navega sin tocar el teclado. Los patrones de voz se definen en <code>pagesConfig.js</code> junto a la ruta.</p>
</div>
</section>
<!-- VERIFACTU -->
<section>
<h2>Firma de facturas: VeriFactu</h2>
<p>Flujo de tres pasos que ocurre en el navegador antes de llegar al servidor.</p>
<div class="cols" style="margin-top:.6em">
<div class="card" style="border-top:3px solid #fbbf24">
<p style="font-size:.72em;color:#fbbf24;font-weight:700;margin:0 0 3px">1 — Certificado</p>
<p class="sub">El usuario selecciona su <code>.p12</code>. La <strong>FileReader API</strong> lo convierte a base64 en el navegador. Nunca toca el disco del servidor.</p>
</div>
<div class="card" style="border-top:3px solid #fbbf24">
<p style="font-size:.72em;color:#fbbf24;font-weight:700;margin:0 0 3px">2 — Contraseña cifrada</p>
<p class="sub">El BFF expone una clave pública RSA. El frontend cifra la contraseña con ella. Nunca viaja en claro.</p>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.72em;color:#4ade80;font-weight:700;margin:0 0 3px">3 — Registro</p>
<p class="sub">El BFF reenvía al microservicio Go, que valida el <code>.p12</code>, lo almacena y devuelve un token de sesión.</p>
</div>
</div>
</section>
<!-- APICLIENT + JWT -->
<section>
<h2>Una sola función para hablar con el backend</h2>
<p>Todo el tráfico HTTP pasa por <code>apiClient.js</code>. JWT automático, 401 redirige al login, errores normalizados.</p>
<pre><code class="javascript">// services/apiClient.js
export async function apiGet(endpoint) {
const token = localStorage.getItem('token');
const res = await fetch(BASE_URL + endpoint, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 401) { navigate('login'); return; }
if (!res.ok) throw new Error(await res.text());
return res.json();
}</code></pre>
<div class="callout" style="margin-top:.5em">
<p>Las páginas nunca manejan tokens ni status HTTP. Solo llaman a <code>apiGet()</code>, <code>apiPost()</code>… y reciben datos o un error.</p>
</div>
</section>
<!-- DECISIONES TÉCNICAS -->
<section>
<h2>Decisiones que valió la pena tomar</h2>
<div class="grid2" style="margin-top:.35em;gap:5px 12px">
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Hash routing sin servidor</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Archivos estáticos. Funciona en cualquier CDN sin configurar rutas.</p>
</div>
<div class="card" style="border-left:3px solid #4ade80;padding:7px 12px">
<p style="font-size:.65em;color:#4ade80;font-weight:700;margin:0 0 2px">Cleanup en cada página</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0"><code>container.cleanup()</code> cancela intervals al navegar. Sin memory leaks.</p>
</div>
<div class="card" style="border-left:3px solid #a78bfa;padding:7px 12px">
<p style="font-size:.65em;color:#a78bfa;font-weight:700;margin:0 0 2px">Vite + import.meta.glob</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">HMR en desarrollo. Cada página es un chunk en producción. Registro dinámico.</p>
</div>
<div class="card" style="border-left:3px solid #fbbf24;padding:7px 12px">
<p style="font-size:.65em;color:#fbbf24;font-weight:700;margin:0 0 2px">FileReader + RSA en cliente</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Certificado y contraseña procesados en el navegador. Nunca viajan en claro.</p>
</div>
</div>
</section>
<!-- CIERRE -->
<section class="center">
<h1 style="font-size:2em">Preguntas</h1>
<p style="margin-top:.8em;color:#555">doli-front · Vanilla JS · Vite · 2026</p>
</section>
</div></div>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/highlight.js"></script>
<script>
Reveal.initialize({
hash: true,
slideNumber: 'c/t',
transition: 'fade',
transitionSpeed: 'fast',
plugins: [RevealHighlight],
width: 1100,
height: 680,
margin: 0.04,
center: false,
disableLayout: false,
});
</script>
</body>
</html>

View File

@ -0,0 +1,42 @@
# Qué subir a projectes.ieslamar.org
## Incluir
```
doli-front/
├── src/ ✅ todo el código fuente
├── public/ ✅ assets estáticos
├── index.html ✅
├── vite.config.js ✅
├── package.json ✅
├── pnpm-lock.yaml ✅
├── .env.example ✅ (si existe, sin valores reales)
├── README.md ✅
└── presentacion/ ✅ tu carpeta de presentación personal
```
## Excluir (NO subir)
```
node_modules/ ❌ se regenera con pnpm install
dist/ ❌ se regenera con pnpm build
.env ❌ contiene credenciales reales
.git/ ❌ historial git interno
```
## Pasos para preparar el ZIP
```bash
cd doli-front
# Asegúrate de que .gitignore ya excluye node_modules y dist
# Luego crea el zip excluyendo lo que no debe ir:
zip -r doli-front.zip . \
--exclude "*/node_modules/*" \
--exclude "*/dist/*" \
--exclude "*/.git/*" \
--exclude "*/.env"
```
El archivo `doli-front.zip` es lo que subes a la plataforma.

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<path d="M10 7h7.5a3.5 3.5 0 0 1 2.47 6L14.5 16.5a3.5 3.5 0 0 1-2.47 6H10V7z" fill="#fff" opacity=".92"/>
<path d="M10 17h5.3" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".6"/>
<path d="M10 21.5h5" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity=".6"/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

1
doli-front/public/vite.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,35 @@
import { icons } from '../services/icons.js';
export function showBlockedNavigationToast(message = 'Guarda los cambios antes de salir', duration = 3000) {
const existingToast = document.querySelector('.blocked-nav-toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = 'blocked-nav-toast';
toast.innerHTML = /*html*/`
<div class="blocked-nav-toast-content">
${icons.info}
<span class="blocked-nav-message">${message}</span>
</div>
`;
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('visible');
});
setTimeout(() => {
toast.classList.remove('visible');
toast.classList.add('hiding');
setTimeout(() => {
toast.remove();
}, 300);
}, duration);
return toast;
}

View File

@ -0,0 +1,95 @@
function displayValue(value) {
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
return value;
}
function roleLabel(role) {
const map = { client: 'Cliente', supplier: 'Proveedor', both: 'Ambos' };
return map[role] || 'N/A';
}
export function ClientItem(client, onView) {
const fragment = document.createDocumentFragment();
const item = document.createElement('tr');
item.className = 'client-item';
const contactsCount = client.contacts ? client.contacts.length : 0;
// Obtener texto de estado
const getStatusText = (status) => {
const statusMap = { '0': 'Inactivo', '1': 'Activo' };
return statusMap[status] || 'N/A';
};
const getStatusClass = (status) => {
const classMap = { '0': 'status-inactive', '1': 'status-active' };
return classMap[status] || 'status-unknown';
};
// Obtener iniciales para el avatar
const getInitials = () => {
if (!client.name) return '?';
const words = client.name.split(' ');
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return client.name.substring(0, 2).toUpperCase();
};
// Generar color consistente basado en el nombre
const getAvatarColor = () => {
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16',
];
let hash = 0;
const name = client.name || '';
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
item.innerHTML = /*html*/`
<td class="client-avatar-cell">
<div class="client-avatar" style="background-color: ${getAvatarColor()}">
${getInitials()}
</div>
</td>
<td class="client-name">
<div class="client-name-wrapper">
<span class="client-company-name">${displayValue(client.name)}</span>
<span class="client-code">${displayValue(client.codeClient)}</span>
</div>
</td>
<td class="client-type">${roleLabel(client.role)}</td>
<td class="client-status">
<span class="status-badge-client ${getStatusClass(client.status)}">${getStatusText(client.status)}</span>
</td>
<td class="client-email">
<span class="email-text" title="${displayValue(client.email)}">${displayValue(client.email)}</span>
</td>
<td class="client-phone">${displayValue(client.phone)}</td>
<td class="client-actions">
<button class="btn-action btn-view" data-client-id="${client.id}" title="Ver detalles">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</td>
`;
fragment.appendChild(item);
// Event listener para el botón de ver
const viewBtn = item.querySelector('.btn-view');
viewBtn.addEventListener('click', () => {
if (onView) {
onView(client.id);
}
});
return fragment;
}

View File

@ -0,0 +1,228 @@
/**
* Componente de modal de confirmación para salir sin guardar cambios
* @param {Object} options - Opciones del modal
* @param {string} options.title - Título del modal (opcional)
* @param {string} options.message - Mensaje del modal (opcional)
* @param {string} options.confirmText - Texto del botón de confirmar (opcional)
* @param {string} options.cancelText - Texto del botón de cancelar (opcional)
* @param {Function} options.onConfirm - Callback al confirmar salida
* @param {Function} options.onCancel - Callback al cancelar (quedarse)
* @returns {HTMLElement} El elemento del modal
*/
export function ConfirmExitModal(options = {}) {
const {
title = 'Cambios sin guardar',
message = '¿Estás seguro de que quieres salir? Los cambios no guardados se perderán.',
confirmText = 'Salir sin guardar',
cancelText = 'Quedarse',
onConfirm = () => { },
onCancel = () => { }
} = options;
const modal = document.createElement('div');
modal.className = 'confirm-exit-overlay';
modal.innerHTML = /*html*/`
<div class="confirm-exit-modal">
<div class="confirm-exit-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h3 class="confirm-exit-title">${title}</h3>
<p class="confirm-exit-message">${message}</p>
<div class="confirm-exit-actions">
<button class="btn-stay" type="button">${cancelText}</button>
<button class="btn-exit" type="button">${confirmText}</button>
</div>
</div>
`;
const handleClose = (confirmed) => {
modal.classList.add('closing');
setTimeout(() => {
modal.remove();
if (confirmed) {
onConfirm();
} else {
onCancel();
}
}, 200);
};
// Botón quedarse
modal.querySelector('.btn-stay').addEventListener('click', () => {
handleClose(false);
});
// Botón salir
modal.querySelector('.btn-exit').addEventListener('click', () => {
handleClose(true);
});
// Cerrar con Escape
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
handleClose(false);
document.removeEventListener('keydown', handleKeyDown);
}
};
document.addEventListener('keydown', handleKeyDown);
// Click fuera del modal (quedarse)
modal.addEventListener('click', (e) => {
if (e.target === modal) {
handleClose(false);
}
});
return modal;
}
/**
* Muestra el modal de confirmación de salida
* @param {Object} options - Opciones del modal
* @returns {Promise<boolean>} true si confirma salir, false si cancela
*/
export function showConfirmExitModal(options = {}) {
return new Promise((resolve) => {
const modal = ConfirmExitModal({
...options,
onConfirm: () => resolve(true),
onCancel: () => resolve(false)
});
document.body.appendChild(modal);
});
}
/**
* Clase para gestionar el seguimiento de cambios en formularios
*/
export class FormChangeTracker {
constructor() {
this.initialState = null;
this.hasChanges = false;
this.listeners = [];
}
/**
* Captura el estado inicial del formulario
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
*/
captureInitialState(container) {
this.initialState = this.getFormState(container);
this.hasChanges = false;
}
/**
* Obtiene el estado actual del formulario
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
* @returns {Object} Estado del formulario
*/
getFormState(container) {
const state = {};
// Inputs de texto, date, number, etc.
container.querySelectorAll('input:not([type="button"]):not([type="submit"])').forEach(input => {
const key = input.name || input.id || input.className;
if (key) {
state[`input_${key}`] = input.value;
}
});
// Textareas
container.querySelectorAll('textarea').forEach(textarea => {
const key = textarea.name || textarea.id || textarea.className;
if (key) {
state[`textarea_${key}`] = textarea.value;
}
});
// Selects
container.querySelectorAll('select').forEach(select => {
const key = select.name || select.id || select.className;
if (key) {
state[`select_${key}`] = select.value;
}
});
// Contar líneas de factura si existen
const lines = container.querySelectorAll('.line-row-compact, .invoice-lines tr');
state['_lineCount'] = lines.length;
return state;
}
/**
* Verifica si hay cambios comparando con el estado inicial
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
* @returns {boolean} true si hay cambios
*/
checkForChanges(container) {
if (!this.initialState) return false;
const currentState = this.getFormState(container);
// Comparar estados
const initialKeys = Object.keys(this.initialState);
const currentKeys = Object.keys(currentState);
// Si cambiaron las claves, hay cambios
if (initialKeys.length !== currentKeys.length) {
this.hasChanges = true;
return true;
}
// Comparar valores
for (const key of initialKeys) {
if (this.initialState[key] !== currentState[key]) {
this.hasChanges = true;
return true;
}
}
this.hasChanges = false;
return false;
}
/**
* Configura listeners automáticos para detectar cambios
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
*/
setupAutoTracking(container) {
const checkChanges = () => {
this.checkForChanges(container);
};
// Escuchar cambios en inputs
container.addEventListener('input', checkChanges);
container.addEventListener('change', checkChanges);
// Observar cambios en el DOM (líneas añadidas/eliminadas)
const observer = new MutationObserver(checkChanges);
observer.observe(container, { childList: true, subtree: true });
this.listeners.push({ container, checkChanges, observer });
}
/**
* Marca que se han guardado los cambios
*/
markAsSaved() {
this.hasChanges = false;
}
/**
* Limpia los listeners
*/
cleanup() {
this.listeners.forEach(({ container, checkChanges, observer }) => {
container.removeEventListener('input', checkChanges);
container.removeEventListener('change', checkChanges);
observer.disconnect();
});
this.listeners = [];
}
}

View File

@ -0,0 +1,82 @@
import { icons } from '../services/icons.js';
export function InvoiceItem(invoice, onView) {
const item = document.createElement('tr');
item.className = 'invoice-item';
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('es-ES');
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
const getStatusClass = (status) => {
const statusMap = {
'draft': 'status-draft',
'validated': 'status-validated',
'paid': 'status-paid',
'unpaid': 'status-unpaid',
'canceled': 'status-canceled',
'pending': 'status-pending',
};
return statusMap[status] || 'status-draft';
};
const getStatusText = (status) => {
const statusTextMap = {
'draft': 'Borrador',
'validated': 'Pte. Pago',
'paid': 'Pagada',
'unpaid': 'Pte. Pago',
'canceled': 'Cancelada',
'pending': 'Pendiente',
};
return statusTextMap[status] || status;
};
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
item.innerHTML = /*html*/`
<td class="invoice-select-col">
<input type="checkbox" class="invoice-check" value="${invoice.id}" aria-label="Seleccionar factura ${escapeHtml(invoice.number)}" />
</td>
<td class="invoice-number">
<button class="btn-invoice-num" title="Ver detalles">${escapeHtml(invoice.number)}</button>
</td>
<td class="invoice-status">
<span class="status-badge ${getStatusClass(invoice.status)}">
${escapeHtml(getStatusText(invoice.status))}
</span>
</td>
<td class="invoice-client">
<span class="client-icon">${icons.user}</span>
${escapeHtml(invoice.clientName || 'Sin nombre')}
</td>
<td class="invoice-date">${formatDate(invoice.date)}</td>
<td class="invoice-total amount-positive">${formatCurrency(invoice.total)}</td>
<td class="invoice-remain ${invoice.status === 'paid' ? 'amount-muted' : 'amount-pending'}">${invoice.status === 'paid' ? '—' : formatCurrency(invoice.remainToPay)}</td>
<td class="invoice-actions">
<button class="btn-action btn-view" data-invoice-id="${invoice.id}" title="Ver/Editar detalles">
${icons.eye}
</button>
</td>
`;
item.querySelector('.btn-invoice-num').addEventListener('click', () => onView?.(invoice.id));
const viewBtn = item.querySelector('.btn-view');
viewBtn.addEventListener('click', () => onView?.(invoice.id));
return item;
}

View File

@ -0,0 +1,999 @@
import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, updateInvoiceLine, addPayment, getPayments, downloadInvoicePdf } from '../services/invoices.js';
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
import { toast } from '../services/toast.js';
import { getPaymentTypes } from '../services/setup.js';
import { apiGet, apiRequest } from '../services/apiClient.js';
export function InvoiceModal(invoiceId, onClose, onUpdate) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
let invoice = null;
let payments = [];
let paymentTypes = [];
let bankAccounts = [];
let documents = [];
let isLoading = true;
// Tracker de cambios
const changeTracker = new FormChangeTracker();
let savedSuccessfully = false;
let activePreviewBlobUrl = null;
// Formatear fecha para input type="date"
const formatDateForInput = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toISOString().split('T')[0];
};
// Formatear fecha para mostrar
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('es-ES');
};
// Formatear tamaño de archivo
const formatFileSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// Formatear moneda
const formatCurrency = (amount) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(amount || 0);
};
// Obtener texto de estado
const getStatusText = (status) => {
const statusTextMap = {
'draft': 'Borrador',
'validated': 'Pte. Pago',
'paid': 'Pagada',
'unpaid': 'Pte. Pago',
'canceled': 'Cancelada'
};
return statusTextMap[status] || status;
};
// Renderizar contenido del modal
const renderContent = () => {
const modalContent = modal.querySelector('.modal-content');
if (isLoading) {
modalContent.innerHTML = `
<div class="modal-header">
<h2>Cargando...</h2>
<button class="btn-close">×</button>
</div>
<div class="modal-body">
<div class="loading">Cargando detalles de la factura...</div>
</div>
`;
modalContent.querySelector('.btn-close')?.addEventListener('click', handleClose);
return;
}
if (!invoice) {
modalContent.innerHTML = `
<div class="modal-header">
<h2>Error</h2>
<button class="btn-close">×</button>
</div>
<div class="modal-body">
<div class="error">No se pudo cargar la factura</div>
</div>
`;
modalContent.querySelector('.btn-close')?.addEventListener('click', handleClose);
return;
}
const isDraft = invoice.status === 'draft';
const canEdit = isDraft || invoice.status === 'validated' || invoice.status === 'unpaid';
const canDownloadPdf = invoice.status === 'validated' || invoice.status === 'paid';
modalContent.innerHTML = `
<div class="modal-header">
<div class="modal-title">
<h2>${invoice.number || 'Nueva Factura'}</h2>
<span class="status-badge status-${invoice.status}">
${getStatusText(invoice.status)}
</span>
</div>
<button class="btn-close">×</button>
</div>
<div class="modal-body">
<form id="invoice-form">
<!-- Información básica -->
<div class="form-section">
<h3>Información de la Factura</h3>
<div class="form-row">
<div class="form-group">
<label>Número de Factura</label>
<input
type="text"
name="number"
value="${invoice.number || ''}"
disabled
/>
</div>
<div class="form-group">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Fecha</label>
<input
type="date"
name="date"
value="${formatDateForInput(invoice.date)}"
disabled
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Fecha de Vencimiento</label>
<input
type="date"
name="expireDate"
value="${formatDateForInput(invoice.expireDate)}"
${!canEdit ? 'disabled' : ''}
/>
</div>
<div class="form-group">
<label>Cliente</label>
<input
type="text"
name="clientId"
value="${invoice.clientName || invoice.clientId || ''}"
disabled
/>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>Nota Pública</label>
<textarea name="note_public" rows="3" placeholder="Visible para el cliente" ${!canEdit ? 'disabled' : ''}>${invoice.notePublic || ''}</textarea>
</div>
</div>
<div class="form-row">
<div class="form-group full-width">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Nota Privada</label>
<textarea name="note_private" rows="3" placeholder="Solo uso interno" ${!canEdit ? 'disabled' : ''}>${invoice.notePrivate || ''}</textarea>
</div>
</div>
</div>
<!-- Líneas de factura -->
<div class="form-section">
<div class="section-header">
<h3>Líneas de Factura</h3>
${isDraft ? '<button type="button" class="btn-add-line btn-small">+ Añadir Línea</button>' : ''}
</div>
${isDraft ? `
<div class="add-line-form" style="display: none;">
<div class="form-row">
<div class="form-group full-width">
<label>Descripción</label>
<input type="text" id="line-description" placeholder="Descripción del producto/servicio" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Cantidad</label>
<input type="number" id="line-quantity" value="1" min="0.01" step="0.01" />
</div>
<div class="form-group">
<label>Precio Unitario ()</label>
<input type="number" id="line-price" value="0" min="0" step="0.01" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>IVA (%)</label>
<input type="number" id="line-tax" value="21" min="0" max="100" step="0.01" />
</div>
<div class="form-group" style="display: flex; align-items: flex-end; gap: 0.5rem;">
<button type="button" class="btn-save-line btn-small btn-success">Guardar Línea</button>
<button type="button" class="btn-cancel-line btn-small btn-cancel">Cancelar</button>
</div>
</div>
</div>
` : ''}
<div class="invoice-lines">
<table class="lines-table">
<thead>
<tr>
<th>Descripción</th>
<th>Cantidad</th>
<th>Precio Unitario</th>
<th>IVA %</th>
<th>Total</th>
${isDraft ? '<th class="line-actions-col"></th>' : ''}
</tr>
</thead>
<tbody>
${invoice.lines && invoice.lines.length > 0
? invoice.lines.map(line => `
<tr data-line-id="${line.id}">
<td>${line.description || ''}</td>
<td>${line.quantity || 0}</td>
<td>${formatCurrency(line.unitPrice)}</td>
<td>${line.taxRate || 0}%</td>
<td>${formatCurrency(line.total)}</td>
${isDraft ? `
<td class="line-actions">
<button type="button" class="btn-edit-line" data-line-id="${line.id}" title="Editar línea">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button type="button" class="btn-delete-line" data-line-id="${line.id}" title="Eliminar línea">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</td>
` : ''}
</tr>
`).join('')
: `<tr><td colspan="${isDraft ? 6 : 5}" class="no-lines">No hay líneas en esta factura</td></tr>`
}
</tbody>
</table>
</div>
<!-- Totales -->
<div class="invoice-totals">
${invoice.totalHt != null ? `
<div class="total-row subtotal">
<span>Base imponible:</span>
<strong>${formatCurrency(invoice.totalHt)}</strong>
</div>` : ''}
${invoice.totalTax != null ? `
<div class="total-row subtotal">
<span>IVA:</span>
<strong>${formatCurrency(invoice.totalTax)}</strong>
</div>` : ''}
<div class="total-row">
<span>Total:</span>
<strong class="amount-positive">${formatCurrency(invoice.total)}</strong>
</div>
${invoice.status === 'paid' ? `
<div class="total-row total-row--highlight total-row--highlight-paid">
<span>Pagado:</span>
<strong>${formatCurrency(invoice.total || 0)}</strong>
</div>` : `
<div class="total-row total-row--highlight total-row--highlight-pending">
<span>Pendiente:</span>
<strong>${formatCurrency(Math.max(0, invoice.remainToPay || 0))}</strong>
</div>`}
</div>
</div>
${payments.length > 0 ? `
<!-- Historial de pagos -->
<div class="form-section payments-history-section">
<h3>Historial de Pagos</h3>
<table class="payments-history-table">
<thead>
<tr>
<th>Fecha</th>
<th>Referencia</th>
<th>Tipo</th>
<th> Transacción</th>
<th>Importe</th>
</tr>
</thead>
<tbody>
${payments.map(p => `
<tr>
<td>${formatDate(p.paymentDate)}</td>
<td class="payment-ref-cell">${p.ref || '—'}</td>
<td>${p.type ? `<span class="payment-type-pill">${p.type}</span>` : '—'}</td>
<td class="payment-ref-cell">${p.transactionNum || '—'}</td>
<td><span class="payment-amount-cell">${formatCurrency(p.amount)}</span></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : ''}
<!-- Documentos adjuntos -->
<div class="form-section documents-section">
<h3>Documentos</h3>
${documents.length === 0
? '<p class="no-documents">No hay documentos adjuntos a esta factura.</p>'
: `<ul class="documents-list">
${documents.map(doc => `
<li class="document-item">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="doc-icon"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span class="document-name">${doc.name}</span>
<span class="document-size">${formatFileSize(doc.size)}</span>
<button type="button" class="btn-download-doc" data-path="${encodeURIComponent(doc.relativePath || doc.name)}" data-name="${doc.name}" title="Descargar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Descargar
</button>
</li>
`).join('')}
</ul>`
}
</div>
${(invoice.status === 'validated' || invoice.status === 'unpaid') && invoice.remainToPay > 0 ? `
<!-- Sección de pago -->
<div class="form-section payment-section">
<h3>Registrar Pago</h3>
<div class="payment-form" id="payment-form">
<div class="form-row">
<div class="form-group">
<label>Cantidad a pagar ()</label>
<input type="number" id="payment-amount"
min="0" max="${invoice.remainToPay}" step="0.01"
placeholder="Dejar vacío o 0 = pago total (${invoice.remainToPay?.toFixed(2)} €)" />
<small class="payment-hint">Máximo: ${formatCurrency(invoice.remainToPay)}. Vacío o 0 = pago completo.</small>
</div>
<div class="form-group">
<label>Fecha de pago</label>
<input type="date" id="payment-date" value="${new Date().toISOString().split('T')[0]}" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Método de pago</label>
<select id="payment-method">
${paymentTypes.length
? paymentTypes.map(t => `<option value="${t.id}">${t.label || t.code}</option>`).join('')
: '<option value="4">Tarjeta bancaria</option>'}
</select>
</div>
<div class="form-group">
<label>Cuenta bancaria</label>
<select id="payment-account">
${bankAccounts.length
? bankAccounts.map(a => `<option value="${a.id}">${a.label}${a.iban ? ' · ' + a.iban.slice(-4) : ''}</option>`).join('')
: '<option value="1">Cuenta principal</option>'}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label> Referencia (opcional)</label>
<input type="text" id="payment-ref" placeholder="Nº de referencia del pago" />
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="button" class="btn-pay btn-success" id="btn-pay">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
Registrar Pago
</button>
</div>
</div>
</div>
</div>
` : ''}
</form>
</div>
<div class="modal-footer">
<div class="footer-actions-left">
${canDownloadPdf ? '<button type="button" class="btn-preview-invoice btn-cancel"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>Vista previa</button>' : ''}
${canDownloadPdf ? '<button type="button" class="btn-download-invoice btn-primary"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Descargar factura</button>' : ''}
${isDraft ? '<button type="button" class="btn-validate btn-success"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><polyline points="20 6 9 17 4 12"/></svg>Validar Factura</button>' : ''}
</div>
<div class="footer-actions-right">
<button type="button" class="btn-cancel-modal">Cancelar</button>
${canEdit ? '<button type="button" class="btn-save btn-primary">Guardar Cambios</button>' : ''}
</div>
</div>
`;
attachEventListeners();
// Capturar estado inicial después de renderizar
setTimeout(() => {
const modalContent = modal.querySelector('.modal-content');
if (modalContent && !isLoading) {
changeTracker.captureInitialState(modalContent);
changeTracker.setupAutoTracking(modalContent);
}
}, 100);
};
// Adjuntar event listeners
const attachEventListeners = () => {
// Botón cerrar (X) - muestra modal si hay cambios
const closeBtn = modal.querySelector('.btn-close');
closeBtn?.addEventListener('click', handleClose);
// Botón cancelar modal - muestra modal si hay cambios
const cancelBtn = modal.querySelector('.btn-cancel-modal');
cancelBtn?.addEventListener('click', handleClose);
// Botón guardar
const saveBtn = modal.querySelector('.btn-save');
saveBtn?.addEventListener('click', handleSave);
// Botón validar
const validateBtn = modal.querySelector('.btn-validate');
validateBtn?.addEventListener('click', handleValidate);
// Botón vista previa de factura PDF
const previewBtn = modal.querySelector('.btn-preview-invoice');
previewBtn?.addEventListener('click', handlePreviewPdf);
// Botón descargar factura PDF
const downloadBtn = modal.querySelector('.btn-download-invoice');
downloadBtn?.addEventListener('click', handleDownloadPdf);
// Botón añadir línea
const addLineBtn = modal.querySelector('.btn-add-line');
addLineBtn?.addEventListener('click', toggleAddLineForm);
// Botón guardar línea
const saveLineBtn = modal.querySelector('.btn-save-line');
saveLineBtn?.addEventListener('click', handleSaveLine);
// Botón cancelar línea
const cancelLineBtn = modal.querySelector('.btn-cancel-line');
cancelLineBtn?.addEventListener('click', () => {
const form = modal.querySelector('.add-line-form');
if (form) form.style.display = 'none';
});
// Botones editar línea
modal.querySelectorAll('.btn-edit-line').forEach(btn => {
btn.addEventListener('click', () => handleEditLine(btn.dataset.lineId));
});
// Botones eliminar línea
modal.querySelectorAll('.btn-delete-line').forEach(btn => {
btn.addEventListener('click', () => handleDeleteLine(parseInt(btn.dataset.lineId)));
});
// Botones descargar documento
modal.querySelectorAll('.btn-download-doc').forEach(btn => {
btn.addEventListener('click', () => handleDocumentDownload(btn.dataset.path, btn.dataset.name));
});
// Botón registrar pago
const payBtn = modal.querySelector('#btn-pay');
payBtn?.addEventListener('click', handlePayment);
// Click fuera del modal - muestra modal si hay cambios
modal.addEventListener('click', (e) => {
if (e.target === modal) {
handleClose();
}
});
};
// Manejar cierre
const handleClose = async () => {
// Si ya se guardó exitosamente, cerrar directamente
if (savedSuccessfully) {
changeTracker.cleanup();
modal.remove();
if (onClose) onClose();
return;
}
// Verificar si hay cambios sin guardar
const modalContent = modal.querySelector('.modal-content');
if (modalContent) {
changeTracker.checkForChanges(modalContent);
}
if (changeTracker.hasChanges) {
// Mostrar modal de confirmación
const confirmed = await showConfirmExitModal({
title: 'Cambios sin guardar',
message: 'Tienes cambios sin guardar en la factura. ¿Seguro que quieres cerrar?',
confirmText: 'Cerrar sin guardar',
cancelText: 'Seguir editando'
});
if (!confirmed) {
return; // No cerrar el modal
}
}
if (activePreviewBlobUrl) {
URL.revokeObjectURL(activePreviewBlobUrl);
activePreviewBlobUrl = null;
}
changeTracker.cleanup();
modal.remove();
if (onClose) onClose();
};
// Manejar guardado
const handleSave = async () => {
const form = modal.querySelector('#invoice-form');
const formData = new FormData(form);
// La API espera camelCase en el PUT (notePublic/notePrivate)
const data = {
number: formData.get('number') || undefined,
expireDate: formData.get('expireDate') || undefined,
notePublic: formData.get('note_public') || undefined,
notePrivate: formData.get('note_private') || undefined
};
// Filtrar valores undefined
Object.keys(data).forEach(key => {
if (data[key] === undefined || data[key] === '') {
delete data[key];
}
});
try {
const saveBtn = modal.querySelector('.btn-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Guardando...';
await updateInvoice(invoiceId, data);
// Marcar como guardado exitosamente
savedSuccessfully = true;
changeTracker.markAsSaved();
toast.success('Factura actualizada correctamente');
if (onUpdate) onUpdate();
handleClose();
} catch (error) {
console.error('Error al guardar:', error);
toast.error('Error al guardar la factura: ' + error.message);
} finally {
const saveBtn = modal.querySelector('.btn-save');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Guardar Cambios';
}
}
};
// Manejar validación
const handleValidate = async () => {
const ok = await showConfirmExitModal({
title: 'Validar factura',
message: '¿Seguro que quieres validar esta factura? No podrás editarla completamente después.',
confirmText: 'Validar',
cancelText: 'Cancelar'
});
if (!ok) return;
try {
const validateBtn = modal.querySelector('.btn-validate');
validateBtn.disabled = true;
validateBtn.textContent = 'Validando...';
await validateInvoice(invoiceId);
// Marcar como guardado para no mostrar el modal de confirmación
savedSuccessfully = true;
changeTracker.markAsSaved();
toast.success('Factura validada correctamente');
if (onUpdate) onUpdate();
// Recargar la factura para mostrar el nuevo estado
await loadInvoice();
// Recapturar estado inicial después de recargar
savedSuccessfully = false;
} catch (error) {
console.error('Error al validar:', error);
toast.error('Error al validar la factura: ' + error.message);
const validateBtn = modal.querySelector('.btn-validate');
if (validateBtn) {
validateBtn.disabled = false;
validateBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><polyline points="20 6 9 17 4 12"/></svg>Validar Factura';
}
}
};
// Manejar descarga del PDF
const handleDownloadPdf = async () => {
if (!invoice?.number) {
toast.warning('No se puede descargar la factura porque no tiene número.');
return;
}
const downloadBtn = modal.querySelector('.btn-download-invoice');
const originalHtml = downloadBtn?.innerHTML;
try {
if (downloadBtn) {
downloadBtn.disabled = true;
downloadBtn.textContent = 'Descargando...';
}
const pdfBlob = await downloadInvoicePdf(invoice.number);
const blobUrl = URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `Factura-${invoice.number}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('Error al descargar factura:', error);
toast.error('Error al descargar la factura: ' + error.message);
} finally {
if (downloadBtn) {
downloadBtn.disabled = false;
downloadBtn.innerHTML = originalHtml;
}
}
};
// Manejar vista previa del PDF
const handlePreviewPdf = async () => {
if (!invoice?.number) {
toast.warning('No se puede previsualizar la factura porque no tiene número.');
return;
}
const previewBtn = modal.querySelector('.btn-preview-invoice');
const originalHtml = previewBtn?.innerHTML;
const previewWindow = window.open('', '_blank');
if (!previewWindow) {
toast.warning('El navegador bloqueó la ventana emergente. Permite popups para ver la vista previa.');
return;
}
previewWindow.opener = null;
previewWindow.document.title = 'Vista previa factura';
try {
if (previewBtn) {
previewBtn.disabled = true;
previewBtn.textContent = 'Abriendo...';
}
const pdfBlob = await downloadInvoicePdf(invoice.number);
if (activePreviewBlobUrl) {
URL.revokeObjectURL(activePreviewBlobUrl);
}
activePreviewBlobUrl = URL.createObjectURL(pdfBlob);
previewWindow.location.href = activePreviewBlobUrl;
} catch (error) {
console.error('Error al previsualizar factura:', error);
previewWindow.close();
toast.error('Error al abrir la vista previa: ' + error.message);
} finally {
if (previewBtn) {
previewBtn.disabled = false;
previewBtn.innerHTML = originalHtml;
}
}
};
// Mostrar/ocultar formulario de añadir línea
const toggleAddLineForm = () => {
const form = modal.querySelector('.add-line-form');
if (form) {
const isVisible = form.style.display !== 'none';
form.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// Limpiar campos
modal.querySelector('#line-description').value = '';
modal.querySelector('#line-quantity').value = '1';
modal.querySelector('#line-price').value = '0';
modal.querySelector('#line-tax').value = '21';
}
}
};
// Guardar nueva línea
const handleSaveLine = async () => {
const description = modal.querySelector('#line-description').value.trim();
const quantity = parseFloat(modal.querySelector('#line-quantity').value);
const unitPrice = parseFloat(modal.querySelector('#line-price').value);
const taxRate = parseFloat(modal.querySelector('#line-tax').value);
if (!description) {
toast.warning('La descripción es obligatoria');
return;
}
if (!quantity || quantity <= 0) {
toast.warning('La cantidad debe ser mayor que 0');
return;
}
if (unitPrice < 0) {
toast.warning('El precio no puede ser negativo');
return;
}
if (taxRate < 0 || taxRate > 100) {
toast.warning('El IVA debe estar entre 0 y 100');
return;
}
try {
const saveBtn = modal.querySelector('.btn-save-line');
saveBtn.disabled = true;
saveBtn.textContent = 'Guardando...';
await addInvoiceLine(invoiceId, {
description,
quantity,
unitPrice,
taxRate
});
toast.success('Línea añadida correctamente');
toggleAddLineForm();
// Recargar y recapturar estado inicial
await loadInvoice();
changeTracker.markAsSaved();
} catch (error) {
console.error('Error al añadir línea:', error);
toast.error('Error al añadir línea: ' + error.message);
} finally {
const saveBtn = modal.querySelector('.btn-save-line');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = 'Guardar Línea';
}
}
};
// Editar línea de factura inline
const handleEditLine = (lineId) => {
const line = invoice.lines?.find(l => String(l.id) === String(lineId));
if (!line) return;
const row = modal.querySelector(`tr[data-line-id="${lineId}"]`);
if (!row) return;
row.innerHTML = `
<td><input class="line-edit-input line-edit-desc" type="text" value="${(line.description || '').replace(/"/g, '&quot;')}" /></td>
<td><input class="line-edit-input line-edit-qty" type="number" value="${line.quantity ?? 1}" min="0.01" step="0.01" style="width:70px"/></td>
<td><input class="line-edit-input line-edit-price" type="number" value="${line.unitPrice ?? 0}" min="0" step="0.01" style="width:90px"/></td>
<td><input class="line-edit-input line-edit-tax" type="number" value="${line.taxRate ?? 21}" min="0" max="100" step="0.01" style="width:60px"/></td>
<td></td>
<td class="line-actions">
<button type="button" class="btn-save-line-edit btn-small btn-success" title="Guardar"></button>
<button type="button" class="btn-cancel-line-edit btn-small" title="Cancelar"></button>
</td>
`;
row.querySelector('.btn-save-line-edit').addEventListener('click', () => saveLineEdit(lineId, row));
row.querySelector('.btn-cancel-line-edit').addEventListener('click', () => loadInvoice());
row.querySelector('.line-edit-desc').focus();
};
const saveLineEdit = async (lineId, row) => {
const description = row.querySelector('.line-edit-desc').value.trim();
const quantity = parseFloat(row.querySelector('.line-edit-qty').value);
const unitPrice = parseFloat(row.querySelector('.line-edit-price').value);
const taxRate = parseFloat(row.querySelector('.line-edit-tax').value);
if (!description) { toast.warning('La descripción es obligatoria'); return; }
if (!quantity || quantity <= 0) { toast.warning('La cantidad debe ser mayor que 0'); return; }
const saveBtn = row.querySelector('.btn-save-line-edit');
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
await updateInvoiceLine(invoiceId, lineId, { description, quantity, unitPrice, taxRate });
toast.success('Línea actualizada');
if (onUpdate) onUpdate();
await loadInvoice();
changeTracker.markAsSaved();
} catch (error) {
toast.error('Error al actualizar línea: ' + error.message);
saveBtn.disabled = false;
saveBtn.textContent = '✓';
}
};
// Eliminar línea de factura
const handleDeleteLine = async (lineId) => {
const ok = await showConfirmExitModal({
title: 'Eliminar línea',
message: '¿Seguro que quieres eliminar esta línea de la factura?',
confirmText: 'Eliminar',
cancelText: 'Cancelar'
});
if (!ok) return;
try {
// Deshabilitar el botón mientras se elimina
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
if (btn) {
btn.disabled = true;
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="spin"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>';
}
await deleteInvoiceLine(invoiceId, lineId);
if (onUpdate) onUpdate();
await loadInvoice();
changeTracker.markAsSaved();
} catch (error) {
console.error('Error al eliminar línea:', error);
toast.error('Error al eliminar línea: ' + error.message);
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
if (btn) {
btn.disabled = false;
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
}
}
};
// Manejar pago de factura
const handlePayment = async () => {
const amountInput = modal.querySelector('#payment-amount');
const dateInput = modal.querySelector('#payment-date');
const refInput = modal.querySelector('#payment-ref');
if (!amountInput || !dateInput) return;
const rawAmount = parseFloat(amountInput.value);
const remainToPay = invoice.remainToPay;
// Si vacío o 0, pago total (enviar null)
let amount = null;
if (!isNaN(rawAmount) && rawAmount > 0) {
if (rawAmount > remainToPay) {
toast.warning(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`);
return;
}
amount = rawAmount;
}
const paymentDate = dateInput.value;
if (!paymentDate) {
toast.warning('La fecha de pago es obligatoria');
return;
}
const paymentRef = refInput?.value?.trim() || undefined;
const paymentModeId = parseInt(modal.querySelector('#payment-method')?.value) || 4;
const accountId = parseInt(modal.querySelector('#payment-account')?.value) || 1;
try {
const payBtn = modal.querySelector('#btn-pay');
payBtn.disabled = true;
payBtn.textContent = 'Procesando...';
await addPayment(invoiceId, {
amount: amount,
paymentDate: paymentDate,
paymentModeId,
closePaidInvoices: "yes",
accountId,
numPayment: paymentRef
});
savedSuccessfully = true;
changeTracker.markAsSaved();
const displayAmount = amount ? `${amount.toFixed(2)}` : `${remainToPay.toFixed(2)} € (total)`;
toast.success(`Pago de ${displayAmount} registrado correctamente`);
if (onUpdate) onUpdate();
await loadInvoice();
savedSuccessfully = false;
} catch (error) {
console.error('Error al registrar pago:', error);
toast.error('Error al registrar el pago: ' + error.message);
} finally {
const payBtn = modal.querySelector('#btn-pay');
if (payBtn) {
payBtn.disabled = false;
payBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>Registrar Pago';
}
}
};
// Descargar documento adjunto
const handleDocumentDownload = async (encodedPath, name) => {
try {
const response = await apiRequest(
`/api/Document/download?modulePart=invoice&file=${encodedPath}`,
{ method: 'GET', responseType: 'raw' }
);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
toast.error('Error al descargar documento: ' + err.message);
}
};
// Cargar factura + datos auxiliares en paralelo
const loadInvoice = async () => {
try {
isLoading = true;
renderContent();
// Load invoice first so we have invoice.number for the documents endpoint
invoice = await getInvoiceById(invoiceId).catch(() => null);
// Resolve client name if BFF didn't return it
if (invoice && invoice.clientId && !invoice.clientName) {
try {
const clients = await apiGet(`/api/Clients?limit=500`);
const list = Array.isArray(clients) ? clients : clients.data || [];
const match = list.find(c => c.id === invoice.clientId);
if (match) invoice.clientName = match.name || match.fullName || match.label;
} catch { /* best-effort */ }
}
const [paymentsData, typesData, accountsData, documentsData] = await Promise.allSettled([
getPayments(invoiceId),
getPaymentTypes(),
apiGet('/api/Bank/accounts'),
invoice?.id
? apiGet(`/api/Document/list?modulePart=invoice&id=${invoice.id}`)
: Promise.resolve([]),
]);
payments = paymentsData.status === 'fulfilled' ? paymentsData.value : [];
paymentTypes = typesData.status === 'fulfilled' ? typesData.value : [];
bankAccounts = accountsData.status === 'fulfilled' ? accountsData.value : [];
documents = documentsData.status === 'fulfilled' ? (documentsData.value ?? []) : [];
isLoading = false;
renderContent();
} catch (error) {
console.error('Error al cargar factura:', error);
isLoading = false;
invoice = null;
renderContent();
}
};
// Inicializar modal
modal.innerHTML = `
<div class="modal-content invoice-modal">
</div>
`;
// Escape key closes modal
const handleEscapeKey = (e) => {
if (e.key !== 'Escape') return;
if (!modal.isConnected) {
document.removeEventListener('keydown', handleEscapeKey);
return;
}
handleClose();
};
document.addEventListener('keydown', handleEscapeKey);
// Cargar factura
loadInvoice();
return modal;
}

View File

@ -0,0 +1,39 @@
import { doliLogo } from '../services/icons.js';
export function renderLogin(onLogin) {
const loginContainer = document.createElement('div');
loginContainer.className = 'login-container';
loginContainer.innerHTML = `
<div class="login-card">
<div class="login-brand">
<div class="login-logo-icon">
${doliLogo(44)}
</div>
</div>
<h2 class="login-title">Iniciar sesión</h2>
<p class="login-subtitle">Accede a tu cuenta para continuar</p>
<form id="login-form">
<div class="form-group">
<label for="identifier">Usuario o email</label>
<input type="text" id="identifier" name="identifier" required autocomplete="username" placeholder="tu@email.com">
</div>
<div class="form-group">
<label for="password">Contraseña</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
</div>
<button type="submit" class="login-button">Ingresar</button>
</form>
<p id="login-error" class="login-error"></p>
</div>
`;
loginContainer.querySelector('#login-form').addEventListener('submit', (e) => {
e.preventDefault();
const email = loginContainer.querySelector('#identifier').value;
const password = loginContainer.querySelector('#password').value;
onLogin(email, password);
});
return loginContainer;
}

View File

@ -0,0 +1,82 @@
import { auth } from '../services/auth.js';
import { doliLogo, icons } from '../services/icons.js';
export function createSidebar(pages, currentPage) {
const sidebar = document.createElement('aside');
sidebar.className = 'sidebar';
sidebar.id = 'sidebar';
const sidebarHeader = document.createElement('div');
sidebarHeader.className = 'sidebar-header';
sidebarHeader.innerHTML = /*html*/`
<div class="sidebar-brand">
<div class="sidebar-logo-icon">
${doliLogo(26)}
</div>
<h2>Doli</h2>
</div>
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Alternar barra lateral">
${icons.menu}
</button>
`;
const nav = document.createElement('nav');
nav.className = 'sidebar-nav';
const navList = document.createElement('ul');
navList.className = 'sidebar-menu';
pages.forEach(page => {
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = `#${page.route}`;
link.className = 'sidebar-link';
if (page.route === currentPage) {
link.classList.add('active');
}
link.innerHTML = /*html*/`
<span class="sidebar-icon">${page.icon}</span>
<span class="sidebar-text">${page.name}</span>
`;
listItem.appendChild(link);
navList.appendChild(listItem);
});
nav.appendChild(navList);
sidebar.appendChild(sidebarHeader);
sidebar.appendChild(nav);
const user = auth.getUser();
const identifier = user?.identifier || user?.name || user?.login || '?';
const initials = identifier.slice(0, 2).toUpperCase();
const sidebarFooter = document.createElement('div');
sidebarFooter.className = 'sidebar-footer';
sidebarFooter.innerHTML = /*html*/`
<div class="sidebar-user">
<div class="sidebar-avatar">${initials}</div>
<span class="sidebar-username">${identifier}</span>
</div>
<button class="sidebar-logout-btn" title="Cerrar sesión">
${icons.logout}
</button>
`;
sidebarFooter.querySelector('.sidebar-logout-btn').addEventListener('click', () => {
auth.logout();
window.location.hash = '#login';
});
sidebar.appendChild(sidebarFooter);
const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle');
toggleBtn.addEventListener('click', () => {
if (window.matchMedia('(max-width: 768px)').matches) {
document.dispatchEvent(new CustomEvent('sidebar:close-mobile'));
} else {
sidebar.classList.toggle('collapsed');
}
});
return sidebar;
}

View File

@ -0,0 +1,359 @@
import { pipeline } from '@huggingface/transformers';
import { pagesRegistry } from '../pages/pagesRegistry.js';
// ── Modelo (singleton entre sesiones) ─────────────────────────────────────────
// Xenova/whisper-tiny + transformers.js v3 = WASM puro, sin WebGPU, todos los navegadores
let transcriberPromise = null;
function loadModel(onProgress) {
if (!transcriberPromise) {
transcriberPromise = pipeline(
'automatic-speech-recognition',
'Xenova/whisper-base',
{ progress_callback: onProgress }
).catch(err => { transcriberPromise = null; throw err; });
}
return transcriberPromise;
}
// ── Comandos de voz (generados desde pagesRegistry) ──────────────────────────
const COMMANDS = pagesRegistry
.filter(p => p.voicePatterns?.length)
.map(p => ({
label: p.name,
hint: `"${p.voicePatterns.slice(0, 2).join('" · "')}"`,
icon: p.icon,
patterns: p.voicePatterns,
action: () => { window.location.hash = '#' + p.route; },
}));
function matchCommand(text) {
const norm = text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
for (const cmd of COMMANDS) {
for (const pattern of cmd.patterns) {
const normP = pattern.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
if (norm.includes(normP)) return cmd;
}
}
return null;
}
// ── Grabación con detección de silencio ───────────────────────────────────────
async function startRecording(onSilence) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
const mimeType = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
.find(t => MediaRecorder.isTypeSupported(t)) || '';
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
const chunks = [];
recorder.ondataavailable = e => { if (e.data?.size > 0) chunks.push(e.data); };
recorder.start(200);
// Detección de silencio via Web Audio API
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const SILENCE_THRESHOLD = 12; // RMS 0-128 (sala silenciosa ≈ 2-5)
const SILENCE_DELAY_MS = 1800; // ms de silencio para auto-parar
const MIN_RECORD_MS = 700; // no parar antes de este tiempo
const startTime = Date.now();
let silenceStart = null;
let rafId = null;
let stopped = false;
function tick() {
if (stopped) return;
analyser.getByteTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0 - 1.0;
sum += v * v;
}
const rms = Math.sqrt(sum / dataArray.length) * 128;
if (Date.now() - startTime >= MIN_RECORD_MS) {
if (rms < SILENCE_THRESHOLD) {
if (!silenceStart) silenceStart = Date.now();
else if (Date.now() - silenceStart >= SILENCE_DELAY_MS) {
stopped = true;
audioCtx.close().catch(() => { });
onSilence?.();
return;
}
} else {
silenceStart = null;
}
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
return {
stop: () => new Promise(resolve => {
stopped = true;
if (rafId) cancelAnimationFrame(rafId);
audioCtx.close().catch(() => { });
recorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
resolve(new Blob(chunks, { type: mimeType || 'audio/webm' }));
};
if (recorder.state !== 'inactive') recorder.stop();
else resolve(new Blob(chunks));
}),
};
}
async function blobToFloat32(blob) {
const arrayBuffer = await blob.arrayBuffer();
if (arrayBuffer.byteLength === 0) throw new Error('Blob de audio vacío');
// 1) Decodificar al sample rate nativo del archivo (p.ej. 44100 Hz)
const decodeCtx = new AudioContext();
let nativeBuffer;
try {
nativeBuffer = await decodeCtx.decodeAudioData(arrayBuffer);
} finally {
await decodeCtx.close();
}
const srcRate = nativeBuffer.sampleRate;
const srcData = nativeBuffer.getChannelData(0); // mono
// 2) Si ya está a 16 kHz no hace falta resampling
if (srcRate === 16000) return srcData;
// 3) Resampling a 16 kHz con OfflineAudioContext
const targetLength = Math.ceil(srcData.length * (16000 / srcRate));
const offCtx = new OfflineAudioContext(1, targetLength, 16000);
const srcBuf = offCtx.createBuffer(1, srcData.length, srcRate);
srcBuf.getChannelData(0).set(srcData);
const source = offCtx.createBufferSource();
source.buffer = srcBuf;
source.connect(offCtx.destination);
source.start(0);
const resampled = await offCtx.startRendering();
return resampled.getChannelData(0);
}
// ── Componente ────────────────────────────────────────────────────────────────
let assistantEl = null;
export function mountVoiceAssistant() {
if (assistantEl) { assistantEl.style.display = ''; return; }
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return;
// ── DOM ────────────────────────────────────────────────────────────────────
assistantEl = document.createElement('div');
assistantEl.className = 'va-root';
assistantEl.innerHTML = /*html*/`
<div class="va-panel" id="va-panel" hidden>
<div class="va-panel-header">
<span class="va-panel-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
Asistente de voz
</span>
<button class="va-close-btn" id="va-close" aria-label="Cerrar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="va-status-area" id="va-status-area">
<div class="va-status-icon" id="va-status-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</div>
<p class="va-status-text" id="va-status-text">Di un comando</p>
<p class="va-status-sub" id="va-status-sub"></p>
</div>
<div class="va-result" id="va-result" hidden>
<span class="va-result-label">Escuché:</span>
<span class="va-result-text" id="va-result-text"></span>
<span class="va-result-action" id="va-result-action"></span>
</div>
<div class="va-commands">
<p class="va-commands-title">Comandos disponibles</p>
<ul class="va-commands-list" id="va-commands-list"></ul>
</div>
</div>
<button class="va-fab" id="va-fab" aria-label="Asistente de voz">
<svg class="va-fab-mic" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
<svg class="va-fab-stop" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="display:none"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
</button>
`;
document.body.appendChild(assistantEl);
// ── Poblar lista de comandos ────────────────────────────────────────────────
const cmdList = assistantEl.querySelector('#va-commands-list');
COMMANDS.forEach(cmd => {
const li = document.createElement('li');
li.className = 'va-cmd-item';
li.innerHTML = /*html*/`
<span class="va-cmd-icon">${cmd.icon}</span>
<span class="va-cmd-info">
<span class="va-cmd-label">${cmd.label}</span>
<span class="va-cmd-hint">${cmd.hint}</span>
</span>
`;
li.addEventListener('click', () => { cmd.action(); closePanel(); });
cmdList.appendChild(li);
});
// ── Referencias ────────────────────────────────────────────────────────────
const fab = assistantEl.querySelector('#va-fab');
const panel = assistantEl.querySelector('#va-panel');
const closeBtn = assistantEl.querySelector('#va-close');
const statusIcon = assistantEl.querySelector('#va-status-icon');
const statusText = assistantEl.querySelector('#va-status-text');
const statusSub = assistantEl.querySelector('#va-status-sub');
const resultBox = assistantEl.querySelector('#va-result');
const resultText = assistantEl.querySelector('#va-result-text');
const resultAction = assistantEl.querySelector('#va-result-action');
const fabMic = fab.querySelector('.va-fab-mic');
const fabStop = fab.querySelector('.va-fab-stop');
// ── Estado ─────────────────────────────────────────────────────────────────
let state = 'idle'; // idle | recording | processing
let recorder = null;
let panelOpen = false;
function setStatus(s, text, sub = '') {
state = s;
statusText.textContent = text;
statusSub.textContent = sub;
statusIcon.className = `va-status-icon va-status-icon--${s}`;
fab.className = `va-fab va-fab--${s}`;
fabMic.style.display = (s === 'recording') ? 'none' : '';
fabStop.style.display = (s === 'recording') ? '' : 'none';
}
function showResult(heard, actionLabel) {
resultBox.hidden = false;
resultText.textContent = `"${heard}"`;
resultAction.textContent = actionLabel ? `${actionLabel}` : '→ Comando no reconocido';
resultAction.className = 'va-result-action' + (actionLabel ? '' : ' va-result-action--unknown');
}
function openPanel() {
panel.hidden = false;
panelOpen = true;
resultBox.hidden = true;
fab.setAttribute('aria-label', 'Hablar');
}
function closePanel() {
panel.hidden = true;
panelOpen = false;
if (state === 'recording' && recorder) {
recorder.stop().catch(() => { });
recorder = null;
}
setStatus('idle', 'Di un comando…');
}
// ── Lógica principal ───────────────────────────────────────────────────────
fab.addEventListener('click', async () => {
if (!panelOpen) { openPanel(); return; }
if (state === 'recording') {
// Detener grabación manualmente
setStatus('processing', 'Procesando audio…');
const blob = await recorder.stop();
recorder = null;
await transcribe(blob);
return;
}
if (state !== 'idle') return;
// Iniciar grabación con auto-stop por silencio
try {
recorder = await startRecording(async () => {
if (state !== 'recording') return;
setStatus('processing', 'Procesando audio…');
const blob = await recorder.stop();
recorder = null;
await transcribe(blob);
});
setStatus('recording', 'Escuchando…', 'Para solo al detectar silencio');
resultBox.hidden = true;
// Pre-carga el modelo mientras graba (sin bloquear)
if (!transcriberPromise) loadModel();
} catch (e) {
console.error('[VA] Micrófono:', e);
setStatus('idle', 'Sin acceso al micrófono');
}
});
closeBtn.addEventListener('click', closePanel);
async function transcribe(blob) {
try {
// Carga el modelo si aún no está listo
setStatus('processing', 'Cargando modelo…', 'Solo la primera vez (~140 MB)');
const transcriber = await loadModel(prog => {
if (prog.status === 'progress') {
const pct = Math.round(prog.progress ?? 0);
statusSub.textContent = `Descargando: ${pct}%`;
}
});
setStatus('processing', 'Transcribiendo…', '');
let audioData;
try {
audioData = await blobToFloat32(blob);
} catch (e) {
console.error('[VA] Error decodificando audio:', e);
setStatus('idle', 'Error al leer el audio');
return;
}
if (audioData.length < 3200) { // < 0.2s de audio
setStatus('idle', 'Muy corto, inténtalo de nuevo');
return;
}
const result = await transcriber(audioData, { language: 'spanish', task: 'transcribe' });
const text = (result?.text ?? '').trim();
if (!text) {
setStatus('idle', 'No se detectó voz');
return;
}
const cmd = matchCommand(text);
showResult(text, cmd?.label ?? '');
setStatus('idle', 'Di un comando…');
if (cmd) {
setTimeout(() => { cmd.action(); }, 600);
}
} catch (e) {
console.error('[VA] Error en transcripción:', e?.message ?? e);
const msg = e?.message?.includes('vacío') ? 'Audio vacío, graba más tiempo'
: e?.message?.includes('session') ? 'Error cargando el modelo'
: e?.message?.includes('decodeAudioData') ? 'Formato de audio no soportado'
: 'Error al procesar';
setStatus('idle', msg);
}
}
}
export function hideVoiceAssistant() {
if (assistantEl) assistantEl.style.display = 'none';
}
export function showVoiceAssistant() {
if (assistantEl) assistantEl.style.display = '';
else mountVoiceAssistant();
}

9
doli-front/src/counter.js Executable file
View File

@ -0,0 +1,9 @@
export function setupCounter(element) {
let counter = 0
const setCounter = (count) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

1
doli-front/src/javascript.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

20
doli-front/src/main.js Executable file
View File

@ -0,0 +1,20 @@
import './style.css'
import './styles/base.css'
import './styles/sidebar.css'
import './styles/login.css'
import './styles/dashboard.css'
import './styles/facturas.css'
import './styles/clients.css'
import './styles/create-invoice.css'
import './styles/settings.css'
import './styles/voice-assistant.css'
import './styles/toast.css'
import './styles/banco.css'
import './styles/modal.css'
import { initRouter } from './router.js'
import { initSessionManager } from './services/session.js'
import { initTheme } from './services/theme.js'
initTheme()
initSessionManager()
initRouter()

View File

@ -0,0 +1,630 @@
import { icons } from '../services/icons.js';
import { apiGet, apiPost, apiPut } from '../services/apiClient.js';
import { getCountries } from '../services/setup.js';
import { showToast } from '../services/toast.js';
const API_ACCOUNTS = '/api/Bank/accounts';
const API_MOVEMENTS = '/api/Bank/movements';
const DOLIBARR_LABELS = {
'(InitialBankBalance)': 'Saldo inicial',
'(banktransfert)': 'Transferencia interna',
'(payment)': 'Pago',
'(withdraw)': 'Retirada',
};
function cleanLabel(raw) {
if (!raw) return 'Movimiento';
return DOLIBARR_LABELS[raw] ?? raw;
}
export function renderBancoPage() {
const container = document.createElement('div');
container.className = 'banco-page';
const fmt = (n) => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n ?? 0);
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('es-ES') : '—';
container.innerHTML = /*html*/`
<div class="banco-header">
<h1>Banco</h1>
<p class="banco-subtitle">Gestión de cuentas bancarias y movimientos</p>
</div>
<div class="banco-summary-grid">
<div class="banco-stat-card banco-stat-card--balance">
<div class="banco-stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Saldo total</span>
<span class="banco-stat-value" id="stat-balance"></span>
</div>
</div>
<div class="banco-stat-card banco-stat-card--income">
<div class="banco-stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/>
<polyline points="17 6 23 6 23 12"/>
</svg>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Ingresos</span>
<span class="banco-stat-value" id="stat-income"></span>
</div>
</div>
<div class="banco-stat-card banco-stat-card--expense">
<div class="banco-stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/>
<polyline points="17 18 23 18 23 12"/>
</svg>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Gastos</span>
<span class="banco-stat-value" id="stat-expense"></span>
</div>
</div>
<div class="banco-stat-card banco-stat-card--accounts">
<div class="banco-stat-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<div class="banco-stat-body">
<span class="banco-stat-label">Cuentas</span>
<span class="banco-stat-value" id="stat-accounts"></span>
</div>
</div>
</div>
<div class="banco-content-grid">
<div class="banco-card banco-card--accounts">
<div class="banco-card-header">
<h2>Cuentas bancarias</h2>
<button class="btn-banco-new" id="btn-new-account">+ Nueva cuenta</button>
</div>
<div class="banco-accounts-list" id="banco-accounts">
<div class="banco-loading">
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
</div>
</div>
</div>
<div class="banco-card banco-card--transactions">
<div class="banco-card-header">
<h2 id="tx-title">Últimos movimientos</h2>
<button class="btn-banco-back" id="btn-all-movements" style="display:none">
Todos
</button>
</div>
<div class="banco-transactions-list" id="banco-transactions">
<div class="banco-loading">
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
<div class="banco-skeleton"></div>
</div>
</div>
</div>
</div>
`;
let allMovements = [];
let selectedAccountId = null;
// ── Render helpers ────────────────────────────────────────────────
function renderAccounts(list) {
const el = container.querySelector('#banco-accounts');
if (!list || list.length === 0) {
el.innerHTML = `<div class="banco-empty">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="color:var(--gray-300)">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<p>No hay cuentas bancarias registradas</p>
</div>`;
return;
}
const totalBalance = list.reduce((s, a) => s + (parseFloat(a.balance) || 0), 0);
container.querySelector('#stat-balance').textContent = fmt(totalBalance);
container.querySelector('#stat-accounts').textContent = list.length;
el.innerHTML = '';
list.forEach(acc => {
const item = document.createElement('div');
item.className = `banco-account-item${acc.isClosed ? ' banco-account-item--closed' : ''}`;
item.dataset.id = acc.id;
item.innerHTML = `
<div class="banco-account-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
</div>
<div class="banco-account-info">
<span class="banco-account-name">${acc.label || acc.ref || 'Cuenta sin nombre'}${acc.isClosed ? ' <span class="banco-account-closed-badge">Cerrada</span>' : ''}</span>
<span class="banco-account-sub">${acc.iban || acc.accountNumber || acc.ref || '—'}</span>
</div>
<div class="banco-account-right">
<span class="banco-account-balance ${parseFloat(acc.balance) < 0 ? 'banco-account-balance--negative' : ''}">${fmt(acc.balance)}</span>
<button class="btn-account-edit" title="Editar cuenta" aria-label="Editar ${acc.label}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
</svg>
</button>
<svg class="banco-account-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</div>
`;
item.querySelector('.btn-account-edit').addEventListener('click', (e) => {
e.stopPropagation();
showEditAccountModal(acc);
});
item.addEventListener('click', () => selectAccount(acc));
el.appendChild(item);
});
}
function renderTransactions(list, subtitle = null) {
const el = container.querySelector('#banco-transactions');
const title = container.querySelector('#tx-title');
const back = container.querySelector('#btn-all-movements');
title.textContent = subtitle ? `Movimientos · ${subtitle}` : 'Últimos movimientos';
back.style.display = subtitle ? 'inline-flex' : 'none';
if (!list || list.length === 0) {
el.innerHTML = `<div class="banco-empty"><p>No hay movimientos${subtitle ? ' para esta cuenta' : ' recientes'}</p></div>`;
return;
}
let income = 0, expense = 0;
el.innerHTML = list.map(tx => {
const amount = parseFloat(tx.amount) || 0;
if (amount >= 0) income += amount; else expense += Math.abs(amount);
return `
<div class="banco-tx-item">
<div class="banco-tx-indicator ${amount >= 0 ? 'banco-tx-indicator--credit' : 'banco-tx-indicator--debit'}"></div>
<div class="banco-tx-info">
<span class="banco-tx-label">${cleanLabel(tx.label)}</span>
<span class="banco-tx-date">${fmtDate(tx.dateValue || tx.date)}</span>
</div>
<span class="banco-tx-amount ${amount >= 0 ? 'banco-tx-amount--credit' : 'banco-tx-amount--debit'}">
${amount >= 0 ? '+' : ''}${fmt(amount)}
</span>
</div>
`;
}).join('');
if (!subtitle) {
container.querySelector('#stat-income').textContent = fmt(income);
container.querySelector('#stat-expense').textContent = fmt(expense);
}
}
function renderUnavailable(section) {
const el = container.querySelector(section);
el.innerHTML = `<div class="banco-empty banco-empty--soon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" style="color:var(--gray-300)">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<p>Módulo bancario no disponible aún</p>
<span class="banco-soon-tag">Próximamente</span>
</div>`;
}
// ── Account selection ─────────────────────────────────────────────
async function selectAccount(acc) {
selectedAccountId = acc.id;
// Highlight selected
container.querySelectorAll('.banco-account-item').forEach(el => {
el.classList.toggle('banco-account-item--active', String(el.dataset.id) === String(acc.id));
});
const el = container.querySelector('#banco-transactions');
el.innerHTML = `<div class="banco-loading"><div class="banco-skeleton"></div><div class="banco-skeleton"></div><div class="banco-skeleton"></div></div>`;
container.querySelector('#tx-title').textContent = `Movimientos · ${acc.label || acc.ref}`;
container.querySelector('#btn-all-movements').style.display = 'inline-flex';
try {
const raw = await apiGet(`${API_ACCOUNTS}/${acc.id}/lines`);
const lines = Array.isArray(raw) ? raw : raw?.data ?? [];
renderTransactions(lines, acc.label || acc.ref);
} catch {
el.innerHTML = `<div class="banco-empty"><p>No se pudieron cargar los movimientos</p></div>`;
}
}
function showAllMovements() {
selectedAccountId = null;
container.querySelectorAll('.banco-account-item').forEach(el => el.classList.remove('banco-account-item--active'));
renderTransactions(allMovements);
}
container.querySelector('#btn-all-movements').addEventListener('click', showAllMovements);
// ── Load all data ─────────────────────────────────────────────────
async function loadData() {
try {
const raw = await apiGet(API_ACCOUNTS);
const accounts = Array.isArray(raw) ? raw : raw?.data ?? [];
renderAccounts(accounts);
} catch {
renderUnavailable('#banco-accounts');
container.querySelector('#stat-balance').textContent = '—';
container.querySelector('#stat-accounts').textContent = '—';
}
try {
const raw = await apiGet(`${API_MOVEMENTS}?limit=50`);
allMovements = Array.isArray(raw) ? raw : raw?.data ?? [];
renderTransactions(allMovements);
} catch {
renderUnavailable('#banco-transactions');
container.querySelector('#stat-income').textContent = '—';
container.querySelector('#stat-expense').textContent = '—';
}
}
// ── Edit account modal ────────────────────────────────────────────
function showEditAccountModal(acc) {
const overlay = document.createElement('div');
overlay.className = 'banco-modal-overlay';
overlay.innerHTML = /*html*/`
<div class="banco-modal">
<div class="banco-modal-header">
<div class="banco-modal-title-group">
<div class="banco-modal-icon banco-modal-icon--edit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</div>
<div>
<h2>Editar cuenta bancaria</h2>
<p class="banco-modal-subtitle">${acc.label || acc.ref || 'Cuenta sin nombre'}</p>
</div>
</div>
<button class="banco-modal-close" type="button" aria-label="Cerrar"></button>
</div>
<form class="banco-modal-form" id="edit-account-form" novalidate>
<div class="banco-form-section">
<span class="banco-form-section-label">Identificación</span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="edit-label">Nombre de la cuenta</label>
<input type="text" class="banco-form-input" id="edit-label" value="${acc.label || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-ref">Código / Ref</label>
<input type="text" class="banco-form-input" id="edit-ref" value="${acc.ref || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-type">Tipo de cuenta</label>
<select class="banco-form-input" id="edit-type">
<option value="1">Cuenta corriente</option>
<option value="0">Cuenta de ahorro</option>
<option value="2">Caja</option>
</select>
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-currency">Divisa</label>
<input type="text" class="banco-form-input" id="edit-currency" value="${acc.currencyCode || 'EUR'}" maxlength="3" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-country">País</label>
<select class="banco-form-input" id="edit-country">
<option value="">Cargando...</option>
</select>
</div>
</div>
</div>
<div class="banco-form-section">
<span class="banco-form-section-label">Datos bancarios <span class="banco-form-section-optional">(opcional)</span></span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="edit-bank">Entidad bancaria</label>
<input type="text" class="banco-form-input" id="edit-bank" value="${acc.bank || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-number">Número de cuenta</label>
<input type="text" class="banco-form-input" id="edit-number" value="${acc.accountNumber || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-iban">IBAN</label>
<input type="text" class="banco-form-input" id="edit-iban" value="${acc.iban || ''}" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="edit-bic">BIC / SWIFT</label>
<input type="text" class="banco-form-input" id="edit-bic" value="${acc.bic || ''}" autocomplete="off" />
</div>
</div>
</div>
<div class="banco-modal-footer">
<button type="button" class="btn-banco-cancel" id="cancel-edit">Cancelar</button>
<button type="submit" class="btn-banco-submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Guardar cambios
</button>
</div>
</form>
</div>
`;
document.body.appendChild(overlay);
// Set type select value
const typeSelect = overlay.querySelector('#edit-type');
if (acc.type != null) typeSelect.value = String(acc.type);
// Load countries and pre-select current
(async () => {
const sel = overlay.querySelector('#edit-country');
try {
const countries = await getCountries();
const priority = ['ES','FR','DE','PT','GB','US'];
const first = countries.filter(c => priority.includes(c.code));
const rest = countries.filter(c => !priority.includes(c.code));
sel.innerHTML = '<option value="">Sin especificar</option>';
first.forEach(c => { const o = document.createElement('option'); o.value = c.id; o.textContent = c.label; sel.appendChild(o); });
if (rest.length) {
const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '──────────'; sel.appendChild(sep);
rest.forEach(c => { const o = document.createElement('option'); o.value = c.id; o.textContent = c.label; sel.appendChild(o); });
}
if (acc.countryId) sel.value = String(acc.countryId);
} catch {
sel.innerHTML = `
<option value="">Sin especificar</option>
<option value="4">España</option>
<option value="1">Francia</option>
<option value="5">Alemania</option>
<option value="25">Portugal</option>
<option value="7">Reino Unido</option>
<option value="11">Estados Unidos</option>`;
if (acc.countryId) sel.value = String(acc.countryId);
}
})();
const close = () => { if (overlay.isConnected) overlay.remove(); };
overlay.querySelector('.banco-modal-close').addEventListener('click', close);
overlay.querySelector('#cancel-edit').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
overlay.querySelector('#edit-account-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = {};
const label = overlay.querySelector('#edit-label').value.trim();
const ref = overlay.querySelector('#edit-ref').value.trim();
const currency = overlay.querySelector('#edit-currency').value.trim();
const bank = overlay.querySelector('#edit-bank').value.trim();
const number = overlay.querySelector('#edit-number').value.trim();
const iban = overlay.querySelector('#edit-iban').value.trim();
const bic = overlay.querySelector('#edit-bic').value.trim();
const countryId = parseInt(overlay.querySelector('#edit-country').value) || null;
const type = parseInt(overlay.querySelector('#edit-type').value);
if (label) payload.label = label;
if (ref) payload.ref = ref;
if (currency) payload.currencyCode = currency;
if (bank) payload.bank = bank;
if (number) payload.accountNumber = number;
if (iban) payload.iban = iban;
if (bic) payload.bic = bic;
if (countryId) payload.countryId = countryId;
payload.type = type;
if (Object.keys(payload).length === 1 && payload.type === acc.type) {
close(); return;
}
const submitBtn = overlay.querySelector('.btn-banco-submit');
submitBtn.disabled = true;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin 0.8s linear infinite"><circle cx="12" cy="12" r="10" stroke-opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10"/></svg> Guardando...';
try {
await apiPut(`/api/Bank/accounts/${acc.id}`, payload);
showToast('Cuenta actualizada', 'success');
close();
loadData();
} catch (err) {
showToast(err.message || 'Error al actualizar la cuenta', 'error');
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Guardar cambios';
}
});
}
// ── Create account modal ──────────────────────────────────────────
function showCreateAccountModal() {
const overlay = document.createElement('div');
overlay.className = 'banco-modal-overlay';
overlay.innerHTML = /*html*/`
<div class="banco-modal">
<div class="banco-modal-header">
<div class="banco-modal-title-group">
<div class="banco-modal-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
</div>
<div>
<h2>Nueva cuenta bancaria</h2>
<p class="banco-modal-subtitle">Los campos marcados con * son obligatorios</p>
</div>
</div>
<button class="banco-modal-close" type="button" aria-label="Cerrar"></button>
</div>
<form class="banco-modal-form" id="create-account-form" novalidate>
<div class="banco-form-section">
<span class="banco-form-section-label">Identificación</span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="acc-label">Nombre de la cuenta <span class="required">*</span></label>
<input type="text" class="banco-form-input" id="acc-label" placeholder="Ej: Cuenta corriente principal" autocomplete="off" required />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-ref">Código / Ref <span class="required">*</span></label>
<input type="text" class="banco-form-input" id="acc-ref" placeholder="Ej: CAJA-01" autocomplete="off" required />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-type">Tipo de cuenta <span class="required">*</span></label>
<select class="banco-form-input" id="acc-type">
<option value="1">Cuenta corriente</option>
<option value="0">Cuenta de ahorro</option>
<option value="2">Caja</option>
</select>
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-currency">Divisa <span class="required">*</span></label>
<input type="text" class="banco-form-input" id="acc-currency" value="EUR" maxlength="3" autocomplete="off" required />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-country">País <span class="required">*</span></label>
<select class="banco-form-input" id="acc-country">
<option value="">Cargando países...</option>
</select>
</div>
</div>
</div>
<div class="banco-form-section">
<span class="banco-form-section-label">Datos bancarios <span class="banco-form-section-optional">(opcional)</span></span>
<div class="banco-form-grid">
<div class="banco-form-field banco-form-field--full">
<label class="banco-form-label" for="acc-bank">Entidad bancaria</label>
<input type="text" class="banco-form-input" id="acc-bank" placeholder="Nombre del banco" autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-number">Número de cuenta</label>
<input type="text" class="banco-form-input" id="acc-number" placeholder="ES12 3456 7890..." autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-iban">IBAN</label>
<input type="text" class="banco-form-input" id="acc-iban" placeholder="ES00 0000 0000..." autocomplete="off" />
</div>
<div class="banco-form-field">
<label class="banco-form-label" for="acc-bic">BIC / SWIFT</label>
<input type="text" class="banco-form-input" id="acc-bic" placeholder="CAIXESBBXXX" autocomplete="off" />
</div>
</div>
</div>
<div class="banco-modal-footer">
<button type="button" class="btn-banco-cancel" id="cancel-account">Cancelar</button>
<button type="submit" class="btn-banco-submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Crear cuenta
</button>
</div>
</form>
</div>
`;
document.body.appendChild(overlay);
// Focus first input
setTimeout(() => overlay.querySelector('#acc-label')?.focus(), 50);
// Load countries
(async () => {
const sel = overlay.querySelector('#acc-country');
try {
const countries = await getCountries();
const priority = ['ES','FR','DE','PT','GB','US'];
const first = countries.filter(c => priority.includes(c.code));
const rest = countries.filter(c => !priority.includes(c.code));
sel.innerHTML = '<option value="">Seleccionar país...</option>';
first.forEach(c => {
const o = document.createElement('option');
o.value = c.id; o.textContent = c.label;
if (c.code === 'ES') o.selected = true;
sel.appendChild(o);
});
if (rest.length) {
const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '──────────';
sel.appendChild(sep);
rest.forEach(c => { const o = document.createElement('option'); o.value = c.id; o.textContent = c.label; sel.appendChild(o); });
}
} catch {
sel.innerHTML = `
<option value="">Seleccionar país...</option>
<option value="4" selected>España</option>
<option value="1">Francia</option>
<option value="5">Alemania</option>
<option value="25">Portugal</option>
<option value="7">Reino Unido</option>
<option value="11">Estados Unidos</option>`;
}
})();
const close = () => { if (overlay.isConnected) overlay.remove(); };
overlay.querySelector('.banco-modal-close').addEventListener('click', close);
overlay.querySelector('#cancel-account').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
overlay.querySelector('#create-account-form').addEventListener('submit', async (e) => {
e.preventDefault();
const label = overlay.querySelector('#acc-label').value.trim();
const ref = overlay.querySelector('#acc-ref').value.trim();
const countryId = parseInt(overlay.querySelector('#acc-country').value);
if (!label) { showToast('El nombre es obligatorio', 'warning'); overlay.querySelector('#acc-label').focus(); return; }
if (!ref) { showToast('El código/ref es obligatorio', 'warning'); overlay.querySelector('#acc-ref').focus(); return; }
if (!countryId) { showToast('Selecciona un país', 'warning'); overlay.querySelector('#acc-country').focus(); return; }
const submitBtn = overlay.querySelector('.btn-banco-submit');
submitBtn.disabled = true;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin 0.8s linear infinite"><circle cx="12" cy="12" r="10" stroke-opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10"/></svg> Guardando...';
try {
await apiPost('/api/Bank/accounts', {
label,
ref,
type: parseInt(overlay.querySelector('#acc-type').value),
currencyCode: overlay.querySelector('#acc-currency').value.trim() || 'EUR',
countryId,
bank: overlay.querySelector('#acc-bank').value.trim() || null,
accountNumber: overlay.querySelector('#acc-number').value.trim() || null,
iban: overlay.querySelector('#acc-iban').value.trim() || null,
bic: overlay.querySelector('#acc-bic').value.trim() || null,
});
showToast('Cuenta bancaria creada', 'success');
close();
loadData();
} catch (err) {
showToast(err.message || 'Error al crear la cuenta', 'error');
submitBtn.disabled = false;
submitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Crear cuenta';
}
});
}
container.querySelector('#btn-new-account').addEventListener('click', showCreateAccountModal);
loadData();
return container;
}

View File

@ -0,0 +1,432 @@
import { ClientItem } from '../components/ClientItem.js';
import { createClient, getClients } from '../services/clients.js';
import { icons } from '../services/icons.js';
import { toast } from '../services/toast.js';
export function renderClientesPage() {
const container = document.createElement('div');
container.className = 'clientes-page page-enter';
let currentPage = 1;
let totalPages = 1;
const pageSize = 20;
let allClients = [];
let filteredClients = [];
let searchTerm = '';
container.innerHTML = /*html*/`
<div class="clientes-header">
<h1>Terceros</h1>
</div>
<div class="clientes-filters">
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar terceros..." class="search-input search-input--with-icon" />
</div>
<button class="btn-new-client">
${icons.plus} Nuevo tercero
</button>
</div>
<div class="clients-table-container">
<table class="clients-table">
<thead>
<tr>
<th class="client-avatar-col"></th>
<th class="client-name-col">Nombre / Código</th>
<th class="client-type-col">Tipo</th>
<th class="client-status-col">Estado</th>
<th class="client-email-col">Email</th>
<th class="client-phone-col">Teléfono</th>
<th class="client-actions-col">Acciones</th>
</tr>
</thead>
<tbody class="clients-list">
<tr><td colspan="7" class="loading">Cargando terceros...</td></tr>
</tbody>
</table>
</div>
<div class="pagination">
<button class="btn-pagination btn-prev"> Anterior</button>
<div class="pagination-info">
<span class="current-page">1</span> / <span class="total-pages">1</span>
</div>
<button class="btn-pagination btn-next">Siguiente </button>
</div>
`;
function displayValue(value) {
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
return value;
}
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
function getClientStatusText(status) {
const statusMap = { '0': 'Inactivo', '1': 'Activo' };
return statusMap[status] || 'N/A';
}
function applyFilters() {
if (!searchTerm) {
filteredClients = [...allClients];
} else {
const term = searchTerm.toLowerCase();
filteredClients = allClients.filter(client => {
const nameMatch = client.name?.toLowerCase().includes(term);
const codeMatch = client.codeClient?.toLowerCase().includes(term);
const emailMatch = client.email?.toLowerCase().includes(term);
const phoneMatch = client.phone?.toLowerCase().includes(term);
const contactMatch = client.contacts?.some(c =>
c.firstname?.toLowerCase().includes(term) ||
c.lastname?.toLowerCase().includes(term) ||
c.email?.toLowerCase().includes(term)
);
return nameMatch || codeMatch || emailMatch || phoneMatch || contactMatch;
});
}
renderPage(1);
}
function renderPage(page = 1) {
const clientsList = container.querySelector('.clients-list');
currentPage = page;
totalPages = Math.ceil(filteredClients.length / pageSize);
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, filteredClients.length);
const clients = filteredClients.slice(startIndex, endIndex);
updatePaginationUI();
clientsList.innerHTML = '';
if (clients.length === 0) {
clientsList.innerHTML = `<tr><td colspan="7"><div class="empty-state">${icons.emptyClients}<h3>No hay terceros</h3><p>Los terceros aparecerán aquí una vez añadidos.</p></div></td></tr>`;
return;
}
clients.forEach(client => {
const clientItem = ClientItem(client, handleViewClient);
clientsList.appendChild(clientItem);
});
}
function handleViewClient(clientId) {
const client = allClients.find(c => c.id === clientId);
if (client) {
showClientModal(client);
}
}
function showClientModal(client) {
const modal = document.createElement('div');
modal.className = 'client-modal-overlay';
const contactsHtml = client.contacts && client.contacts.length > 0
? client.contacts.map(contact => `
<div class="contact-card">
<div class="contact-header">
<div class="contact-avatar">${icons.user}</div>
<span class="contact-full-name">${escapeHtml(displayValue(contact.firstname))} ${escapeHtml(displayValue(contact.lastname))}</span>
</div>
<div class="contact-details">
${contact.email ? `<div class="contact-row"><span class="contact-icon">${icons.mail}</span><span>${escapeHtml(displayValue(contact.email))}</span></div>` : ''}
${contact.phoneMobile ? `<div class="contact-row"><span class="contact-icon">${icons.mobile}</span><span>${escapeHtml(displayValue(contact.phoneMobile))}</span></div>` : ''}
${contact.phonePro ? `<div class="contact-row"><span class="contact-icon">${icons.phone}</span><span>${escapeHtml(displayValue(contact.phonePro))}</span></div>` : ''}
${contact.phonePerso ? `<div class="contact-row"><span class="contact-icon">${icons.phone}</span><span>${escapeHtml(displayValue(contact.phonePerso))}</span></div>` : ''}
</div>
</div>
`).join('')
: '<p class="no-contacts">No hay contactos registrados</p>';
modal.innerHTML = /*html*/`
<div class="client-modal">
<div class="client-modal-header">
<h2>${escapeHtml(displayValue(client.name))}</h2>
<button class="btn-close-modal">${icons.close}</button>
</div>
<div class="client-modal-body">
<div class="client-info-section">
<h3>Información del Tercero</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">ID</span>
<span class="info-value">${escapeHtml(client.id)}</span>
</div>
<div class="info-item">
<span class="info-label">Código</span>
<span class="info-value">${escapeHtml(displayValue(client.codeClient))}</span>
</div>
<div class="info-item">
<span class="info-label">Tipo</span>
<span class="info-value">${{ client: 'Cliente', supplier: 'Proveedor', both: 'Ambos' }[client.role] || 'N/A'}</span>
</div>
<div class="info-item">
<span class="info-label">Estado</span>
<span class="info-value">${escapeHtml(getClientStatusText(client.status))}</span>
</div>
<div class="info-item">
<span class="info-label">Email</span>
<span class="info-value">${escapeHtml(displayValue(client.email))}</span>
</div>
<div class="info-item">
<span class="info-label">Teléfono</span>
<span class="info-value">${escapeHtml(displayValue(client.phone))}</span>
</div>
</div>
</div>
<div class="contacts-section">
<h3>Contactos (${client.contacts?.length || 0})</h3>
<div class="contacts-list">
${contactsHtml}
</div>
</div>
</div>
</div>
`;
const closeBtn = modal.querySelector('.btn-close-modal');
closeBtn.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
document.body.appendChild(modal);
}
function showCreateClientModal() {
const overlay = document.createElement('div');
overlay.className = 'client-modal-overlay';
overlay.innerHTML = /*html*/`
<div class="client-modal create-client-modal">
<div class="client-modal-header">
<h2>Nuevo tercero</h2>
<button class="btn-close-modal" type="button">${icons.close}</button>
</div>
<form class="create-client-form" novalidate>
<div class="client-modal-body">
<div class="create-client-section">
<h3>Información básica</h3>
<div class="create-client-grid">
<div class="form-field form-field--full">
<label class="form-label" for="cc-name">Nombre <span class="form-required">*</span></label>
<input id="cc-name" name="name" type="text" class="form-input" placeholder="Nombre o razón social" required maxlength="200" />
<span class="form-error" id="cc-name-error"></span>
</div>
<div class="form-field">
<label class="form-label" for="cc-role">Tipo <span class="form-required">*</span></label>
<select id="cc-role" name="role" class="form-input">
<option value="client">Cliente</option>
<option value="supplier">Proveedor</option>
<option value="both">Ambos</option>
</select>
</div>
<div class="form-field">
<label class="form-label" for="cc-email">Email</label>
<input id="cc-email" name="email" type="email" class="form-input" placeholder="empresa@ejemplo.com" maxlength="200" />
</div>
<div class="form-field">
<label class="form-label" for="cc-phone">Teléfono</label>
<input id="cc-phone" name="phone" type="tel" class="form-input" placeholder="+34 600 000 000" maxlength="20" />
</div>
<div class="form-field">
<label class="form-label" for="cc-vat">NIF / CIF / VAT</label>
<input id="cc-vat" name="vatNumber" type="text" class="form-input" placeholder="B12345678" maxlength="50" />
</div>
<div class="form-field">
<label class="form-label" for="cc-country">País (código)</label>
<input id="cc-country" name="countryCode" type="text" class="form-input" placeholder="ES" maxlength="50" />
</div>
</div>
</div>
<div class="create-client-section">
<h3>Dirección</h3>
<div class="create-client-grid">
<div class="form-field form-field--full">
<label class="form-label" for="cc-address">Dirección</label>
<input id="cc-address" name="address" type="text" class="form-input" placeholder="Calle, número, piso..." maxlength="50" />
</div>
<div class="form-field">
<label class="form-label" for="cc-zip">Código postal</label>
<input id="cc-zip" name="zip" type="text" class="form-input" placeholder="46001" maxlength="50" />
</div>
<div class="form-field">
<label class="form-label" for="cc-town">Ciudad</label>
<input id="cc-town" name="town" type="text" class="form-input" placeholder="Valencia" maxlength="50" />
</div>
</div>
</div>
<div class="create-client-section">
<h3>Notas</h3>
<div class="create-client-grid">
<div class="form-field form-field--full">
<label class="form-label" for="cc-note-pub">Nota pública</label>
<textarea id="cc-note-pub" name="notePublic" class="form-input form-textarea" placeholder="Visible para el cliente..." maxlength="500" rows="2"></textarea>
</div>
<div class="form-field form-field--full">
<label class="form-label" for="cc-note-priv">Nota privada</label>
<textarea id="cc-note-priv" name="notePrivate" class="form-input form-textarea" placeholder="Solo visible internamente..." maxlength="500" rows="2"></textarea>
</div>
</div>
</div>
</div>
<div class="create-client-footer">
<button type="button" class="btn-cancel-create">Cancelar</button>
<button type="submit" class="btn-submit-create">
<span class="btn-submit-text">Crear cliente</span>
<span class="btn-submit-spinner" hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="animation:spin .7s linear infinite;display:block"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
</span>
</button>
</div>
</form>
</div>
`;
const form = overlay.querySelector('.create-client-form');
const nameInput = overlay.querySelector('#cc-name');
const nameError = overlay.querySelector('#cc-name-error');
const submitBtn = overlay.querySelector('.btn-submit-create');
const submitText = overlay.querySelector('.btn-submit-text');
const submitSpinner = overlay.querySelector('.btn-submit-spinner');
const close = () => overlay.remove();
overlay.querySelector('.btn-close-modal').addEventListener('click', close);
overlay.querySelector('.btn-cancel-create').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const handleEscape = (e) => {
if (e.key !== 'Escape') return;
if (!overlay.isConnected) { document.removeEventListener('keydown', handleEscape); return; }
close();
};
document.addEventListener('keydown', handleEscape);
form.addEventListener('submit', async (e) => {
e.preventDefault();
nameError.textContent = '';
const name = nameInput.value.trim();
if (!name) {
nameError.textContent = 'El nombre es obligatorio.';
nameInput.focus();
return;
}
const getData = (n) => {
const v = form.elements[n]?.value?.trim();
return v || undefined;
};
const payload = {
Name: name,
Role: getData('role') || 'client',
Email: getData('email'),
Phone: getData('phone'),
VatNumber: getData('vatNumber'),
CountryCode: getData('countryCode'),
Address: getData('address'),
Zip: getData('zip'),
Town: getData('town'),
NotePublic: getData('notePublic'),
NotePrivate: getData('notePrivate'),
};
submitBtn.disabled = true;
submitText.hidden = true;
submitSpinner.hidden = false;
try {
await createClient(payload);
toast.success(`Tercero "${name}" creado correctamente.`);
close();
await loadAllClients();
} catch (err) {
toast.error(err.message || 'Error al crear el cliente.');
submitBtn.disabled = false;
submitText.hidden = false;
submitSpinner.hidden = true;
}
});
document.body.appendChild(overlay);
setTimeout(() => nameInput.focus(), 50);
}
async function loadAllClients() {
const clientsList = container.querySelector('.clients-list');
try {
clientsList.innerHTML = '<tr><td colspan="7" class="loading">Cargando terceros...</td></tr>';
const data = await getClients(1000, 1);
allClients = Array.isArray(data) ? data : data.data || data.clients || [];
filteredClients = [...allClients];
renderPage(1);
} catch (error) {
console.error('Error al cargar clientes:', error);
clientsList.innerHTML = `<tr><td colspan="7"><div class="empty-state">${icons.error}<h3>Error al cargar</h3><p>No se pudieron cargar los terceros. Inténtalo de nuevo.</p></div></td></tr>`;
}
}
function updatePaginationUI() {
const paginationDiv = container.querySelector('.pagination');
const currentPageSpan = container.querySelector('.current-page');
const totalPagesSpan = container.querySelector('.total-pages');
const btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next');
currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages;
if (totalPages <= 1) {
paginationDiv.style.display = 'none';
} else {
paginationDiv.style.display = 'flex';
}
btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages;
}
container.querySelector('.btn-new-client').addEventListener('click', showCreateClientModal);
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
clearTimeout(searchInput._debounceTimer);
searchInput._debounceTimer = setTimeout(() => {
searchTerm = e.target.value.trim();
applyFilters();
}, 300);
});
container.querySelector('.btn-prev').addEventListener('click', () => {
if (currentPage > 1) { renderPage(currentPage - 1); scrollToTop(); }
});
container.querySelector('.btn-next').addEventListener('click', () => {
if (currentPage < totalPages) { renderPage(currentPage + 1); scrollToTop(); }
});
function scrollToTop() {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
loadAllClients();
return container;
}

View File

@ -0,0 +1,402 @@
import { icons } from '../services/icons.js';
import { getContacts, getContactById, createContact, updateContact, deleteContact } from '../services/contacts.js';
import { showToast } from '../services/toast.js';
import { apiGet } from '../services/apiClient.js';
export function renderContactsPage() {
const container = document.createElement('div');
container.className = 'facturas-page page-enter';
let allContacts = [];
let filteredContacts = [];
let searchTerm = '';
let clients = [];
const escHtml = (str) => {
if (str == null) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
};
container.innerHTML = /*html*/`
<div class="facturas-header">
<h1>Contactos</h1>
</div>
<div class="facturas-filters">
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar por nombre, email o teléfono..." class="search-input search-input--with-icon" id="contacts-search" />
</div>
<button class="btn-new-invoice" id="btn-new-contact">${icons.plus} <span>Nuevo contacto</span></button>
</div>
<div class="invoices-table-container">
<table class="invoices-table">
<thead>
<tr>
<th>Nombre</th>
<th>Email</th>
<th>Teléfono</th>
<th>Móvil</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="contacts-tbody">
<tr><td colspan="5" class="facturas-empty">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Detail / Edit modal -->
<div class="modal-overlay sup-detail-overlay" id="contact-detail-overlay" style="display:none">
<div class="modal-content sup-detail-modal" id="contact-detail-modal">
<div class="modal-header">
<h2 id="contact-detail-title">Contacto</h2>
<button class="btn-close" id="contact-detail-close">×</button>
</div>
<div class="modal-body" id="contact-detail-body">
<div class="loading">Cargando...</div>
</div>
</div>
</div>
<!-- Create modal -->
<div class="modal-overlay sup-detail-overlay" id="contact-create-overlay" style="display:none">
<div class="modal-content sup-detail-modal" id="contact-create-modal">
<div class="modal-header">
<h2>Nuevo contacto</h2>
<button class="btn-close" id="contact-create-close">×</button>
</div>
<div class="modal-body">
<form id="contact-create-form">
<div class="invoice-detail-grid" style="margin-bottom:1rem">
<div class="form-field-compact">
<label>Apellidos *</label>
<input type="text" id="c-lastname" required style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Nombre</label>
<input type="text" id="c-firstname" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact full-width" style="grid-column:1/-1">
<label>Empresa</label>
<select id="c-client" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--card-bg);color:var(--text-primary)">
<option value="">Sin empresa</option>
</select>
</div>
<div class="form-field-compact">
<label>Email</label>
<input type="email" id="c-email" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Teléfono profesional</label>
<input type="tel" id="c-phone-pro" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Teléfono personal</label>
<input type="tel" id="c-phone-perso" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Móvil</label>
<input type="tel" id="c-phone-mobile" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact full-width" style="grid-column:1/-1">
<label>Dirección</label>
<input type="text" id="c-address" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>CP</label>
<input type="text" id="c-zip" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Ciudad</label>
<input type="text" id="c-town" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
</div>
<div class="form-actions-compact" style="margin-top:1rem">
<button type="button" class="btn-cancel-compact" id="contact-create-cancel">Cancelar</button>
<button type="submit" class="btn-submit-compact" id="contact-create-submit">Crear contacto</button>
</div>
</form>
</div>
</div>
</div>
`;
const tbody = container.querySelector('#contacts-tbody');
const searchInput = container.querySelector('#contacts-search');
const detailOverlay = container.querySelector('#contact-detail-overlay');
const detailBody = container.querySelector('#contact-detail-body');
const detailTitle = container.querySelector('#contact-detail-title');
const createOverlay = container.querySelector('#contact-create-overlay');
let currentContact = null;
function applyFilters() {
const q = searchTerm.toLowerCase();
filteredContacts = !q ? [...allContacts] : allContacts.filter(c =>
(c.lastname || '').toLowerCase().includes(q) ||
(c.firstname || '').toLowerCase().includes(q) ||
(c.email || '').toLowerCase().includes(q) ||
(c.phonePro || '').toLowerCase().includes(q) ||
(c.phoneMobile || '').toLowerCase().includes(q)
);
renderTable();
}
function renderTable() {
if (filteredContacts.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="facturas-empty">No hay contactos</td></tr>`;
return;
}
tbody.innerHTML = filteredContacts.map(c => `
<tr class="invoice-item" data-id="${c.id}">
<td class="invoice-client">
<span class="client-icon">${icons.user}</span>
<button class="btn-invoice-num" data-id="${c.id}">${escHtml([c.lastname, c.firstname].filter(Boolean).join(', ') || '—')}</button>
</td>
<td>${escHtml(c.email || '—')}</td>
<td>${escHtml(c.phonePro || '—')}</td>
<td>${escHtml(c.phoneMobile || '—')}</td>
<td class="invoice-actions">
<button class="btn-action btn-view" data-id="${c.id}" title="Ver detalle">${icons.eye}</button>
<button class="btn-action" data-delete="${c.id}" title="Eliminar" style="color:var(--danger)">${icons.close}</button>
</td>
</tr>
`).join('');
tbody.querySelectorAll('[data-id]').forEach(el =>
el.addEventListener('click', () => openDetail(Number(el.dataset.id)))
);
tbody.querySelectorAll('[data-delete]').forEach(el =>
el.addEventListener('click', (e) => { e.stopPropagation(); confirmDelete(Number(el.dataset.delete)); })
);
}
async function openDetail(id) {
detailOverlay.style.display = 'flex';
detailTitle.textContent = 'Cargando...';
detailBody.innerHTML = '<div class="loading">Cargando...</div>';
try {
const c = allContacts.find(x => x.id === id) || await getContactById(id);
currentContact = c;
detailTitle.textContent = [c.lastname, c.firstname].filter(Boolean).join(', ') || `Contacto #${c.id}`;
renderDetailView(c);
} catch (err) {
detailBody.innerHTML = `<div class="error">No se pudo cargar: ${escHtml(err.message)}</div>`;
}
}
function renderDetailView(c) {
detailBody.innerHTML = /*html*/`
<div id="contact-view-mode">
<div class="invoice-detail-grid">
<div class="detail-row"><span class="detail-label">Apellidos</span><span class="detail-value">${escHtml(c.lastname || '')}</span></div>
<div class="detail-row"><span class="detail-label">Nombre</span><span class="detail-value">${escHtml(c.firstname || '')}</span></div>
<div class="detail-row"><span class="detail-label">Email</span><span class="detail-value">${escHtml(c.email || '')}</span></div>
<div class="detail-row"><span class="detail-label">Tel. profesional</span><span class="detail-value">${escHtml(c.phonePro || '')}</span></div>
<div class="detail-row"><span class="detail-label">Tel. personal</span><span class="detail-value">${escHtml(c.phonePerso || '')}</span></div>
<div class="detail-row"><span class="detail-label">Móvil</span><span class="detail-value">${escHtml(c.phoneMobile || '')}</span></div>
${c.address ? `<div class="detail-row full-width"><span class="detail-label">Dirección</span><span class="detail-value">${escHtml(c.address)}</span></div>` : ''}
${(c.zip || c.town) ? `<div class="detail-row"><span class="detail-label">Ciudad</span><span class="detail-value">${escHtml([c.zip, c.town].filter(Boolean).join(' '))}</span></div>` : ''}
</div>
<div class="form-actions-compact" style="margin-top:1.5rem">
<button class="btn-cancel-compact" id="contact-edit-btn">Editar</button>
</div>
</div>
`;
detailBody.querySelector('#contact-edit-btn').addEventListener('click', () => renderEditView(c));
}
function renderEditView(c) {
detailBody.innerHTML = /*html*/`
<form id="contact-edit-form">
<div class="invoice-detail-grid" style="margin-bottom:1rem">
<div class="form-field-compact">
<label>Apellidos *</label>
<input type="text" id="e-lastname" value="${escHtml(c.lastname || '')}" required style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Nombre</label>
<input type="text" id="e-firstname" value="${escHtml(c.firstname || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Email</label>
<input type="email" id="e-email" value="${escHtml(c.email || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Tel. profesional</label>
<input type="tel" id="e-phone-pro" value="${escHtml(c.phonePro || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Tel. personal</label>
<input type="tel" id="e-phone-perso" value="${escHtml(c.phonePerso || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Móvil</label>
<input type="tel" id="e-phone-mobile" value="${escHtml(c.phoneMobile || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact full-width" style="grid-column:1/-1">
<label>Dirección</label>
<input type="text" id="e-address" value="${escHtml(c.address || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>CP</label>
<input type="text" id="e-zip" value="${escHtml(c.zip || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Ciudad</label>
<input type="text" id="e-town" value="${escHtml(c.town || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
</div>
<div class="form-actions-compact">
<button type="button" class="btn-cancel-compact" id="contact-edit-cancel">Cancelar</button>
<button type="submit" class="btn-submit-compact" id="contact-edit-submit">Guardar</button>
</div>
</form>
`;
detailBody.querySelector('#contact-edit-cancel').addEventListener('click', () => renderDetailView(c));
detailBody.querySelector('#contact-edit-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = detailBody.querySelector('#contact-edit-submit');
submitBtn.disabled = true;
submitBtn.textContent = 'Guardando...';
try {
await updateContact(c.id, {
lastname: detailBody.querySelector('#e-lastname').value.trim() || undefined,
firstname: detailBody.querySelector('#e-firstname').value.trim() || undefined,
email: detailBody.querySelector('#e-email').value.trim() || undefined,
phonePro: detailBody.querySelector('#e-phone-pro').value.trim() || undefined,
phonePerso: detailBody.querySelector('#e-phone-perso').value.trim() || undefined,
phoneMobile: detailBody.querySelector('#e-phone-mobile').value.trim() || undefined,
address: detailBody.querySelector('#e-address').value.trim() || undefined,
zip: detailBody.querySelector('#e-zip').value.trim() || undefined,
town: detailBody.querySelector('#e-town').value.trim() || undefined,
});
showToast('Contacto actualizado', 'success');
await reload();
const updated = allContacts.find(x => x.id === c.id) || c;
currentContact = updated;
detailTitle.textContent = [updated.lastname, updated.firstname].filter(Boolean).join(', ') || `Contacto #${c.id}`;
renderDetailView(updated);
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
submitBtn.disabled = false;
submitBtn.textContent = 'Guardar';
}
});
}
async function confirmDelete(id) {
const contact = allContacts.find(c => c.id === id);
const name = contact ? [contact.lastname, contact.firstname].filter(Boolean).join(' ') : `#${id}`;
if (!confirm(`¿Eliminar el contacto "${name}"? Esta acción no se puede deshacer.`)) return;
try {
await deleteContact(id);
showToast('Contacto eliminado', 'success');
if (detailOverlay.style.display !== 'none' && currentContact?.id === id) {
detailOverlay.style.display = 'none';
}
await reload();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function reload() {
try {
allContacts = await getContacts({ limit: 500 });
} catch (err) {
showToast(`Error al actualizar lista: ${err.message}`, 'error');
}
applyFilters();
}
async function loadClients() {
if (clients.length > 0) return;
try {
clients = await apiGet('/api/Clients?limit=500');
} catch { clients = []; }
}
function populateClientSelect(selectEl) {
selectEl.innerHTML = '<option value="">Sin empresa</option>';
clients.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
selectEl.appendChild(opt);
});
}
// Close detail
container.querySelector('#contact-detail-close').addEventListener('click', () => { detailOverlay.style.display = 'none'; });
detailOverlay.addEventListener('click', (e) => { if (e.target === detailOverlay) detailOverlay.style.display = 'none'; });
// Create modal
container.querySelector('#btn-new-contact').addEventListener('click', async () => {
await loadClients();
populateClientSelect(container.querySelector('#c-client'));
createOverlay.style.display = 'flex';
container.querySelector('#c-lastname').value = '';
container.querySelector('#c-firstname').value = '';
container.querySelector('#c-email').value = '';
container.querySelector('#c-phone-pro').value = '';
container.querySelector('#c-phone-perso').value = '';
container.querySelector('#c-phone-mobile').value = '';
container.querySelector('#c-address').value = '';
container.querySelector('#c-zip').value = '';
container.querySelector('#c-town').value = '';
});
container.querySelector('#contact-create-close').addEventListener('click', () => { createOverlay.style.display = 'none'; });
container.querySelector('#contact-create-cancel').addEventListener('click', () => { createOverlay.style.display = 'none'; });
createOverlay.addEventListener('click', (e) => { if (e.target === createOverlay) createOverlay.style.display = 'none'; });
container.querySelector('#contact-create-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = container.querySelector('#contact-create-submit');
submitBtn.disabled = true;
submitBtn.textContent = 'Creando...';
try {
const clientId = parseInt(container.querySelector('#c-client').value) || 0;
await createContact({
lastname: container.querySelector('#c-lastname').value.trim(),
firstname: container.querySelector('#c-firstname').value.trim() || undefined,
clientId: clientId > 0 ? clientId : 0,
email: container.querySelector('#c-email').value.trim() || undefined,
phonePro: container.querySelector('#c-phone-pro').value.trim() || undefined,
phonePerso: container.querySelector('#c-phone-perso').value.trim() || undefined,
phoneMobile: container.querySelector('#c-phone-mobile').value.trim() || undefined,
address: container.querySelector('#c-address').value.trim() || undefined,
zip: container.querySelector('#c-zip').value.trim() || undefined,
town: container.querySelector('#c-town').value.trim() || undefined,
});
showToast('Contacto creado', 'success');
createOverlay.style.display = 'none';
await reload();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Crear contacto';
}
});
searchInput.addEventListener('input', () => { searchTerm = searchInput.value; applyFilters(); });
// Initial load
(async () => {
try {
allContacts = await getContacts({ limit: 500 });
applyFilters();
} catch (err) {
tbody.innerHTML = `<tr><td colspan="5" class="facturas-empty">Error al cargar: ${escHtml(err.message)}</td></tr>`;
showToast('Error al cargar contactos', 'error');
}
})();
return container;
}

View File

@ -0,0 +1,463 @@
import { FormChangeTracker } from '../components/ConfirmExitModal.js';
import { navigationGuards } from '../router.js';
import { apiGet, apiPost } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
import { showToast } from '../services/toast.js';
import { getPaymentTerms } from '../services/setup.js';
import { getInvoiceTemplates, getTemplateById } from '../services/invoices.js';
export function renderCreateInvoicePage() {
const container = document.createElement('div');
container.className = 'create-invoice-page page-enter';
// Tracker de cambios
const changeTracker = new FormChangeTracker();
let savedSuccessfully = false;
container.innerHTML = /*html*/`
<div class="invoice-header-compact">
<h1>Nueva Factura</h1>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="btn-template" id="template-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
Usar plantilla
</button>
<button class="btn-back" id="back-btn"> Volver</button>
</div>
</div>
<!-- Panel de plantillas (oculto por defecto) -->
<div id="template-panel" class="template-panel" style="display:none;">
<div class="template-panel-header">
<span>Seleccionar plantilla</span>
<button type="button" id="template-panel-close" class="template-panel-close">×</button>
</div>
<div id="template-list" class="template-list">
<p class="template-loading">Cargando plantillas...</p>
</div>
</div>
<form class="invoice-form-compact" id="invoice-form">
<div class="form-grid-compact">
<div class="form-field-compact full-width">
<label>Cliente</label>
<select id="clientId" name="clientId" required disabled>
<option value="">Cargando...</option>
</select>
</div>
<div class="form-field-compact">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Fecha</label>
<div class="date-input-wrapper">
<input type="date" id="date" name="date" min="2000-01-01" max="2100-12-31" />
<button type="button" class="date-picker-btn" data-target="date" title="Seleccionar fecha">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</button>
</div>
</div>
<div class="form-field-compact">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Vencimiento</label>
<div class="date-input-wrapper">
<input type="date" id="expireDate" name="expireDate" min="2000-01-01" max="2100-12-31" />
<button type="button" class="date-picker-btn" data-target="expireDate" title="Seleccionar fecha">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</button>
</div>
</div>
<div class="form-field-compact full-width">
<label>Condiciones de pago</label>
<select id="paymentTerms" name="paymentTerms">
<option value="">Sin condiciones específicas</option>
</select>
</div>
</div>
<div class="notes-grid-compact">
<div class="form-field-compact">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>Nota pública</label>
<textarea id="notePublic" name="notePublic" rows="2" placeholder="Visible para el cliente (opcional)"></textarea>
</div>
<div class="form-field-compact">
<label><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 4px;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Nota privada</label>
<textarea id="notePrivate" name="notePrivate" rows="2" placeholder="Interna (opcional)"></textarea>
</div>
</div>
<div class="lines-section-compact">
<div class="lines-header-compact">
<h3>Líneas de Factura</h3>
<button type="button" class="btn-add-compact" id="add-line-btn">+ Añadir</button>
</div>
<div class="lines-table-compact" id="invoice-lines-container"></div>
</div>
<div class="form-actions-compact">
<button type="button" class="btn-cancel-compact" id="cancel-btn">Cancelar</button>
<button type="submit" class="btn-submit-compact">Crear Factura</button>
</div>
</form>
`;
const clientSelect = container.querySelector('#clientId');
async function loadClients() {
try {
const clients = await apiGet('/api/Clients?limit=1000');
clientSelect.innerHTML = '<option value="">Seleccionar cliente...</option>';
clients.forEach(client => {
const option = document.createElement('option');
option.value = client.id;
option.textContent = client.name;
clientSelect.appendChild(option);
});
clientSelect.disabled = false;
} catch (error) {
console.error('Error:', error);
clientSelect.innerHTML = '<option value="">Error al cargar</option>';
}
}
loadClients();
const today = new Date().toISOString().split('T')[0];
container.querySelector('#date').value = today;
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + 30);
container.querySelector('#expireDate').value = expireDate.toISOString().split('T')[0];
// Condiciones de pago — calcula vencimiento automáticamente al seleccionar
async function loadPaymentTerms() {
try {
const terms = await getPaymentTerms();
const select = container.querySelector('#paymentTerms');
terms.forEach(t => {
if (!t.label && !t.code) return;
const opt = document.createElement('option');
opt.value = t.nbjour ?? '';
opt.textContent = t.label || t.code;
select.appendChild(opt);
});
} catch { /* opcional — fallo silencioso */ }
}
loadPaymentTerms();
// --- Plantillas ---
let templatesCache = null;
async function openTemplatePanel() {
const panel = container.querySelector('#template-panel');
const listEl = container.querySelector('#template-list');
panel.style.display = 'block';
if (templatesCache === null) {
listEl.innerHTML = '<p class="template-loading">Cargando plantillas...</p>';
try {
templatesCache = await getInvoiceTemplates();
} catch {
listEl.innerHTML = '<p class="template-loading">Error al cargar plantillas.</p>';
return;
}
}
if (!templatesCache.length) {
listEl.innerHTML = '<p class="template-loading">No hay plantillas disponibles.</p>';
return;
}
listEl.innerHTML = templatesCache.map(t => `
<button type="button" class="template-item" data-id="${t.id}">
<span class="template-item-number">${t.number || `#${t.id}`}</span>
<span class="template-item-client">${t.clientName || '—'}</span>
<span class="template-item-total">${new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(t.total || 0)}</span>
</button>
`).join('');
listEl.querySelectorAll('.template-item').forEach(btn => {
btn.addEventListener('click', () => applyTemplate(parseInt(btn.dataset.id)));
});
}
async function applyTemplate(id) {
const panel = container.querySelector('#template-panel');
const listEl = container.querySelector('#template-list');
listEl.innerHTML = '<p class="template-loading">Cargando detalle...</p>';
try {
const detail = await getTemplateById(id);
panel.style.display = 'none';
// Pre-fill client
if (detail.clientId) {
const clientSelect = container.querySelector('#clientId');
clientSelect.value = String(detail.clientId);
}
// Pre-fill notes
if (detail.notePublic) container.querySelector('#notePublic').value = detail.notePublic;
if (detail.notePrivate) container.querySelector('#notePrivate').value = detail.notePrivate;
// Replace lines
const linesContainer = container.querySelector('#invoice-lines-container');
linesContainer.innerHTML = '';
lineCounter = 0;
const linesToAdd = detail.lines?.length ? detail.lines : [null];
linesToAdd.forEach(line => {
const lineDiv = createInvoiceLine();
if (line) {
lineDiv.querySelector('.line-desc-compact').value = line.description || '';
lineDiv.querySelector('.line-qty-compact').value = line.quantity ?? 1;
lineDiv.querySelector('.line-price-compact').value = line.unitPrice ?? 0;
lineDiv.querySelector('.line-tax-compact').value = line.taxRate ?? 21;
lineDiv.querySelector('.line-total-compact').textContent =
`${((line.quantity ?? 1) * (line.unitPrice ?? 0) * (1 + (line.taxRate ?? 21) / 100)).toFixed(2)}`;
}
linesContainer.appendChild(lineDiv);
});
showToast('Plantilla aplicada', 'success');
} catch {
listEl.innerHTML = '<p class="template-loading">Error al cargar la plantilla.</p>';
}
}
container.querySelector('#template-btn').addEventListener('click', openTemplatePanel);
container.querySelector('#template-panel-close').addEventListener('click', () => {
container.querySelector('#template-panel').style.display = 'none';
});
container.querySelector('#paymentTerms').addEventListener('change', (e) => {
const days = parseInt(e.target.value);
if (!days || days <= 0) return;
const base = new Date(container.querySelector('#date').value || today);
base.setDate(base.getDate() + days);
container.querySelector('#expireDate').value = base.toISOString().split('T')[0];
});
// Si cambia la fecha base, recalcular vencimiento si hay condición seleccionada
container.querySelector('#date').addEventListener('change', (e) => {
const days = parseInt(container.querySelector('#paymentTerms').value);
if (!days || days <= 0) return;
const base = new Date(e.target.value);
base.setDate(base.getDate() + days);
container.querySelector('#expireDate').value = base.toISOString().split('T')[0];
});
// Event listeners para botones de calendario
container.querySelectorAll('.date-picker-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetInput = container.querySelector(`#${btn.dataset.target}`);
if (targetInput) targetInput.showPicker();
});
});
let lineCounter = 0;
function createInvoiceLine() {
lineCounter++;
const lineDiv = document.createElement('div');
lineDiv.className = 'line-row-compact';
lineDiv.innerHTML = /*html*/`
<input type="text" class="line-desc-compact" required placeholder="Descripción" />
<input type="number" class="line-qty-compact" required min="0.01" step="0.01" value="1" placeholder="Cant" />
<input type="number" class="line-price-compact" required min="0" step="0.01" placeholder="Precio" />
<input type="number" class="line-tax-compact" required min="0" max="100" step="0.01" value="21" placeholder="IVA %" />
<div class="line-total-compact">0.00 </div>
<button type="button" class="btn-delete-compact" data-line-id="${lineCounter}">×</button>
`;
const descInput = lineDiv.querySelector('.line-desc-compact');
const qtyInput = lineDiv.querySelector('.line-qty-compact');
const priceInput = lineDiv.querySelector('.line-price-compact');
const taxInput = lineDiv.querySelector('.line-tax-compact');
const totalDiv = lineDiv.querySelector('.line-total-compact');
function updateTotal() {
const qty = parseFloat(qtyInput.value) || 0;
const price = parseFloat(priceInput.value) || 0;
const tax = parseFloat(taxInput.value) || 0;
const subtotal = qty * price;
const total = subtotal * (1 + tax / 100);
totalDiv.textContent = `${total.toFixed(2)}`;
}
qtyInput.addEventListener('input', updateTotal);
priceInput.addEventListener('input', updateTotal);
taxInput.addEventListener('input', updateTotal);
lineDiv.querySelector('.btn-delete-compact').addEventListener('click', () => {
lineDiv.remove();
});
return lineDiv;
}
const linesContainer = container.querySelector('#invoice-lines-container');
linesContainer.appendChild(createInvoiceLine());
linesContainer.appendChild(createInvoiceLine());
container.querySelector('#add-line-btn').addEventListener('click', () => {
linesContainer.appendChild(createInvoiceLine());
});
// Capturar estado inicial después de cargar clientes y crear líneas iniciales
setTimeout(() => {
changeTracker.captureInitialState(container);
changeTracker.setupAutoTracking(container);
// Registrar guard en el router - bloquea navegación si hay cambios
navigationGuards.register(() => {
if (savedSuccessfully) return false;
changeTracker.checkForChanges(container);
return changeTracker.hasChanges;
});
}, 500);
// Interceptar beforeunload (cerrar pestaña/navegador)
const handleBeforeUnload = (e) => {
if (savedSuccessfully) return;
changeTracker.checkForChanges(container);
if (changeTracker.hasChanges) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
// Botón volver - el router se encarga del modal
container.querySelector('#back-btn').addEventListener('click', () => {
window.location.hash = '#invoices';
});
// Botón cancelar - el router se encarga del modal
container.querySelector('#cancel-btn').addEventListener('click', () => {
window.location.hash = '#invoices';
});
container.querySelector('#invoice-form').addEventListener('submit', async (e) => {
e.preventDefault();
const clientIdValue = parseInt(container.querySelector('#clientId').value);
if (!clientIdValue || isNaN(clientIdValue) || clientIdValue < 1) {
showToast('Selecciona un cliente', 'warning');
return;
}
const lines = container.querySelectorAll('.line-row-compact');
if (lines.length === 0) {
showToast('Añade al menos una línea', 'warning');
return;
}
// Validar fechas coherentes
const dateValue = container.querySelector('#date').value;
const expireDateValue = container.querySelector('#expireDate').value;
function isValidDate(dateStr) {
if (!dateStr) return false;
const parts = dateStr.split('-');
if (parts.length !== 3) return false;
const year = parseInt(parts[0]);
const month = parseInt(parts[1]);
const day = parseInt(parts[2]);
if (year < 2000 || year > 2100) return false;
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
const d = new Date(year, month - 1, day);
return d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day;
}
if (!isValidDate(dateValue)) {
showToast('La fecha de factura no es válida.', 'warning');
return;
}
if (!isValidDate(expireDateValue)) {
showToast('La fecha de vencimiento no es válida.', 'warning');
return;
}
const dateObj = new Date(dateValue);
const expireDateObj = new Date(expireDateValue);
if (expireDateObj < dateObj) {
showToast('La fecha de vencimiento no puede ser anterior a la de factura.', 'warning');
return;
}
const formData = {
clientId: clientIdValue,
date: container.querySelector('#date').value + 'T00:00:00',
expireDate: container.querySelector('#expireDate').value + 'T00:00:00',
notePublic: container.querySelector('#notePublic').value || null,
notePrivate: container.querySelector('#notePrivate').value || null,
lines: []
};
let hasEmptyLine = false;
lines.forEach(line => {
const desc = line.querySelector('.line-desc-compact').value.trim();
const qty = parseFloat(line.querySelector('.line-qty-compact').value);
const price = parseFloat(line.querySelector('.line-price-compact').value);
const tax = parseFloat(line.querySelector('.line-tax-compact').value);
if (!desc || !qty || !price) {
hasEmptyLine = true;
return;
}
formData.lines.push({
description: desc,
quantity: qty,
unitPrice: price,
taxRate: tax
});
});
if (hasEmptyLine) {
showToast('Completa todos los campos de las líneas', 'warning');
return;
}
try {
const submitBtn = container.querySelector('.btn-submit-compact');
submitBtn.disabled = true;
submitBtn.textContent = 'Guardando...';
const invoiceId = await apiPost('/api/Invoices', formData);
// Marcar como guardado exitosamente para evitar el modal de confirmación
savedSuccessfully = true;
changeTracker.cleanup();
navigationGuards.unregister();
window.removeEventListener('beforeunload', handleBeforeUnload);
showToast(`Factura creada (ID: ${invoiceId})`, 'success');
window.location.hash = '#invoices';
} catch (error) {
console.error('Error:', error);
showToast(error.message || 'Error al crear la factura', 'error');
const submitBtn = container.querySelector('.btn-submit-compact');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = 'Crear Factura';
}
}
});
return container;
}

View File

@ -0,0 +1,561 @@
import { auth } from '../services/auth.js';
import { InvoiceModal } from '../components/InvoiceModal.js';
import { apiGet } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
import {
Chart,
BarController,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend
} from 'chart.js';
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
function getGreeting() {
const hour = new Date().getHours();
if (hour < 12) return 'Buenos días';
if (hour < 20) return 'Buenas tardes';
return 'Buenas noches';
}
async function fetchAllInvoices() {
const data = await apiGet('/api/Invoices?limit=500&page=1');
return Array.isArray(data) ? data : data.data || data.invoices || [];
}
async function fetchAllSupplierInvoices() {
const data = await apiGet('/api/SupplierInvoices?limit=500&page=1');
return Array.isArray(data) ? data : data.data || data.invoices || [];
}
function calcKPIs(invoices) {
const total = invoices.length;
const totalBilled = invoices.reduce((sum, inv) => sum + (inv.total || 0), 0);
const paid = invoices.filter(inv => inv.status === 'paid').length;
const unpaid = invoices.filter(inv => inv.status === 'unpaid').length;
const draft = invoices.filter(inv => inv.status === 'draft').length;
const paidRate = total > 0 ? ((paid / total) * 100).toFixed(1) : '0.0';
const pendingAmount = invoices.reduce((s, inv) => s + (inv.remainToPay || 0), 0);
return { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount };
}
function calcQuarterlyBoth(ventas, compras) {
const year = new Date().getFullYear();
const qv = [0, 0, 0, 0];
const qc = [0, 0, 0, 0];
const addTo = (arr, invoices) => {
invoices.forEach(inv => {
if (!inv.date || !inv.total) return;
const d = new Date(inv.date);
if (d.getFullYear() !== year) return;
const m = d.getMonth();
const idx = m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3;
arr[idx] += inv.total;
});
};
addTo(qv, ventas);
addTo(qc, compras);
return { ventas: qv, compras: qc };
}
function formatCurrency(amount) {
return new Intl.NumberFormat('es-ES', {
style: 'currency', currency: 'EUR', maximumFractionDigits: 0
}).format(amount || 0);
}
function formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('es-ES', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
}
function getStatusBadge(status) {
const map = {
draft: { label: 'Borrador', cls: 'badge-draft' },
unpaid: { label: 'Pte. Pago', cls: 'badge-unpaid' },
paid: { label: 'Pagada', cls: 'badge-paid' },
};
const s = map[status] || { label: status || '—', cls: 'badge-draft' };
return `<span class="badge ${s.cls}">${s.label}</span>`;
}
function statusDotClass(status) {
const map = {
paid: 'status-paid',
unpaid: 'status-unpaid',
draft: 'status-draft',
};
return map[status] || 'status-draft';
}
function escapeHtml(str) {
if (str == null) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
function buildQuarterlySummary(invoices) {
const quarters = {};
invoices.forEach(inv => {
if (!inv.date) return;
const d = new Date(inv.date);
const year = d.getFullYear();
const q = Math.floor(d.getMonth() / 3) + 1;
const key = `${year}-Q${q}`;
if (!quarters[key]) quarters[key] = { year, q, ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 };
const ttc = parseFloat(inv.total) || 0;
quarters[key].ht += parseFloat(inv.totalHt) || 0;
quarters[key].tax += parseFloat(inv.totalTax) || 0;
quarters[key].total += ttc;
if (inv.status === 'paid') quarters[key].paid += ttc;
else if (inv.status === 'unpaid') quarters[key].pending += ttc;
quarters[key].count += 1;
});
return Object.values(quarters).sort((a, b) =>
b.year !== a.year ? b.year - a.year : b.q - a.q
);
}
function renderQuarterlyTable(wrap, invoices) {
const rows = buildQuarterlySummary(invoices);
if (rows.length === 0) { wrap.innerHTML = '<p class="db-table-loading">Sin datos</p>'; return; }
const fmt = n => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n);
const qLabel = q => [`1er`, ``, `3er`, ``][q - 1] + ` trimestre`;
const byYear = {};
rows.forEach(r => { if (!byYear[r.year]) byYear[r.year] = []; byYear[r.year].push(r); });
let html = '';
Object.keys(byYear).sort((a, b) => b - a).forEach(year => {
const yr = byYear[year];
const tot = yr.reduce((a, r) => ({
ht: a.ht + r.ht, tax: a.tax + r.tax, total: a.total + r.total,
paid: a.paid + r.paid, pending: a.pending + r.pending, count: a.count + r.count
}), { ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 });
html += `
<div class="quarterly-year-block">
<div class="quarterly-year-header">
<span class="quarterly-year-label">${year}</span>
<span class="quarterly-year-total">${fmt(tot.total)} · ${tot.count} facturas</span>
</div>
<table class="quarterly-table">
<thead><tr>
<th>Trimestre</th><th>Facturas</th><th>Base imponible</th>
<th>IVA</th><th>Total emitido</th><th>Cobrado</th><th>Pte. cobro</th>
</tr></thead>
<tbody>
${yr.map(r => `<tr>
<td class="quarterly-q-label">T${r.q} · <span>${qLabel(r.q)}</span></td>
<td class="quarterly-count">${r.count}</td>
<td class="quarterly-num">${fmt(r.ht)}</td>
<td class="quarterly-num quarterly-tax">${fmt(r.tax)}</td>
<td class="quarterly-num ${r.total > 0 ? 'quarterly-paid' : r.total < 0 ? 'quarterly-pending' : ''}">${fmt(r.total)}</td>
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : r.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${r.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(r.paid)}</td>
<td class="quarterly-num ${r.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(r.pending)}</td>
</tr>`).join('')}
</tbody>
<tfoot>
<tr class="quarterly-footer-row">
<td>Total ${year}</td><td>${tot.count}</td>
<td>${fmt(tot.ht)}</td><td>${fmt(tot.tax)}</td>
<td class="${tot.total > 0 ? 'quarterly-paid' : tot.total < 0 ? 'quarterly-pending' : ''}">${fmt(tot.total)}</td>
<td class="${tot.paid > 0 ? 'quarterly-paid' : tot.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${tot.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(tot.paid)}</td>
<td class="${tot.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(tot.pending)}</td>
</tr>
</tfoot>
</table>
</div>`;
});
wrap.innerHTML = html;
}
function openInvoiceModal(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => { });
document.body.appendChild(modal);
}
export function renderDashboard() {
const user = auth.getUser();
const userName = user?.identifier || user?.email || user?.username || 'Usuario';
const year = new Date().getFullYear();
const container = document.createElement('div');
container.className = 'dashboard';
container.innerHTML = /*html*/`
<div class="db-header">
<div>
<h1 class="db-greeting">${getGreeting()}, ${escapeHtml(userName)}</h1>
<p class="db-subtitle">Resumen de facturación · ${year}</p>
</div>
<button id="logout-button" class="logout-button">
${icons.logout}
<span>Cerrar sesión</span>
</button>
</div>
<div class="db-kpi-row">
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Facturas emitidas</span>
<div class="db-kpi-icon db-kpi-icon--blue">${icons.fileText}</div>
</div>
<div class="db-kpi-value" id="kpi-total"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-total-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Total ventas</span>
<div class="db-kpi-icon db-kpi-icon--green">${icons.dollarSign}</div>
</div>
<div class="db-kpi-value" id="kpi-ventas"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-ventas-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Total compras</span>
<div class="db-kpi-icon db-kpi-icon--red">${icons.dollarSign}</div>
</div>
<div class="db-kpi-value" id="kpi-compras"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-compras-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Balance neto</span>
<div class="db-kpi-icon db-kpi-icon--emerald">${icons.checkCircle}</div>
</div>
<div class="db-kpi-value" id="kpi-balance"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-balance-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Tasa de cobro</span>
<div class="db-kpi-icon db-kpi-icon--purple">${icons.checkCircle}</div>
</div>
<div class="db-kpi-value" id="kpi-rate"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-rate-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Pendiente de cobro</span>
<div class="db-kpi-icon db-kpi-icon--amber">${icons.clock}</div>
</div>
<div class="db-kpi-value" id="kpi-pending"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-pending-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
</div>
<div class="db-mid-row">
<div class="db-chart-card">
<div class="db-card-header">
<span class="db-card-title">Balance trimestral</span>
<span class="db-card-year">${year}</span>
</div>
<div class="db-chart-body">
<canvas id="db-quarterly-chart"></canvas>
</div>
</div>
<div class="db-recent-card">
<div class="db-card-header">
<span class="db-card-title">Últimos movimientos</span>
<a href="#invoices" class="db-see-all">Ver todas</a>
</div>
<div class="db-recent-list" id="db-recent-list">
${[...Array(6)].map(() => `
<div class="db-recent-item db-recent-skeleton">
<div class="db-skel-icon"></div>
<div class="db-skel-lines"><span></span><span></span></div>
<div class="db-skel-right"><span></span><span></span></div>
</div>`).join('')}
</div>
</div>
</div>
<div class="db-table-card">
<div class="db-card-header">
<span class="db-card-title">Facturación trimestral</span>
</div>
<div class="db-table-wrap" id="db-quarterly-table-wrap">
<p class="db-table-loading">Cargando...</p>
</div>
</div>
<div class="db-table-card">
<div class="db-card-header">
<span class="db-card-title">Últimas facturas emitidas</span>
<a href="#invoices" class="db-see-all">Ver todas</a>
</div>
<div class="db-table-wrap">
<table class="db-monitoring-table">
<thead>
<tr>
<th>Número</th>
<th>Cliente</th>
<th>Estado</th>
<th>Fecha</th>
<th>Vencimiento</th>
<th class="db-th-right">Total</th>
</tr>
</thead>
<tbody id="db-table-body">
<tr><td colspan="6" class="db-table-loading">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
`;
container.querySelector('#logout-button').addEventListener('click', () => {
auth.logout();
window.location.hash = '#login';
});
(async () => {
let invoices = [];
let supplierInvoices = [];
try {
[invoices, supplierInvoices] = await Promise.all([
fetchAllInvoices(),
fetchAllSupplierInvoices(),
]);
} catch (err) {
console.error('Dashboard: error al cargar facturas', err);
['#db-recent-list', '#db-table-body'].forEach(sel => {
container.querySelector(sel).innerHTML = '<p class="db-error">Error al cargar datos.</p>';
});
return;
}
const { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount } = calcKPIs(invoices);
const totalCompras = supplierInvoices.reduce((s, inv) => s + (inv.total || 0), 0);
const balance = totalBilled - totalCompras;
container.querySelector('#kpi-total').textContent = total.toLocaleString('es-ES');
container.querySelector('#kpi-total-sub').innerHTML =
`<span class="db-sub-green">${paid} pagadas</span>&nbsp;&middot;&nbsp;` +
`<span class="db-sub-amber">${unpaid} pendientes</span>&nbsp;&middot;&nbsp;` +
`<span class="db-sub-muted">${draft} borrador</span>`;
container.querySelector('#kpi-ventas').textContent = formatCurrency(totalBilled);
container.querySelector('#kpi-ventas-sub').textContent = `Acumulado ${year}`;
container.querySelector('#kpi-compras').textContent = formatCurrency(totalCompras);
container.querySelector('#kpi-compras-sub').textContent = `${supplierInvoices.length} facturas de proveedor`;
container.querySelector('#kpi-balance').textContent = formatCurrency(balance);
container.querySelector('#kpi-balance-sub').innerHTML =
balance >= 0
? `<span class="db-sub-green">Resultado positivo</span>`
: `<span class="db-sub-red">Resultado negativo</span>`;
container.querySelector('#kpi-rate').textContent = `${paidRate}%`;
container.querySelector('#kpi-rate-sub').textContent = `${paid} de ${total} cobradas`;
container.querySelector('#kpi-pending').textContent = formatCurrency(pendingAmount);
container.querySelector('#kpi-pending-sub').innerHTML =
pendingAmount > 0
? `<span class="db-sub-red">${unpaid} factura${unpaid !== 1 ? 's' : ''} sin cobrar</span>`
: '<span class="db-sub-green">Sin importes pendientes</span>';
const canvas = container.querySelector('#db-quarterly-chart');
const { ventas: qv, compras: qc } = calcQuarterlyBoth(invoices, supplierInvoices);
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const chartGridColor = isDark ? 'rgba(148, 163, 184, 0.08)' : '#f3f4f6';
const chartTickColor = isDark ? '#94a3b8' : '#9ca3af';
new Chart(canvas, {
type: 'bar',
data: {
labels: ['Q1 EneMar', 'Q2 AbrJun', 'Q3 JulSep', 'Q4 OctDic'],
datasets: [
{
label: 'Ventas',
data: qv,
backgroundColor: 'rgba(37, 99, 235, 0.85)',
hoverBackgroundColor: 'rgba(29, 78, 216, 1)',
borderRadius: 4,
borderSkipped: false,
barPercentage: 0.7,
categoryPercentage: 0.8,
},
{
label: 'Compras',
data: qc,
backgroundColor: 'rgba(220, 38, 38, 0.75)',
hoverBackgroundColor: 'rgba(185, 28, 28, 1)',
borderRadius: 4,
borderSkipped: false,
barPercentage: 0.7,
categoryPercentage: 0.8,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: chartTickColor,
font: { size: 12, family: 'Inter, system-ui, sans-serif' },
boxWidth: 12,
padding: 16,
}
},
tooltip: {
callbacks: { label: ctx => ` ${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y)}` },
backgroundColor: isDark ? '#1e293b' : '#111827',
titleColor: '#f9fafb',
bodyColor: isDark ? '#cbd5e1' : '#d1d5db',
padding: 10,
cornerRadius: 6,
displayColors: true,
}
},
scales: {
x: {
grid: { display: false },
border: { display: false },
ticks: {
color: chartTickColor,
font: { size: 12, family: 'Inter, system-ui, sans-serif' }
}
},
y: {
grid: { color: chartGridColor },
border: { display: false },
ticks: {
color: chartTickColor,
font: { size: 11, family: 'Inter, system-ui, sans-serif' },
callback: v => v >= 1000 ? `${(v / 1000).toFixed(0)}k €` : `${v}`,
maxTicksLimit: 5,
}
}
},
animation: { duration: 600, easing: 'easeOutQuart' }
}
});
// Combined recent list: ventas + compras, last 8 by date
const activityDate = inv => new Date(inv.dateModification || inv.dateCreation || inv.date || 0);
const ventasTagged = invoices.map(inv => ({ ...inv, _type: 'venta', _party: inv.clientName }));
const comprasTagged = supplierInvoices.map(inv => ({
...inv,
_type: 'compra',
_party: inv.supplierName,
number: inv.number || inv.supplierRef,
}));
const recent = [...ventasTagged, ...comprasTagged]
.sort((a, b) => activityDate(b) - activityDate(a))
.slice(0, 10);
const recentList = container.querySelector('#db-recent-list');
if (recent.length === 0) {
recentList.innerHTML = `<div class="db-no-data">No hay facturas recientes.</div>`;
} else {
recentList.innerHTML = recent.map(inv => {
const isVenta = inv._type === 'venta';
const typeBadge = isVenta
? `<span class="db-type-badge db-type-venta" title="Venta">V</span>`
: `<span class="db-type-badge db-type-compra" title="Compra">C</span>`;
return `
<div class="db-recent-item${isVenta ? '' : ' db-recent-compra'}" data-id="${inv.id}" data-type="${inv._type}" role="button" tabindex="0">
<div class="db-recent-dot ${statusDotClass(inv.status)}">
${icons.fileText}
</div>
<div class="db-recent-info">
<span class="db-recent-num">${typeBadge} ${escapeHtml(inv.number || `#${inv.id}`)}</span>
<span class="db-recent-client">${escapeHtml(inv._party || '—')}</span>
</div>
<div class="db-recent-right">
<span class="db-recent-amount${(inv.total || 0) < 0 || inv.status === 'unpaid' ? ' db-recent-amount--pending' : inv.status === 'paid' ? ' db-recent-amount--paid' : ''}">${formatCurrency(inv.total)}</span>
<span class="db-recent-date">${formatDate(inv.dateModification || inv.dateCreation || inv.date)}</span>
</div>
<div class="db-recent-arrow">
${icons.chevron}
</div>
</div>
`;
}).join('');
recentList.querySelectorAll('.db-recent-item[data-id]').forEach(el => {
const id = parseInt(el.dataset.id);
const type = el.dataset.type;
el.addEventListener('click', () => {
if (type === 'venta') openInvoiceModal(id);
else { window.__openSupplierInvoice = id; window.location.hash = '#facturas-proveedores'; }
});
el.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
if (type === 'venta') openInvoiceModal(id);
else { window.__openSupplierInvoice = id; window.location.hash = '#facturas-proveedores'; }
}
});
});
}
renderQuarterlyTable(container.querySelector('#db-quarterly-table-wrap'), invoices);
const tableRows = [...invoices]
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 25);
const tbody = container.querySelector('#db-table-body');
if (tableRows.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="db-table-loading">No hay facturas</td></tr>';
} else {
const today = new Date(); today.setHours(0, 0, 0, 0);
const in7 = new Date(today); in7.setDate(in7.getDate() + 7);
tbody.innerHTML = tableRows.map(inv => {
const expDate = inv.expireDate ? new Date(inv.expireDate) : null;
const needsPayment = inv.status !== 'paid';
let dateClass = '';
if (expDate && needsPayment) {
if (expDate < today) dateClass = 'db-date--overdue';
else if (expDate <= in7) dateClass = 'db-date--soon';
}
return `
<tr class="db-table-row db-row--${inv.status || 'draft'}" data-id="${inv.id}" role="button" tabindex="0">
<td class="db-cell-num db-num--${inv.status || 'draft'}">${escapeHtml(inv.number || `#${inv.id}`)}</td>
<td class="db-cell-client">${escapeHtml(inv.clientName || '—')}</td>
<td>${getStatusBadge(inv.status)}</td>
<td>${formatDate(inv.date)}</td>
<td class="${dateClass}">${formatDate(inv.expireDate)}</td>
<td class="db-cell-right db-cell-num ${inv.status === 'unpaid' || (inv.total || 0) < 0 ? 'db-total--neg' : inv.status === 'paid' ? 'db-total--pos' : ''}">${formatCurrency(inv.total)}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('.db-table-row[data-id]').forEach(row => {
const id = parseInt(row.dataset.id);
row.addEventListener('click', () => openInvoiceModal(id));
row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openInvoiceModal(id); });
});
}
})();
return container;
}

View File

@ -0,0 +1,567 @@
import { InvoiceItem } from '../components/InvoiceItem.js';
import { InvoiceModal } from '../components/InvoiceModal.js';
import { apiDelete, apiGet, apiPost } from '../services/apiClient.js';
import { icons } from '../services/icons.js';
import { showToast } from '../services/toast.js';
export function renderFacturasPage() {
const container = document.createElement('div');
container.className = 'facturas-page page-enter';
let currentPage = 1;
let totalPages = 1;
const pageSize = 20;
let allInvoices = [];
let filteredInvoices = [];
let currentFilter = '';
let searchTerm = '';
let sortKey = null;
let sortDir = 'asc';
let selectedIds = new Set();
container.innerHTML = /*html*/`
<div class="facturas-header">
<h1>Facturas</h1>
</div>
<div class="facturas-filters">
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar facturas..." class="search-input search-input--with-icon" />
</div>
<select class="filter-status">
<option value="">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="unpaid">Pte. Pago</option>
<option value="paid">Pagada</option>
</select>
<button class="btn-quarterly" id="btn-quarterly">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Resumen trimestral
</button>
<button class="btn-new-invoice">${icons.plus} <span>Nueva Factura</span></button>
</div>
<div class="quarterly-view" id="quarterly-view" style="display:none">
<div class="quarterly-table-wrap" id="quarterly-content">
<p class="quarterly-loading">Calculando...</p>
</div>
</div>
<div class="bulk-action-bar" id="bulk-bar" style="display:none">
<span class="bulk-count" id="bulk-count">0 seleccionadas</span>
<div class="bulk-actions">
<button class="btn-bulk btn-bulk-duplicate" id="btn-duplicate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Duplicar
</button>
<button class="btn-bulk btn-bulk-delete" id="btn-bulk-delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Eliminar
</button>
<button class="btn-bulk btn-bulk-cancel" id="btn-deselect"> Cancelar selección</button>
</div>
</div>
<div class="invoices-table-container">
<table class="invoices-table">
<thead>
<tr>
<th class="invoice-select-col">
<input type="checkbox" class="invoice-check-all" id="check-all" title="Seleccionar todo" />
</th>
<th class="invoice-number sortable" data-sort="number">
Número <span class="sort-icon"></span>
</th>
<th class="invoice-status sortable" data-sort="status">
Estado <span class="sort-icon"></span>
</th>
<th class="invoice-client sortable" data-sort="clientName">
Cliente <span class="sort-icon"></span>
</th>
<th class="invoice-date sortable" data-sort="date">
Fecha <span class="sort-icon"></span>
</th>
<th class="invoice-total sortable" data-sort="total">
Total <span class="sort-icon"></span>
</th>
<th class="invoice-remain sortable" data-sort="remainToPay">
Pendiente <span class="sort-icon"></span>
</th>
<th class="invoice-actions">Acciones</th>
</tr>
</thead>
<tbody class="invoices-list">
<tr><td colspan="8" class="loading">Cargando facturas...</td></tr>
</tbody>
</table>
</div>
<div class="invoices-summary" style="display:none">
<div class="summary-item">
<span class="summary-label">Facturas</span>
<span class="summary-value" data-summary="count"></span>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
<span class="summary-label">Total facturado</span>
<span class="summary-value" data-summary="total"></span>
</div>
<div class="summary-item summary-item--paid">
<span class="summary-label">Pagado</span>
<span class="summary-value" data-summary="paid"></span>
</div>
<div class="summary-item summary-item--pending">
<span class="summary-label">Pendiente</span>
<span class="summary-value" data-summary="pending"></span>
</div>
</div>
<div class="pagination">
<button class="btn-pagination btn-prev"> Anterior</button>
<div class="pagination-info">
<span class="current-page">1</span> / <span class="total-pages">1</span>
</div>
<button class="btn-pagination btn-next">Siguiente </button>
</div>
`;
const formatCurrency = (n) =>
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n || 0);
function sortInvoices(invoices) {
if (!sortKey) return invoices;
return [...invoices].sort((a, b) => {
let va = a[sortKey], vb = b[sortKey];
if (sortKey === 'total' || sortKey === 'remainToPay') {
va = parseFloat(va) || 0;
vb = parseFloat(vb) || 0;
} else if (sortKey === 'date') {
va = new Date(va).getTime() || 0;
vb = new Date(vb).getTime() || 0;
} else {
va = String(va ?? '').toLowerCase();
vb = String(vb ?? '').toLowerCase();
}
if (va < vb) return sortDir === 'asc' ? -1 : 1;
if (va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
}
function updateSortIcons() {
container.querySelectorAll('th.sortable').forEach(th => {
const key = th.dataset.sort;
const icon = th.querySelector('.sort-icon');
if (key === sortKey) {
icon.textContent = sortDir === 'asc' ? ' ↑' : ' ↓';
th.classList.add('sort-active');
} else {
icon.textContent = '';
th.classList.remove('sort-active');
}
});
}
function updateSummary(invoices) {
const summaryEl = container.querySelector('.invoices-summary');
if (!invoices.length) { summaryEl.style.display = 'none'; return; }
const total = invoices.reduce((s, i) => s + (parseFloat(i.total) || 0), 0);
const pending = invoices.reduce((s, i) => s + (parseFloat(i.remainToPay) || 0), 0);
const paid = total - pending;
container.querySelector('[data-summary="count"]').textContent = invoices.length;
container.querySelector('[data-summary="total"]').textContent = formatCurrency(total);
container.querySelector('[data-summary="paid"]').textContent = formatCurrency(paid);
container.querySelector('[data-summary="pending"]').textContent = formatCurrency(pending);
summaryEl.style.display = 'flex';
}
function renderPage(page = 1) {
const invoicesList = container.querySelector('.invoices-list');
currentPage = page;
const sorted = sortInvoices(filteredInvoices);
totalPages = Math.ceil(sorted.length / pageSize);
const startIndex = (page - 1) * pageSize;
const invoices = sorted.slice(startIndex, startIndex + pageSize);
updatePaginationUI();
updateSortIcons();
updateSummary(filteredInvoices);
invoicesList.innerHTML = '';
if (invoices.length === 0) {
invoicesList.innerHTML = `<tr><td colspan="8"><div class="empty-state">${icons.emptyInvoices}<h3>No hay facturas</h3><p>Crea tu primera factura para comenzar.</p></div></td></tr>`;
return;
}
invoices.forEach(invoice => {
const invoiceItem = InvoiceItem(invoice, handleViewInvoice);
// Restore checked state for already-selected rows
const chk = invoiceItem.querySelector('.invoice-check');
if (chk && selectedIds.has(String(invoice.id))) chk.checked = true;
invoicesList.appendChild(invoiceItem);
});
// Sync select-all state
syncSelectAll();
}
function handleViewInvoice(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => {
loadAllInvoices();
});
document.body.appendChild(modal);
}
async function loadAllInvoices() {
const invoicesList = container.querySelector('.invoices-list');
selectedIds.clear();
updateBulkBar();
try {
invoicesList.innerHTML = '<tr><td colspan="8" class="loading">Cargando facturas...</td></tr>';
let url = '/api/Invoices?limit=1000';
if (currentFilter) url += `&status=${currentFilter}`;
if (searchTerm) url += `&search=${encodeURIComponent(searchTerm)}`;
const data = await apiGet(url);
allInvoices = Array.isArray(data) ? data : data.data || data.invoices || [];
filteredInvoices = [...allInvoices].reverse();
renderPage(1);
} catch (error) {
console.error('Error al cargar facturas:', error);
invoicesList.innerHTML = `<tr><td colspan="8"><div class="empty-state">${icons.error}<h3>Error al cargar</h3><p>No se pudieron cargar las facturas. Inténtalo de nuevo.</p></div></td></tr>`;
}
}
function applyFilters() {
loadAllInvoices();
}
function updatePaginationUI() {
const paginationDiv = container.querySelector('.pagination');
const currentPageSpan = container.querySelector('.current-page');
const totalPagesSpan = container.querySelector('.total-pages');
const btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next');
currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages;
paginationDiv.style.display = totalPages <= 1 ? 'none' : 'flex';
btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages;
}
container.querySelectorAll('th.sortable').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDir = 'asc';
}
renderPage(1);
});
});
// ── Bulk selection ──────────────────────────────────────────────────
function updateBulkBar() {
const bar = container.querySelector('#bulk-bar');
const count = container.querySelector('#bulk-count');
if (selectedIds.size > 0) {
bar.style.display = 'flex';
count.textContent = `${selectedIds.size} seleccionada${selectedIds.size !== 1 ? 's' : ''}`;
} else {
bar.style.display = 'none';
}
}
function syncSelectAll() {
const checkAll = container.querySelector('#check-all');
if (!checkAll) return;
const pageCheckboxes = [...container.querySelectorAll('.invoice-check')];
if (pageCheckboxes.length === 0) { checkAll.checked = false; checkAll.indeterminate = false; return; }
const checkedCount = pageCheckboxes.filter(c => c.checked).length;
checkAll.checked = checkedCount === pageCheckboxes.length;
checkAll.indeterminate = checkedCount > 0 && checkedCount < pageCheckboxes.length;
}
// Event delegation for row checkboxes
container.querySelector('.invoices-list').addEventListener('change', (e) => {
if (!e.target.classList.contains('invoice-check')) return;
const id = String(e.target.value);
if (e.target.checked) selectedIds.add(id); else selectedIds.delete(id);
syncSelectAll();
updateBulkBar();
});
container.querySelector('#check-all').addEventListener('change', (e) => {
const checkboxes = container.querySelectorAll('.invoice-check');
checkboxes.forEach(chk => {
chk.checked = e.target.checked;
if (e.target.checked) selectedIds.add(String(chk.value)); else selectedIds.delete(String(chk.value));
});
updateBulkBar();
});
container.querySelector('#btn-deselect').addEventListener('click', () => {
selectedIds.clear();
container.querySelectorAll('.invoice-check').forEach(c => { c.checked = false; });
syncSelectAll();
updateBulkBar();
});
container.querySelector('#btn-bulk-delete').addEventListener('click', async () => {
if (selectedIds.size === 0) return;
const confirmed = window.confirm(`¿Eliminar ${selectedIds.size} factura${selectedIds.size !== 1 ? 's' : ''}? Esta acción no se puede deshacer.`);
if (!confirmed) return;
const btn = container.querySelector('#btn-bulk-delete');
btn.disabled = true;
btn.textContent = 'Eliminando...';
let ok = 0, fail = 0;
await Promise.allSettled([...selectedIds].map(async id => {
try {
await apiDelete(`/api/Invoices/${id}`);
ok++;
} catch {
fail++;
}
}));
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg> Eliminar`;
if (ok > 0) showToast(`${ok} factura${ok !== 1 ? 's' : ''} eliminada${ok !== 1 ? 's' : ''}`, 'success');
if (fail > 0) showToast(`${fail} factura${fail !== 1 ? 's no pudieron' : ' no pudo'} eliminarse`, 'error');
loadAllInvoices();
});
container.querySelector('#btn-duplicate').addEventListener('click', async () => {
if (selectedIds.size === 0) return;
const btn = container.querySelector('#btn-duplicate');
btn.disabled = true;
btn.textContent = 'Duplicando...';
const today = new Date().toISOString().split('T')[0];
const expire = new Date(); expire.setDate(expire.getDate() + 30);
const expireStr = expire.toISOString().split('T')[0];
let ok = 0, fail = 0;
await Promise.allSettled([...selectedIds].map(async id => {
try {
const inv = await apiGet(`/api/Invoices/${id}`);
const lines = (inv.lines || []).map(l => ({
description: l.description,
quantity: l.quantity,
unitPrice: l.unitPrice,
taxRate: l.taxRate ?? l.tax ?? 0,
}));
await apiPost('/api/Invoices', {
clientId: inv.clientId,
date: today + 'T00:00:00',
expireDate: expireStr + 'T00:00:00',
notePublic: inv.notePublic || null,
notePrivate: inv.notePrivate || null,
lines,
});
ok++;
} catch {
fail++;
}
}));
btn.disabled = false;
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Duplicar`;
if (ok > 0) showToast(`${ok} factura${ok !== 1 ? 's' : ''} duplicada${ok !== 1 ? 's' : ''}`, 'success');
if (fail > 0) showToast(`${fail} factura${fail !== 1 ? 's no pudieron' : ' no pudo'} duplicarse`, 'error');
loadAllInvoices();
});
// ── Quarterly summary ─────────────────────────────────────────────
let quarterlyVisible = false;
function buildQuarterlySummary(invoices) {
const quarters = {};
invoices.forEach(inv => {
if (!inv.date) return;
const d = new Date(inv.date);
const year = d.getFullYear();
const q = Math.floor(d.getMonth() / 3) + 1;
const key = `${year}-Q${q}`;
if (!quarters[key]) quarters[key] = { year, q, ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 };
const ttc = parseFloat(inv.total) || 0;
quarters[key].ht += parseFloat(inv.totalHt) || 0;
quarters[key].tax += parseFloat(inv.totalTax) || 0;
quarters[key].total += ttc;
if (inv.status === 'paid') quarters[key].paid += ttc;
else if (inv.status === 'unpaid') quarters[key].pending += ttc;
quarters[key].count += 1;
});
return Object.values(quarters).sort((a, b) =>
b.year !== a.year ? b.year - a.year : b.q - a.q
);
}
function renderQuarterlySummary() {
const content = container.querySelector('#quarterly-content');
const rows = buildQuarterlySummary(allInvoices);
if (rows.length === 0) {
content.innerHTML = '<p class="quarterly-empty">No hay facturas para mostrar</p>';
return;
}
// Group by year
const byYear = {};
rows.forEach(r => {
if (!byYear[r.year]) byYear[r.year] = [];
byYear[r.year].push(r);
});
const fmt = (n) => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n);
const qLabel = (q) => [`1er`, ``, `3er`, ``][q - 1] + ` trimestre`;
let html = '';
Object.keys(byYear).sort((a,b) => b - a).forEach(year => {
const yearRows = byYear[year];
const totals = yearRows.reduce((acc, r) => ({
ht: acc.ht + r.ht, tax: acc.tax + r.tax,
total: acc.total + r.total, paid: acc.paid + r.paid,
pending: acc.pending + r.pending, count: acc.count + r.count
}), { ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 });
html += `
<div class="quarterly-year-block">
<div class="quarterly-year-header">
<span class="quarterly-year-label">${year}</span>
<span class="quarterly-year-total">${fmt(totals.total)} · ${totals.count} facturas</span>
</div>
<table class="quarterly-table">
<thead>
<tr>
<th>Trimestre</th>
<th>Facturas</th>
<th>Base imponible</th>
<th>IVA</th>
<th>Total</th>
<th>Cobrado</th>
<th>Pendiente</th>
</tr>
</thead>
<tbody>
${yearRows.map(r => {
return `<tr>
<td class="quarterly-q-label">T${r.q} · <span>${qLabel(r.q)}</span></td>
<td class="quarterly-count">${r.count}</td>
<td class="quarterly-num">${fmt(r.ht)}</td>
<td class="quarterly-num quarterly-tax">${fmt(r.tax)}</td>
<td class="quarterly-num ${r.total > 0 ? 'quarterly-paid' : r.total < 0 ? 'quarterly-pending' : ''}">${fmt(r.total)}</td>
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : r.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${r.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(r.paid)}</td>
<td class="quarterly-num ${r.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(r.pending)}</td>
</tr>`;
}).join('')}
</tbody>
<tfoot>
<tr class="quarterly-footer-row">
<td>Total ${year}</td>
<td>${totals.count}</td>
<td>${fmt(totals.ht)}</td>
<td>${fmt(totals.tax)}</td>
<td class="${totals.total > 0 ? 'quarterly-paid' : totals.total < 0 ? 'quarterly-pending' : ''}">${fmt(totals.total)}</td>
<td class="${totals.paid > 0 ? 'quarterly-paid' : totals.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${totals.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(totals.paid)}</td>
<td class="${totals.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(totals.pending)}</td>
</tr>
</tfoot>
</table>
</div>`;
});
content.innerHTML = html;
}
function toggleQuarterly() {
quarterlyVisible = !quarterlyVisible;
const view = container.querySelector('#quarterly-view');
const table = container.querySelector('.invoices-table-container');
const pag = container.querySelector('.pagination');
const summary = container.querySelector('.invoices-summary');
const btn = container.querySelector('#btn-quarterly');
const bulk = container.querySelector('#bulk-bar');
if (quarterlyVisible) {
view.style.display = 'block';
table.style.display = 'none';
if (pag) pag.style.display = 'none';
if (summary) summary.style.display = 'none';
if (bulk) bulk.style.display = 'none';
btn.classList.add('btn-quarterly--active');
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Cerrar resumen`;
renderQuarterlySummary();
} else {
view.style.display = 'none';
table.style.display = '';
btn.classList.remove('btn-quarterly--active');
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Resumen trimestral`;
updatePaginationUI();
updateSummary(filteredInvoices);
}
}
container.querySelector('#btn-quarterly').addEventListener('click', toggleQuarterly);
container.querySelector('.btn-new-invoice').addEventListener('click', () => {
window.location.hash = '#create-invoice';
});
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
const inputValue = e.target.value.trim();
clearTimeout(searchInput._debounceTimer);
searchInput._debounceTimer = setTimeout(() => {
searchTerm = /\d/.test(inputValue) ? inputValue : '';
applyFilters();
}, 500);
});
container.querySelector('.filter-status').addEventListener('change', (e) => {
currentFilter = e.target.value;
applyFilters();
});
container.querySelector('.btn-prev').addEventListener('click', () => {
if (currentPage > 1) { renderPage(currentPage - 1); scrollToTop(); }
});
container.querySelector('.btn-next').addEventListener('click', () => {
if (currentPage < totalPages) { renderPage(currentPage + 1); scrollToTop(); }
});
function scrollToTop() {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
loadAllInvoices();
return container;
}

View File

@ -0,0 +1,855 @@
import { icons } from '../services/icons.js';
import {
getSupplierInvoices, getSupplierInvoiceById, createSupplierInvoice,
updateSupplierInvoice, deleteSupplierInvoice, changeSupplierInvoiceStatus,
addSupplierInvoiceLine, updateSupplierInvoiceLine, deleteSupplierInvoiceLine,
getSupplierInvoicePayments, addSupplierInvoicePayment
} from '../services/supplierInvoices.js';
import { showToast } from '../services/toast.js';
import { apiGet } from '../services/apiClient.js';
import { getPaymentTypes } from '../services/setup.js';
export function renderFacturasProveedoresPage() {
const container = document.createElement('div');
container.className = 'facturas-page page-enter';
let allInvoices = [];
let filteredInvoices = [];
let currentFilter = '';
let searchTerm = '';
let currentInvoice = null;
let payments = [];
let paymentTypes = [];
const fmt = (n) => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n || 0);
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('es-ES') : '—';
const fmtDateInput = (d) => d ? new Date(d).toISOString().split('T')[0] : '';
const STATUS_TEXT = {
draft: 'Borrador',
unpaid: 'Pte. Pago',
paid: 'Pagada',
cancelled: 'Cancelada',
unknown: 'Desconocido'
};
const STATUS_CLASS = {
draft: 'status-draft',
unpaid: 'status-unpaid',
paid: 'status-paid',
cancelled: 'status-canceled',
unknown: 'status-draft'
};
function escHtml(str) {
if (str == null) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
container.innerHTML = /*html*/`
<div class="facturas-header">
<h1>Facturas de Proveedores</h1>
</div>
<div class="facturas-filters">
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar proveedor o referencia..." class="search-input search-input--with-icon" id="sup-search" />
</div>
<select class="filter-status" id="sup-filter">
<option value="">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="unpaid">Pte. Pago</option>
<option value="paid">Pagada</option>
</select>
<button class="btn-new-invoice" id="btn-new-sup">${icons.plus} <span>Nueva factura</span></button>
</div>
<div class="invoices-summary" id="sup-summary" style="display:none">
<div class="summary-item">
<span class="summary-label">Facturas</span>
<span class="summary-value" id="sum-count"></span>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
<span class="summary-label">Base imponible</span>
<span class="summary-value" id="sum-ht"></span>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
<span class="summary-label">IVA soportado</span>
<span class="summary-value" id="sum-tax"></span>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
<span class="summary-label">Total</span>
<span class="summary-value" id="sum-total"></span>
</div>
<div class="summary-divider"></div>
<div class="summary-item summary-item--pending">
<span class="summary-label">Pendiente</span>
<span class="summary-value" id="sum-pending"></span>
</div>
</div>
<div class="invoices-table-container">
<table class="invoices-table">
<thead>
<tr>
<th>Número</th>
<th>Estado</th>
<th>Proveedor</th>
<th>Fecha</th>
<th class="text-right">Total</th>
<th class="text-right">Pendiente</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="sup-tbody">
<tr><td colspan="7" class="facturas-empty">Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Vista crear factura proveedor (página completa) -->
<div id="sup-create-view" class="create-invoice-page" style="display:none">
<div class="invoice-header-compact">
<h1>Nueva factura de proveedor</h1>
<button type="button" class="btn-back" id="sup-create-close"> Volver</button>
</div>
<form id="sup-create-form" class="invoice-form-compact">
<div class="form-grid-compact">
<div class="form-field-compact full-width">
<label>Proveedor *</label>
<select id="sup-supplier-select" required>
<option value="">Cargando proveedores...</option>
</select>
</div>
<div class="form-field-compact full-width">
<label>Ref. proveedor *</label>
<input type="text" id="sup-ref-supplier" required placeholder="Ej: FAC-2024-001" />
</div>
<div class="form-field-compact">
<label>Fecha *</label>
<div class="date-input-wrapper">
<input type="date" id="sup-date" required />
<button type="button" class="date-picker-btn" data-target="sup-date" title="Seleccionar fecha">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</button>
</div>
</div>
<div class="form-field-compact">
<label>Vencimiento</label>
<div class="date-input-wrapper">
<input type="date" id="sup-expire" />
<button type="button" class="date-picker-btn" data-target="sup-expire" title="Seleccionar fecha">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</button>
</div>
</div>
</div>
<div class="notes-grid-compact">
<div class="form-field-compact">
<label>Nota pública</label>
<textarea id="sup-note-public" rows="2" placeholder="Opcional"></textarea>
</div>
</div>
<div class="lines-section-compact">
<div class="lines-header-compact">
<h3>Líneas de Factura</h3>
<button type="button" class="btn-add-compact" id="sup-add-line">+ Añadir</button>
</div>
<div class="lines-table-compact" id="sup-lines-container"></div>
</div>
<div class="form-actions-compact">
<button type="button" class="btn-cancel-compact" id="sup-create-cancel">Cancelar</button>
<button type="submit" class="btn-submit-compact" id="sup-create-submit">Crear factura</button>
</div>
</form>
</div>
`;
const tbody = container.querySelector('#sup-tbody');
const searchInput = container.querySelector('#sup-search');
const filterSelect = container.querySelector('#sup-filter');
const summary = container.querySelector('#sup-summary');
let detailView = null;
let detailBody = null;
let detailTitle = null;
function createDetailModal() {
const el = document.createElement('div');
el.className = 'sup-modal-backdrop';
el.innerHTML = `
<div class="sup-modal-box">
<div class="sup-modal-head">
<h2 id="sup-detail-title">Detalle</h2>
<button class="btn-close" id="sup-detail-close">×</button>
</div>
<div class="sup-modal-body" id="sup-detail-body">
<div class="loading">Cargando...</div>
</div>
</div>`;
el.querySelector('#sup-detail-close').addEventListener('click', closeDetail);
el.addEventListener('click', e => { if (e.target === el) closeDetail(); });
document.body.appendChild(el);
detailView = el;
detailBody = el.querySelector('#sup-detail-body');
detailTitle = el.querySelector('#sup-detail-title');
}
function applyFilters() {
filteredInvoices = allInvoices.filter(inv => {
const matchStatus = !currentFilter || inv.status === currentFilter;
const q = searchTerm.toLowerCase();
const matchSearch = !q
|| (inv.number || '').toLowerCase().includes(q)
|| (inv.supplierRef || '').toLowerCase().includes(q)
|| (inv.supplierName || '').toLowerCase().includes(q);
return matchStatus && matchSearch;
});
renderTable();
renderSummary();
}
function renderTable() {
if (filteredInvoices.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="facturas-empty">No hay facturas de proveedores</td></tr>`;
return;
}
tbody.innerHTML = filteredInvoices.map(inv => `
<tr class="invoice-item" data-id="${inv.id}">
<td class="invoice-number">
<button class="btn-invoice-num" data-id="${inv.id}">${escHtml(inv.number)}</button>
</td>
<td class="invoice-status">
<span class="status-badge ${STATUS_CLASS[inv.status] || 'status-draft'}">
${STATUS_TEXT[inv.status] || inv.status}
</span>
</td>
<td class="invoice-client">
<span class="client-icon">${icons.user}</span>
${escHtml(inv.supplierName || 'Sin nombre')}
</td>
<td class="invoice-date">${fmtDate(inv.date)}</td>
<td class="invoice-total amount-positive">${fmt(inv.total)}</td>
<td class="invoice-remain ${parseFloat(inv.remainToPay) > 0.009 ? 'amount-pending' : 'amount-paid'}">
${fmt(inv.remainToPay)}
</td>
<td class="invoice-actions">
<button class="btn-action btn-view" data-id="${inv.id}" title="Ver detalle">
${icons.eye}
</button>
</td>
</tr>
`).join('');
tbody.querySelectorAll('[data-id]').forEach(el => {
el.addEventListener('click', () => openDetail(Number(el.dataset.id)));
});
}
function renderSummary() {
if (filteredInvoices.length === 0) { summary.style.display = 'none'; return; }
summary.style.display = 'flex';
const totalHt = filteredInvoices.reduce((s, i) => s + (i.totalHt || 0), 0);
const totalTax = filteredInvoices.reduce((s, i) => s + (i.totalTax || 0), 0);
const total = filteredInvoices.reduce((s, i) => s + (i.total || 0), 0);
const pending = filteredInvoices.reduce((s, i) => s + (i.remainToPay || 0), 0);
container.querySelector('#sum-count').textContent = filteredInvoices.length;
container.querySelector('#sum-ht').textContent = fmt(totalHt);
container.querySelector('#sum-tax').textContent = fmt(totalTax);
container.querySelector('#sum-total').textContent = fmt(total);
container.querySelector('#sum-pending').textContent = fmt(pending);
}
function isModalOpen() {
return detailView && detailView.isConnected;
}
async function openDetail(id) {
if (isModalOpen()) closeDetail();
createDetailModal();
payments = [];
if (paymentTypes.length === 0) {
try { paymentTypes = await getPaymentTypes(); } catch { paymentTypes = []; }
}
if (!isModalOpen()) return;
try {
const inv = await getSupplierInvoiceById(id);
if (!isModalOpen()) return;
currentInvoice = inv;
await loadPayments(id);
if (!isModalOpen()) return;
renderDetail(inv);
} catch (err) {
if (!isModalOpen()) return;
detailBody.innerHTML = `<div class="error">No se pudo cargar el detalle: ${escHtml(err.message)}</div>`;
}
}
async function refreshDetail() {
if (!currentInvoice || !isModalOpen()) return;
try {
const inv = await getSupplierInvoiceById(currentInvoice.id);
if (!isModalOpen()) return;
currentInvoice = inv;
await loadPayments(inv.id);
if (!isModalOpen()) return;
renderDetail(inv);
const idx = allInvoices.findIndex(x => x.id === inv.id);
if (idx >= 0) allInvoices[idx] = { ...allInvoices[idx], ...inv };
applyFilters();
} catch (err) {
showToast(`Error al recargar: ${err.message}`, 'error');
}
}
async function loadPayments(id) {
try {
payments = await getSupplierInvoicePayments(id);
} catch {
payments = [];
}
}
function renderDetail(inv) {
const isDraft = inv.status === 'draft';
const isUnpaid = inv.status === 'unpaid';
const lines = inv.lines || [];
// Update title with status badge
detailTitle.innerHTML = `${escHtml(inv.number || '—')} <span class="badge ${STATUS_CLASS[inv.status] || 'badge-draft'}" style="font-size:.7em;vertical-align:middle;margin-left:.5rem">${STATUS_TEXT[inv.status] || inv.status}</span>`;
detailBody.innerHTML = /*html*/`
<!-- Info fields -->
<div class="form-section" id="sup-detail-info">
<div class="form-row">
<div class="form-field">
<label class="form-label">Proveedor</label>
<input class="form-input" value="${escHtml(inv.supplierName || '—')}" readonly />
</div>
<div class="form-field">
<label class="form-label">Ref. proveedor</label>
<input class="form-input" id="sup-d-ref" value="${escHtml(inv.supplierRef || '—')}" readonly />
</div>
</div>
<div class="form-row">
<div class="form-field">
<label class="form-label">Fecha</label>
<input class="form-input" value="${fmtDate(inv.date)}" readonly />
</div>
<div class="form-field">
<label class="form-label">Vencimiento</label>
<input class="form-input" id="sup-d-expire" value="${fmtDate(inv.expireDate)}" readonly />
</div>
</div>
${inv.notePublic ? `
<div class="form-field" style="margin-top:.5rem">
<label class="form-label">Nota pública</label>
<textarea class="form-input" id="sup-d-note" readonly rows="2" style="resize:none">${escHtml(inv.notePublic)}</textarea>
</div>` : ''}
</div>
<!-- Actions -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin:1rem 0 1.5rem">
${isDraft ? `<button class="btn-submit-compact" id="sup-btn-validate">Validar factura</button>` : ''}
${isUnpaid ? `<button class="btn-submit-compact" id="sup-btn-paid" style="background:var(--success);color:#fff">Marcar como pagada</button>` : ''}
${inv.status === 'paid' || isUnpaid ? `<button class="btn-cancel-compact" id="sup-btn-draft">Volver a borrador</button>` : ''}
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-edit">Editar</button>` : ''}
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-delete" style="color:var(--danger)">Eliminar</button>` : ''}
</div>
<!-- Lines -->
<h3 class="lines-section-title">Líneas de Factura</h3>
<div class="invoice-lines-table-wrap">
<table class="invoice-lines-table">
<thead>
<tr>
<th>Descripción</th>
<th class="text-right">Cant.</th>
<th class="text-right">P. Unit.</th>
<th class="text-right">IVA %</th>
<th class="text-right">Total</th>
${isDraft ? '<th></th>' : ''}
</tr>
</thead>
<tbody id="sup-lines-tbody">
${lines.length === 0
? `<tr><td colspan="${isDraft ? 6 : 5}" class="no-lines">Sin líneas</td></tr>`
: lines.map(l => `
<tr data-line-id="${l.id}">
<td>${escHtml(l.description || '—')}</td>
<td class="text-right">${l.quantity}</td>
<td class="text-right">${fmt(l.unitPrice)}</td>
<td class="text-right">${l.taxRate}%</td>
<td class="text-right amount-positive">${fmt(l.total)}</td>
${isDraft ? `<td style="white-space:nowrap">
<button class="btn-action" data-edit-line="${l.id}" title="Editar">${icons.eye}</button>
<button class="btn-action" data-del-line="${l.id}" title="Eliminar" style="color:var(--danger)">${icons.close}</button>
</td>` : ''}
</tr>
`).join('')}
</tbody>
</table>
</div>
${isDraft ? `
<div id="sup-add-line-form" style="margin-top:0.75rem;display:none">
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<input type="text" id="nl-desc" placeholder="Descripción *" style="flex:2;min-width:140px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<input type="number" id="nl-qty" placeholder="Cant." value="1" min="0.01" step="0.01" style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<input type="number" id="nl-price" placeholder="P.Unit." min="0" step="0.01" style="width:90px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<input type="number" id="nl-tax" placeholder="IVA%" value="21" min="0" max="100" step="0.01" style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<button type="button" class="btn-submit-compact" id="nl-save">Añadir</button>
<button type="button" class="btn-cancel-compact" id="nl-cancel">Cancelar</button>
</div>
</div>
<button type="button" class="btn-add-compact" id="sup-show-add-line" style="margin-top:0.5rem">+ Añadir línea</button>
` : ''}
<!-- Totals -->
<div class="invoice-totals">
${inv.totalHt != null ? `<div class="total-row subtotal"><span>Base imponible:</span><strong>${fmt(inv.totalHt)}</strong></div>` : ''}
${inv.totalTax != null ? `<div class="total-row subtotal"><span>IVA soportado:</span><strong>${fmt(inv.totalTax)}</strong></div>` : ''}
<div class="total-row"><span>Total:</span><strong class="amount-positive">${fmt(inv.total)}</strong></div>
${inv.status === 'paid' ? `
<div class="total-row total-row--highlight total-row--highlight-paid">
<span>Pagado:</span><strong>${fmt(inv.total || 0)}</strong>
</div>` : `
<div class="total-row total-row--highlight total-row--highlight-pending">
<span>Pendiente:</span><strong>${fmt(Math.max(0, inv.remainToPay || 0))}</strong>
</div>`}
</div>
<!-- Payments -->
<h3 class="lines-section-title" style="margin-top:1.5rem">Pagos</h3>
<div class="invoice-lines-table-wrap">
<table class="invoice-lines-table">
<thead>
<tr>
<th>Fecha</th>
<th>Referencia</th>
<th>Tipo</th>
<th class="text-right">Importe</th>
</tr>
</thead>
<tbody id="sup-payments-tbody">
${payments.length === 0
? `<tr><td colspan="4" class="no-lines">Sin pagos registrados</td></tr>`
: payments.map(p => `
<tr>
<td>${p.paymentDate ? new Date(p.paymentDate).toLocaleDateString('es-ES') : '—'}</td>
<td>${escHtml(p.ref || '—')}</td>
<td>${escHtml(p.type || '—')}</td>
<td class="text-right amount-paid">${fmt(p.amount)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
${isUnpaid ? `
<div id="sup-pay-form" style="margin-top:1rem;display:none">
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<input type="date" id="pay-date" style="padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<input type="number" id="pay-amount" placeholder="Importe (vacío=total)" min="0.01" step="0.01" style="width:130px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<select id="pay-type" style="padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--card-bg);color:var(--text-primary)">
${paymentTypes.length
? paymentTypes.map(t => `<option value="${t.id}">${escHtml(t.label || t.code || t.id)}</option>`).join('')
: `<option value="6">Transferencia</option><option value="4">Cheque</option><option value="7">Efectivo</option>`}
</select>
<button type="button" class="btn-submit-compact" id="pay-save">Registrar pago</button>
<button type="button" class="btn-cancel-compact" id="pay-cancel">Cancelar</button>
</div>
</div>
<button type="button" class="btn-add-compact" id="sup-show-pay-form" style="margin-top:0.5rem">+ Registrar pago</button>
` : ''}
`;
// Wire up status buttons
detailBody.querySelector('#sup-btn-validate')?.addEventListener('click', () => changeStatus('unpaid'));
detailBody.querySelector('#sup-btn-paid')?.addEventListener('click', () => changeStatus('paid'));
detailBody.querySelector('#sup-btn-draft')?.addEventListener('click', () => changeStatus('draft'));
detailBody.querySelector('#sup-btn-delete')?.addEventListener('click', handleDelete);
detailBody.querySelector('#sup-btn-edit')?.addEventListener('click', () => renderEditForm(inv));
// Wire up line actions
detailBody.querySelectorAll('[data-del-line]').forEach(btn => {
btn.addEventListener('click', () => handleDeleteLine(parseInt(btn.dataset.delLine)));
});
detailBody.querySelectorAll('[data-edit-line]').forEach(btn => {
btn.addEventListener('click', () => openEditLineForm(parseInt(btn.dataset.editLine), inv));
});
// Add line form
const showAddLine = detailBody.querySelector('#sup-show-add-line');
const addLineForm = detailBody.querySelector('#sup-add-line-form');
showAddLine?.addEventListener('click', () => {
addLineForm.style.display = 'flex';
showAddLine.style.display = 'none';
detailBody.querySelector('#nl-desc').focus();
});
detailBody.querySelector('#nl-cancel')?.addEventListener('click', () => {
addLineForm.style.display = 'none';
showAddLine.style.display = '';
});
detailBody.querySelector('#nl-save')?.addEventListener('click', handleAddLine);
// Payment form
const showPayForm = detailBody.querySelector('#sup-show-pay-form');
const payForm = detailBody.querySelector('#sup-pay-form');
showPayForm?.addEventListener('click', () => {
payForm.style.display = 'flex';
showPayForm.style.display = 'none';
const dateInput = detailBody.querySelector('#pay-date');
if (dateInput) dateInput.value = new Date().toISOString().split('T')[0];
});
detailBody.querySelector('#pay-cancel')?.addEventListener('click', () => {
payForm.style.display = 'none';
showPayForm.style.display = '';
});
detailBody.querySelector('#pay-save')?.addEventListener('click', handleAddPayment);
}
function renderEditForm(inv) {
const editSection = detailBody.querySelector('#sup-detail-info');
if (!editSection) return;
const actionsBar = detailBody.querySelector('div[style*="flex-wrap"]');
// Replace info grid with edit form
const form = document.createElement('form');
form.id = 'sup-edit-form';
form.innerHTML = /*html*/`
<div class="invoice-detail-grid" style="margin-bottom:1rem">
<div class="form-field-compact">
<label>Ref. proveedor</label>
<input type="text" id="ed-ref" value="${escHtml(inv.supplierRef || '')}"
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Vencimiento</label>
<input type="date" id="ed-expire" value="${fmtDateInput(inv.expireDate)}"
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact full-width" style="grid-column:1/-1">
<label>Nota pública</label>
<input type="text" id="ed-note" value="${escHtml(inv.notePublic || '')}"
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
</div>
<div class="form-actions-compact">
<button type="button" class="btn-cancel-compact" id="ed-cancel">Cancelar</button>
<button type="submit" class="btn-submit-compact" id="ed-save">Guardar cambios</button>
</div>
`;
detailBody.querySelector('#sup-detail-info').replaceWith(form);
if (actionsBar) actionsBar.style.display = 'none';
form.querySelector('#ed-cancel').addEventListener('click', () => renderDetail(inv));
form.addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = form.querySelector('#ed-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Guardando...';
try {
await updateSupplierInvoice(inv.id, {
supplierRef: form.querySelector('#ed-ref').value.trim() || undefined,
expireDate: form.querySelector('#ed-expire').value || undefined,
notePublic: form.querySelector('#ed-note').value.trim() || undefined,
});
showToast('Factura actualizada', 'success');
await refreshDetail();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
saveBtn.disabled = false;
saveBtn.textContent = 'Guardar cambios';
}
});
}
async function changeStatus(status) {
try {
await changeSupplierInvoiceStatus(currentInvoice.id, status);
showToast(`Estado actualizado a ${STATUS_TEXT[status] || status}`, 'success');
await refreshDetail();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function handleDelete() {
if (!confirm(`¿Eliminar la factura ${currentInvoice?.number}? Esta acción no se puede deshacer.`)) return;
try {
await deleteSupplierInvoice(currentInvoice.id);
showToast('Factura eliminada', 'success');
closeDetail();
allInvoices = allInvoices.filter(i => i.id !== currentInvoice.id);
applyFilters();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function handleAddLine() {
const desc = detailBody.querySelector('#nl-desc').value.trim();
const qty = parseFloat(detailBody.querySelector('#nl-qty').value) || 1;
const price = parseFloat(detailBody.querySelector('#nl-price').value) || 0;
const tax = parseFloat(detailBody.querySelector('#nl-tax').value) || 0;
if (!desc) { showToast('La descripción es obligatoria', 'error'); return; }
const saveBtn = detailBody.querySelector('#nl-save');
saveBtn.disabled = true;
try {
await addSupplierInvoiceLine(currentInvoice.id, {
description: desc, quantity: qty, unitPrice: price, taxRate: tax
});
showToast('Línea añadida', 'success');
await refreshDetail();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
saveBtn.disabled = false;
}
}
function openEditLineForm(lineId, inv) {
const line = inv.lines?.find(l => l.id === lineId);
if (!line) return;
const row = detailBody.querySelector(`[data-line-id="${lineId}"]`);
if (!row) return;
row.innerHTML = /*html*/`
<td><input type="text" value="${escHtml(line.description || '')}" class="el-desc" style="width:100%;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
<td><input type="number" value="${line.quantity}" class="el-qty" min="0.01" step="0.01" style="width:70px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
<td><input type="number" value="${line.unitPrice}" class="el-price" min="0" step="0.01" style="width:90px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
<td><input type="number" value="${line.taxRate}" class="el-tax" min="0" max="100" step="0.01" style="width:70px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
<td></td>
<td style="white-space:nowrap">
<button class="btn-submit-compact el-save" style="padding:0.25rem 0.5rem;font-size:0.8rem">OK</button>
<button class="btn-cancel-compact el-cancel" style="padding:0.25rem 0.5rem;font-size:0.8rem"></button>
</td>
`;
row.querySelector('.el-cancel').addEventListener('click', () => renderDetail(inv));
row.querySelector('.el-save').addEventListener('click', async () => {
const btn = row.querySelector('.el-save');
btn.disabled = true;
try {
await updateSupplierInvoiceLine(currentInvoice.id, lineId, {
description: row.querySelector('.el-desc').value.trim() || undefined,
quantity: parseFloat(row.querySelector('.el-qty').value) || undefined,
unitPrice: parseFloat(row.querySelector('.el-price').value) || undefined,
taxRate: parseFloat(row.querySelector('.el-tax').value) ?? undefined,
});
showToast('Línea actualizada', 'success');
await refreshDetail();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
btn.disabled = false;
}
});
}
async function handleDeleteLine(lineId) {
if (!confirm('¿Eliminar esta línea?')) return;
try {
await deleteSupplierInvoiceLine(currentInvoice.id, lineId);
showToast('Línea eliminada', 'success');
await refreshDetail();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function handleAddPayment() {
const date = detailBody.querySelector('#pay-date').value;
const amountRaw = detailBody.querySelector('#pay-amount').value;
const paymentModeId = parseInt(detailBody.querySelector('#pay-type').value) || 6;
if (!date) { showToast('Selecciona una fecha de pago', 'error'); return; }
const saveBtn = detailBody.querySelector('#pay-save');
saveBtn.disabled = true;
try {
await addSupplierInvoicePayment(currentInvoice.id, {
paymentDate: date,
amount: amountRaw ? parseFloat(amountRaw) : undefined,
paymentModeId,
closePaidInvoices: 'yes',
accountId: 1,
});
showToast('Pago registrado', 'success');
await refreshDetail();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
saveBtn.disabled = false;
}
}
function closeDetail() {
detailView?.remove();
detailView = detailBody = detailTitle = null;
currentInvoice = null;
}
async function load() {
try {
allInvoices = await getSupplierInvoices({ limit: 200 });
applyFilters();
} catch (err) {
tbody.innerHTML = `<tr><td colspan="7" class="facturas-empty">Error al cargar: ${escHtml(err.message)}</td></tr>`;
showToast('Error al cargar facturas de proveedores', 'error');
}
}
searchInput.addEventListener('input', () => { searchTerm = searchInput.value; applyFilters(); });
filterSelect.addEventListener('change', () => { currentFilter = filterSelect.value; applyFilters(); });
// ===== CREAR FACTURA =====
const createView = container.querySelector('#sup-create-view');
const listEls = [
container.querySelector('.facturas-header'),
container.querySelector('.facturas-filters'),
container.querySelector('#sup-summary'),
container.querySelector('.invoices-table-container'),
];
function showListView() {
detailView.style.display = 'none';
createView.style.display = 'none';
listEls.forEach(el => { if (el) el.style.display = ''; });
}
const supplierSelect = container.querySelector('#sup-supplier-select');
const linesContainer = container.querySelector('#sup-lines-container');
let lineCount = 0;
function createSupplierLine() {
lineCount++;
const row = document.createElement('div');
row.className = 'line-row-compact';
row.innerHTML = `
<input type="text" class="line-desc-compact" required placeholder="Descripción" />
<input type="number" class="line-qty-compact" required min="0.01" step="0.01" value="1" placeholder="Cant" />
<input type="number" class="line-price-compact" required min="0" step="0.01" placeholder="Precio" />
<input type="number" class="line-tax-compact" required min="0" max="100" step="0.01" value="21" placeholder="IVA %" />
<div class="line-total-compact">0.00 </div>
<button type="button" class="btn-delete-compact">×</button>
`;
const qty = row.querySelector('.line-qty-compact');
const price = row.querySelector('.line-price-compact');
const tax = row.querySelector('.line-tax-compact');
const total = row.querySelector('.line-total-compact');
const updateTotal = () => {
const t = (parseFloat(qty.value) || 0) * (parseFloat(price.value) || 0) * (1 + (parseFloat(tax.value) || 0) / 100);
total.textContent = `${t.toFixed(2)}`;
};
qty.addEventListener('input', updateTotal);
price.addEventListener('input', updateTotal);
tax.addEventListener('input', updateTotal);
row.querySelector('.btn-delete-compact').addEventListener('click', () => {
row.remove();
if (linesContainer.children.length === 0) linesContainer.appendChild(createSupplierLine());
});
return row;
}
function showCreateView() {
listEls.forEach(el => { if (el) el.style.display = 'none'; });
createView.style.display = 'block';
linesContainer.innerHTML = '';
lineCount = 0;
container.querySelector('#sup-ref-supplier').value = '';
container.querySelector('#sup-date').value = new Date().toISOString().split('T')[0];
container.querySelector('#sup-expire').value = '';
container.querySelector('#sup-note-public').value = '';
linesContainer.appendChild(createSupplierLine());
}
container.querySelectorAll('.date-picker-btn').forEach(btn => {
btn.addEventListener('click', () => {
const input = container.querySelector(`#${btn.dataset.target}`);
if (input) input.showPicker?.();
});
});
function closeCreate() {
showListView();
}
async function loadSuppliers() {
try {
const clients = await apiGet('/api/Clients?limit=500&supplier=true');
supplierSelect.innerHTML = '<option value="">Seleccionar proveedor...</option>';
clients.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
supplierSelect.appendChild(opt);
});
} catch {
supplierSelect.innerHTML = '<option value="">Error al cargar</option>';
}
}
container.querySelector('#btn-new-sup').addEventListener('click', () => {
showCreateView();
loadSuppliers();
});
container.querySelector('#sup-create-close').addEventListener('click', closeCreate);
container.querySelector('#sup-create-cancel').addEventListener('click', closeCreate);
container.querySelector('#sup-add-line').addEventListener('click', () => linesContainer.appendChild(createSupplierLine()));
container.querySelector('#sup-create-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = container.querySelector('#sup-create-submit');
const supplierId = parseInt(supplierSelect.value);
if (!supplierId) { showToast('Selecciona un proveedor', 'error'); return; }
const lines = [...linesContainer.querySelectorAll('.line-row-compact')].map(row => ({
description: row.querySelector('.line-desc-compact').value.trim(),
quantity: parseFloat(row.querySelector('.line-qty-compact').value) || 1,
unitPrice: parseFloat(row.querySelector('.line-price-compact').value) || 0,
taxRate: parseFloat(row.querySelector('.line-tax-compact').value) || 0,
}));
if (lines.some(l => !l.description)) { showToast('Rellena la descripción de todas las líneas', 'error'); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Creando...';
try {
await createSupplierInvoice({
supplierId,
supplierRef: container.querySelector('#sup-ref-supplier').value.trim(),
date: container.querySelector('#sup-date').value,
expireDate: container.querySelector('#sup-expire').value || null,
notePublic: container.querySelector('#sup-note-public').value.trim() || null,
lines
});
showToast('Factura de proveedor creada', 'success');
showListView();
allInvoices = await getSupplierInvoices({ limit: 200 });
applyFilters();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Crear factura';
}
});
load().then(() => {
const pendingId = window.__openSupplierInvoice;
if (pendingId) {
window.__openSupplierInvoice = null;
openDetail(pendingId);
}
});
return container;
}

View File

@ -0,0 +1,33 @@
import { auth } from '../services/auth.js';
import { renderLogin } from '../components/Login.js';
export function renderLoginPage(onLoginSuccess) {
const container = document.createElement('div');
container.className = 'login-page';
const loginComponent = renderLogin(async (email, password) => {
if (await auth.login(email, password)) {
if (onLoginSuccess) {
onLoginSuccess();
} else {
window.location.hash = '#dashboard';
}
} else {
const error = container.querySelector('#login-error');
error.textContent = 'Credenciales inválidas';
}
});
container.appendChild(loginComponent);
const sessionExpiredMessage = sessionStorage.getItem('session-expired-message');
if (sessionExpiredMessage) {
const error = container.querySelector('#login-error');
if (error) {
error.textContent = sessionExpiredMessage;
}
sessionStorage.removeItem('session-expired-message');
}
return container;
}

View File

@ -0,0 +1,353 @@
import { isDarkTheme, toggleTheme } from '../services/theme.js';
import { icons } from '../services/icons.js';
import { apiGet, apiPut } from '../services/apiClient.js';
import { showToast } from '../services/toast.js';
import { encryptVeriFactuPassword, formatVeriFactuError, getVeriFactuFormats, getVeriFactuHealth, registerVeriFactuCertificate } from '../services/verifactu.js';
function decodeTokenPayload(token) {
try {
const payloadPart = token.split('.')[1];
if (!payloadPart) return null;
const base64 = payloadPart.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
} catch {
return null;
}
}
function formatSeconds(totalSeconds) {
if (totalSeconds <= 0) return 'Expirada';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const hh = String(hours).padStart(2, '0');
const mm = String(minutes).padStart(2, '0');
const ss = String(seconds).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function getTokenExpirationInfo() {
const token = localStorage.getItem('token');
if (!token) {
return { hasToken: false, expiresAtText: 'No hay token', remainingText: 'No autenticado' };
}
const payload = decodeTokenPayload(token);
if (!payload || !payload.exp) {
return { hasToken: true, expiresAtText: 'No disponible', remainingText: 'No disponible' };
}
const expiresAtMs = payload.exp * 1000;
const nowMs = Date.now();
const remainingSeconds = Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
return {
hasToken: true,
expiresAtText: new Date(expiresAtMs).toLocaleString('es-ES'),
remainingText: formatSeconds(remainingSeconds),
remainingSeconds
};
}
export function renderSettingsPage() {
const container = document.createElement('div');
container.className = 'settings-page';
const dark = isDarkTheme();
container.innerHTML = `
<div class="settings-header">
<h1>Configuración</h1>
<p>Preferencias visuales y estado de sesión.</p>
</div>
<div class="settings-grid">
<section class="settings-card">
<h2>Apariencia</h2>
<div class="settings-row">
<div>
<p class="settings-label">Modo oscuro</p>
<p class="settings-help">Cambia entre tema claro y oscuro.</p>
</div>
<button type="button" class="settings-theme-toggle ${dark ? 'settings-theme-toggle--active' : ''}" id="settings-theme-toggle" aria-pressed="${dark ? 'true' : 'false'}">
<span class="toggle-track">
<span class="toggle-icons">
<span class="toggle-icon-sun">${icons.sun}</span>
<span class="toggle-icon-moon">${icons.moon}</span>
</span>
<span class="toggle-thumb"></span>
</span>
</button>
</div>
</section>
<section class="settings-card settings-card--session">
<h2>Sesión</h2>
<div class="settings-session-list">
<div class="settings-session-item">
<div class="settings-session-item-label">
<span class="settings-session-icon">${icons.info}</span>
<span>Caducidad del token</span>
</div>
<strong id="token-expire-at">-</strong>
</div>
<div class="settings-session-item">
<div class="settings-session-item-label">
<span class="settings-session-icon">${icons.clock}</span>
<span>Tiempo restante</span>
</div>
<strong id="token-remaining">-</strong>
</div>
</div>
</section>
<section class="settings-card" style="grid-column:1/-1">
<h2>Notificaciones</h2>
<p class="settings-help" style="margin-bottom:1rem">
URL de webhook para notificaciones de cambio de estado de facturas (compatible con Teams y Slack).
Déjalo vacío para desactivar.
</p>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:0.75rem">
<input
type="url"
id="webhook-url-input"
placeholder="https://outlook.office.com/webhook/... o https://hooks.slack.com/..."
style="width:100%;padding:0.6rem 0.75rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem"
/>
<div style="display:flex;gap:0.75rem;justify-content:flex-end">
<button type="button" class="btn-cancel-compact" id="webhook-clear-btn">Limpiar</button>
<button type="button" class="btn-submit-compact" id="webhook-save-btn">Guardar URL</button>
</div>
<p id="webhook-status" style="font-size:0.8rem;color:var(--text-secondary);min-height:1.2em"></p>
</div>
</section>
<section class="settings-card" style="grid-column:1/-1">
<h2>VeriFactu</h2>
<p class="settings-help" style="margin-bottom:1rem">
Conexión con VeriFactu MidAPI para validar el enlace, registrar certificados y preparar el envío de facturas.
</p>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:0.75rem">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:0.75rem;">
<input
type="text"
id="verifactu-cert-name"
placeholder="Nombre del certificado"
style="width:100%;padding:0.6rem 0.75rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem"
/>
<label style="display:flex;flex-direction:column;gap:0.25rem;font-size:0.85rem;color:var(--text-secondary)">
Archivo .p12
<input
type="file"
id="verifactu-cert-file"
accept=".p12"
style="padding:0.4rem 0;font-size:0.85rem;color:var(--text-primary)"
/>
</label>
</div>
<input
type="password"
id="verifactu-cert-password"
placeholder="Contraseña del certificado"
style="width:100%;padding:0.6rem 0.75rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem"
/>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;flex-wrap:wrap">
<button type="button" class="btn-cancel-compact" id="verifactu-health-btn">Comprobar API</button>
<button type="button" class="btn-cancel-compact" id="verifactu-formats-btn">Ver formatos</button>
<button type="button" class="btn-submit-compact" id="verifactu-register-btn">Registrar certificado</button>
</div>
<p id="verifactu-status" style="font-size:0.8rem;color:var(--text-secondary);min-height:1.2em"></p>
</div>
</section>
</div>
`;
const themeBtn = container.querySelector('#settings-theme-toggle');
const expireAtEl = container.querySelector('#token-expire-at');
const remainingEl = container.querySelector('#token-remaining');
const renderThemeButton = () => {
const isDark = isDarkTheme();
themeBtn.classList.toggle('settings-theme-toggle--active', isDark);
themeBtn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
};
const renderTokenInfo = () => {
const info = getTokenExpirationInfo();
expireAtEl.textContent = info.expiresAtText;
remainingEl.textContent = info.remainingText;
if (info.remainingSeconds === 0 && info.hasToken) {
remainingEl.classList.add('settings-token-expired');
} else {
remainingEl.classList.remove('settings-token-expired');
}
};
renderThemeButton();
renderTokenInfo();
themeBtn.addEventListener('click', () => {
toggleTheme();
renderThemeButton();
});
const intervalId = setInterval(renderTokenInfo, 1000);
// Webhook config
const webhookInput = container.querySelector('#webhook-url-input');
const webhookStatus = container.querySelector('#webhook-status');
const verifactuStatus = container.querySelector('#verifactu-status');
const verifactuCertName = container.querySelector('#verifactu-cert-name');
const verifactuCertFile = container.querySelector('#verifactu-cert-file');
const verifactuCertPassword = container.querySelector('#verifactu-cert-password');
verifactuCertFile.addEventListener('change', () => {
const file = verifactuCertFile.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.p12')) {
verifactuCertFile.value = '';
showToast('Solo se permite subir archivos con extension .p12', 'error');
}
});
async function loadWebhookUrl() {
try {
const data = await apiGet('/api/Settings/webhook');
webhookInput.value = data.url || '';
webhookStatus.textContent = data.url ? 'Webhook configurado.' : 'Sin webhook configurado.';
} catch {
webhookStatus.textContent = 'No se pudo cargar la configuración.';
}
}
container.querySelector('#webhook-save-btn').addEventListener('click', async () => {
const saveBtn = container.querySelector('#webhook-save-btn');
saveBtn.disabled = true;
try {
await apiPut('/api/Settings/webhook', { url: webhookInput.value.trim() });
showToast('URL de webhook guardada', 'success');
webhookStatus.textContent = webhookInput.value.trim() ? 'Webhook configurado.' : 'Sin webhook configurado.';
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
} finally {
saveBtn.disabled = false;
}
});
container.querySelector('#webhook-clear-btn').addEventListener('click', () => {
webhookInput.value = '';
});
container.querySelector('#verifactu-health-btn').addEventListener('click', async () => {
const button = container.querySelector('#verifactu-health-btn');
button.disabled = true;
verifactuStatus.textContent = 'Consultando estado de VeriFactu...';
try {
const health = await getVeriFactuHealth();
if (health?.status === 'down') {
verifactuStatus.textContent = `VeriFactu no responde: ${health.error || 'sin detalle'}`;
showToast('VeriFactu no está disponible en 6789', 'error');
} else {
verifactuStatus.textContent = `API activa: ${health.status || 'ok'}`;
showToast('VeriFactu responde correctamente', 'success');
}
} catch (error) {
verifactuStatus.textContent = `Error al comprobar la API: ${error.message}`;
showToast(`VeriFactu: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
});
container.querySelector('#verifactu-formats-btn').addEventListener('click', async () => {
const button = container.querySelector('#verifactu-formats-btn');
button.disabled = true;
verifactuStatus.textContent = 'Cargando formatos soportados...';
try {
const data = await getVeriFactuFormats();
const formats = Array.isArray(data.formats) ? data.formats.join(', ') : 'sin datos';
verifactuStatus.textContent = `Formatos soportados: ${formats}`;
} catch (error) {
verifactuStatus.textContent = `Error al cargar formatos: ${error.message}`;
showToast(`VeriFactu: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
});
container.querySelector('#verifactu-register-btn').addEventListener('click', async () => {
const button = container.querySelector('#verifactu-register-btn');
const certName = verifactuCertName.value.trim();
const file = verifactuCertFile.files?.[0];
const password = verifactuCertPassword.value;
if (!certName || !file || !password) {
showToast('Rellena nombre, selecciona el archivo .p12 y escribe la contraseña', 'error');
return;
}
if (!file.name.toLowerCase().endsWith('.p12')) {
showToast('El certificado debe tener extension .p12', 'error');
return;
}
let cert_file;
try {
cert_file = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(file);
});
} catch {
showToast('No se pudo leer el archivo .p12', 'error');
return;
}
let password_encrypted;
try {
console.log('[DEBUG] Password before encryption:', password);
password_encrypted = await encryptVeriFactuPassword(password);
console.log('[DEBUG] Encrypted password:', password_encrypted);
} catch (error) {
showToast(`No se pudo cifrar la contraseña: ${error.message}`, 'error');
return;
}
button.disabled = true;
verifactuStatus.textContent = 'Registrando certificado en VeriFactu...';
try {
const result = await registerVeriFactuCertificate({
cert_name: certName,
cert_file,
password: password_encrypted
});
if (result?.success === false) {
const errorMessage = formatVeriFactuError(result.error);
verifactuStatus.textContent = `Error al registrar certificado: ${errorMessage}`;
showToast(`VeriFactu: ${errorMessage}`, 'error');
return;
}
const token = result.token ? ` Token: ${result.token}` : '';
verifactuStatus.textContent = `Certificado registrado.${token}`;
showToast('Certificado registrado en VeriFactu', 'success');
} catch (error) {
const errorMessage = formatVeriFactuError(error);
verifactuStatus.textContent = `Error al registrar certificado: ${errorMessage}`;
showToast(`VeriFactu: ${errorMessage}`, 'error');
} finally {
button.disabled = false;
}
});
loadWebhookUrl();
container.cleanup = () => clearInterval(intervalId);
return container;
}

View File

@ -0,0 +1,48 @@
import { pagesConfig } from '../services/pagesConfig.js';
import { icons } from '../services/icons.js';
const modules = import.meta.glob('./*Page.js', { eager: true });
const knownFiles = new Set(pagesConfig.map(p => p._file).filter(Boolean));
function toRoute(path) {
return path.replace('./', '').replace('Page.js', '')
.replace(/([A-Z])/g, (c, l, i) => (i ? '-' : '') + l.toLowerCase());
}
function toName(path) {
return path.replace('./', '').replace('Page.js', '')
.replace(/([A-Z])/g, (c, l, i) => (i ? ' ' : '') + l).trim();
}
const autoPages = Object.entries(modules)
.filter(([path]) => {
const file = path.replace('./', '').replace('.js', '');
return !knownFiles.has(file);
})
.map(([path, mod]) => {
const render = mod.render ?? mod.default
?? Object.values(mod).find(v => typeof v === 'function')
?? (() => document.createElement('div'));
return {
route: mod.route ?? toRoute(path),
name: mod.name ?? toName(path),
order: mod.order ?? 99,
icon: mod.icon ?? icons.dashboard,
voicePatterns: mod.voicePatterns ?? [],
requiresAuth: mod.requiresAuth ?? true,
showInSidebar: mod.showInSidebar ?? true,
render,
};
});
export const pagesRegistry = [...pagesConfig, ...autoPages]
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
export function getAvailablePages(isAuthenticated) {
if (!isAuthenticated) return [];
return pagesRegistry.filter(p => p.showInSidebar && (!p.requiresAuth || isAuthenticated));
}
export function getPageByRoute(route) {
return pagesRegistry.find(p => p.route === route);
}

191
doli-front/src/router.js Executable file
View File

@ -0,0 +1,191 @@
import { auth } from './services/auth.js';
import { renderLoginPage } from './pages/LoginPage.js';
import { createSidebar } from './components/Sidebar.js';
import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js';
import { showConfirmExitModal } from './components/ConfirmExitModal.js';
import { applyLoginTheme, applySavedTheme } from './services/theme.js';
import { hideVoiceAssistant, showVoiceAssistant } from './components/VoiceAssistant.js';
// 🔧 Modo DEV: cambiar a false para activar login
const DEV_MODE = false;
// Sistema de guards para bloquear navegación
const navigationGuards = {
currentGuard: null,
// Registrar un guard (función que devuelve true si hay cambios sin guardar)
register(checkFn) {
this.currentGuard = checkFn;
},
// Eliminar el guard actual
unregister() {
this.currentGuard = null;
},
// Verificar si hay cambios pendientes
hasUnsavedChanges() {
return this.currentGuard ? this.currentGuard() : false;
}
};
// Exportar para uso en páginas
export { navigationGuards };
export function initRouter() {
const app = document.querySelector('#app');
let isNavigating = false;
let pendingHash = null;
let currentPageCleanup = null;
async function navigate(forceNavigate = false) {
const hash = window.location.hash || (DEV_MODE ? '#dashboard' : '#login');
const route = hash.substring(1);
// Auth check first — a forced logout must never be blocked by the unsaved-changes guard
if (!DEV_MODE && !auth.checkAuth() && hash !== '#login') {
navigationGuards.unregister();
window.location.hash = '#login';
return;
}
// Si hay un guard activo y no estamos forzando navegación
if (!forceNavigate && navigationGuards.hasUnsavedChanges()) {
// Guardar el hash al que se quiere ir
pendingHash = hash;
// Volver al hash anterior temporalmente
const previousHash = '#' + (document.querySelector('.main-content')?.dataset?.currentRoute || 'dashboard');
history.pushState(null, '', previousHash);
// Mostrar modal de confirmación
const confirmed = await showConfirmExitModal({
title: 'Cambios sin guardar',
message: 'Tienes cambios sin guardar. ¿Seguro que quieres salir?',
confirmText: 'Salir sin guardar',
cancelText: 'Seguir editando'
});
if (confirmed) {
// Usuario confirmó salir - limpiar guard y navegar
navigationGuards.unregister();
window.location.hash = pendingHash;
}
// Si no confirmó, ya estamos en la página correcta
pendingHash = null;
return;
}
if (typeof currentPageCleanup === 'function') {
currentPageCleanup();
currentPageCleanup = null;
}
// Remove any body-level modals left open from the previous page
document.querySelectorAll('body > [class*="overlay"], body > [class*="modal"]').forEach(el => {
if (!el.classList.contains('connection-lost-overlay')) el.remove();
});
if (hash === '#login') {
applyLoginTheme();
hideVoiceAssistant();
} else {
applySavedTheme();
showVoiceAssistant();
}
app.innerHTML = '';
// For authenticated routes, add sidebar and main content wrapper
const isAuthenticated = DEV_MODE || auth.checkAuth();
if (isAuthenticated && hash !== '#login') {
const layout = document.createElement('div');
layout.className = 'app-layout';
// Create sidebar
const pages = getAvailablePages(true);
const sidebar = createSidebar(pages, route);
// Create main content area
const mainContent = document.createElement('main');
mainContent.className = 'main-content';
mainContent.dataset.currentRoute = route; // Guardar ruta actual
// Get page from registry
const page = getPageByRoute(route);
if (!page) {
// For unregistered routes, redirect to dashboard
window.location.hash = '#dashboard';
return;
}
if (!DEV_MODE && page.requiresAuth && !auth.checkAuth()) {
window.location.hash = '#login';
return;
}
// Render page content using registry (supports async render)
const maybePromise = page.render();
const pageContent = maybePromise instanceof Promise ? await maybePromise : maybePromise;
// Mobile sidebar: backdrop + topbar
const sidebarBackdrop = document.createElement('div');
sidebarBackdrop.className = 'sidebar-backdrop';
const openMobileSidebar = () => {
sidebar.classList.add('mobile-open');
sidebarBackdrop.classList.add('visible');
};
const closeMobileSidebar = () => {
sidebar.classList.remove('mobile-open');
sidebarBackdrop.classList.remove('visible');
};
const handleMobileClose = () => closeMobileSidebar();
document.addEventListener('sidebar:close-mobile', handleMobileClose);
sidebarBackdrop.addEventListener('click', closeMobileSidebar);
const mobileTopbar = document.createElement('header');
mobileTopbar.className = 'mobile-topbar';
mobileTopbar.innerHTML = /*html*/`
<button class="mobile-menu-btn" aria-label="Abrir menú">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<span class="mobile-topbar-title">${page.name}</span>
`;
mobileTopbar.querySelector('.mobile-menu-btn').addEventListener('click', openMobileSidebar);
// Chain page cleanup with sidebar event listener cleanup
const pageCleanup = pageContent && typeof pageContent.cleanup === 'function' ? pageContent.cleanup : null;
currentPageCleanup = () => {
document.removeEventListener('sidebar:close-mobile', handleMobileClose);
if (pageCleanup) pageCleanup();
};
mainContent.appendChild(mobileTopbar);
mainContent.appendChild(pageContent);
layout.appendChild(sidebar);
layout.appendChild(sidebarBackdrop);
layout.appendChild(mainContent);
app.appendChild(layout);
} else {
// For login page, no sidebar
switch (hash) {
case '#login':
app.appendChild(renderLoginPage(() => {
window.location.hash = '#dashboard';
}));
break;
default:
window.location.hash = '#login';
}
}
}
window.addEventListener('hashchange', () => navigate(false));
navigate(true); // Primera navegación sin guard
}

View File

@ -0,0 +1,183 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
let reloginInProgress = false;
function redirectToLogin() {
// Always clean up body-level modals regardless of current hash —
// auth:logout can navigate to #login before this runs, making the hash check a no-op.
document.querySelectorAll('body > [class*="overlay"], body > [class*="modal"]').forEach(el => {
if (!el.classList.contains('connection-lost-overlay')) el.remove();
});
if (window.location.hash !== '#login') {
window.location.hash = '#login';
}
}
function showConnectionLostNotice(message) {
if (!document?.body) {
alert(message);
redirectToLogin();
return;
}
const existing = document.querySelector('.connection-lost-overlay');
if (existing) {
return;
}
const overlay = document.createElement('div');
overlay.className = 'connection-lost-overlay';
overlay.innerHTML = `
<div class="connection-lost-modal" role="alertdialog" aria-modal="true" aria-labelledby="connection-lost-title">
<h3 id="connection-lost-title">Conexion perdida</h3>
<p>${message}</p>
<p class="connection-lost-sub">Seras redirigido al login en <strong id="connection-lost-countdown">3</strong> segundos.</p>
<button type="button" class="connection-lost-login-btn">Ir al login ahora</button>
</div>
`;
const countdownEl = overlay.querySelector('#connection-lost-countdown');
const loginNowBtn = overlay.querySelector('.connection-lost-login-btn');
let seconds = 3;
const finishRedirect = () => {
clearInterval(timerId);
overlay.remove();
redirectToLogin();
};
const timerId = setInterval(() => {
seconds -= 1;
if (countdownEl) {
countdownEl.textContent = String(Math.max(0, seconds));
}
if (seconds <= 0) {
finishRedirect();
}
}, 1000);
loginNowBtn?.addEventListener('click', finishRedirect);
document.body.appendChild(overlay);
}
function forceRelogin(message) {
if (reloginInProgress) {
return;
}
reloginInProgress = true;
localStorage.removeItem('user');
localStorage.removeItem('token');
sessionStorage.setItem('session-expired-message', message);
window.dispatchEvent(new Event('auth:logout'));
showConnectionLostNotice(message);
}
function buildUrl(path) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
return `${API_BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`;
}
function buildHeaders({ auth = true, headers = {}, hasJsonBody = false }) {
const finalHeaders = { ...headers };
if (auth) {
const token = localStorage.getItem('token');
if (token) {
finalHeaders.Authorization = `Bearer ${token}`;
}
}
if (hasJsonBody && !finalHeaders['Content-Type']) {
finalHeaders['Content-Type'] = 'application/json';
}
return finalHeaders;
}
async function parseError(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => ({}));
return data.detail || data.message || data.error || `HTTP ${response.status}`;
}
const text = await response.text().catch(() => '');
return text || `HTTP ${response.status}`;
}
export async function apiRequest(path, options = {}) {
const {
method = 'GET',
auth = true,
headers = {},
body,
responseType = 'json'
} = options;
const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData);
let response;
try {
response = await fetch(buildUrl(path), {
method,
headers: buildHeaders({ auth, headers, hasJsonBody: isJsonBody }),
body: isJsonBody ? JSON.stringify(body) : body
});
} catch (error) {
if (auth) {
forceRelogin('Se ha perdido la conexion con el servidor. Debes volver a iniciar sesion.');
}
throw new Error('Se ha perdido la conexion con el servidor.');
}
if (!response.ok) {
if (auth && (response.status === 401 || response.status === 403 || response.status === 503)) {
forceRelogin('Se ha perdido la conexion con el servidor. Debes volver a iniciar sesion.');
}
throw new Error(await parseError(response));
}
if (responseType === 'raw') {
return response;
}
if (response.status === 204) {
return null;
}
if (responseType === 'blob') {
return response.blob();
}
if (responseType === 'text') {
return response.text();
}
return response.json();
}
export function apiGet(path, options = {}) {
return apiRequest(path, { ...options, method: 'GET' });
}
export function apiPost(path, body, options = {}) {
return apiRequest(path, { ...options, method: 'POST', body });
}
export function apiPut(path, body, options = {}) {
return apiRequest(path, { ...options, method: 'PUT', body });
}
export function apiPatch(path, body, options = {}) {
return apiRequest(path, { ...options, method: 'PATCH', body });
}
export function apiDelete(path, options = {}) {
return apiRequest(path, { ...options, method: 'DELETE' });
}

91
doli-front/src/services/auth.js Executable file
View File

@ -0,0 +1,91 @@
import { apiPost } from './apiClient.js';
function isTokenExpired(token) {
try {
const payloadPart = token.split('.')[1];
if (!payloadPart) return true;
const base64 = payloadPart.replace(/-/g, '+').replace(/_/g, '/');
const decoded = atob(base64);
const payload = JSON.parse(decoded);
if (!payload.exp) return false;
return payload.exp * 1000 <= Date.now();
} catch {
return true;
}
}
export class Auth {
constructor() {
this.currentUser = null;
this.isAuthenticated = false;
}
async login(identifier, password) {
if (!identifier || !password) return false;
try {
const body = { Username: identifier, Password: password };
console.log('🔐 Login attempt - Datos enviados:', body);
const data = await apiPost('/api/Auth/login', body, { auth: false });
console.log('✅ Respuesta exitosa:', data);
const { token, user } = data;
if (!token) {
console.error('❌ No token in response');
return false;
}
this.currentUser = user || { identifier };
this.isAuthenticated = true;
localStorage.setItem('user', JSON.stringify(this.currentUser));
localStorage.setItem('token', token);
window.dispatchEvent(new Event('auth:login'));
console.log('✅ Login successful, guardado en localStorage');
return true;
} catch (error) {
console.error('❌ Login error:', error);
return false;
}
}
logout() {
this.currentUser = null;
this.isAuthenticated = false;
localStorage.removeItem('user');
localStorage.removeItem('token');
window.dispatchEvent(new Event('auth:logout'));
}
checkAuth() {
const user = localStorage.getItem('user');
const token = localStorage.getItem('token');
if (user && token && !isTokenExpired(token)) {
this.currentUser = JSON.parse(user);
this.isAuthenticated = true;
} else {
if (token && isTokenExpired(token)) {
sessionStorage.setItem('session-expired-message', 'Tu sesion expiro. Inicia sesion de nuevo.');
}
this.isAuthenticated = false;
this.currentUser = null;
localStorage.removeItem('user');
localStorage.removeItem('token');
window.dispatchEvent(new Event('auth:logout'));
}
return this.isAuthenticated;
}
getUser() {
return this.currentUser;
}
getToken() {
return localStorage.getItem('token');
}
}
export const auth = new Auth();

Some files were not shown because too many files have changed in this diff Show More