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:
parent
4609c43b40
commit
322608286f
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"}`))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, "_")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,5 @@
|
|||
allowBuilds:
|
||||
esbuild: false
|
||||
onnxruntime-node: false
|
||||
protobufjs: false
|
||||
sharp: false
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>Nº 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>Nº 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, '"')}" /></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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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`, `2º`, `3er`, `4º`][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> · ` +
|
||||
`<span class="db-sub-amber">${unpaid} pendientes</span> · ` +
|
||||
`<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 Ene–Mar', 'Q2 Abr–Jun', 'Q3 Jul–Sep', 'Q4 Oct–Dic'],
|
||||
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;
|
||||
}
|
||||
|
|
@ -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`, `2º`, `3er`, `4º`][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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue