diff --git a/.gitignore b/.gitignore index 87d35d5..6e36b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/VerifactuMidAPI b/VerifactuMidAPI deleted file mode 160000 index 35446f3..0000000 --- a/VerifactuMidAPI +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 35446f3c490c6b476e6657ee6a9db777bf84284c diff --git a/VerifactuMidAPI/.github/workflows/build.yml b/VerifactuMidAPI/.github/workflows/build.yml new file mode 100644 index 0000000..751748c --- /dev/null +++ b/VerifactuMidAPI/.github/workflows/build.yml @@ -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 diff --git a/VerifactuMidAPI/.gitignore b/VerifactuMidAPI/.gitignore new file mode 100644 index 0000000..e138a3f --- /dev/null +++ b/VerifactuMidAPI/.gitignore @@ -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/ diff --git a/VerifactuMidAPI/AGENTS.md b/VerifactuMidAPI/AGENTS.md new file mode 100644 index 0000000..65395fc --- /dev/null +++ b/VerifactuMidAPI/AGENTS.md @@ -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 \ No newline at end of file diff --git a/VerifactuMidAPI/README.md b/VerifactuMidAPI/README.md new file mode 100644 index 0000000..4f4a842 --- /dev/null +++ b/VerifactuMidAPI/README.md @@ -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 diff --git a/VerifactuMidAPI/api/handler.go b/VerifactuMidAPI/api/handler.go new file mode 100644 index 0000000..0f237dd --- /dev/null +++ b/VerifactuMidAPI/api/handler.go @@ -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) +} diff --git a/VerifactuMidAPI/api/router.go b/VerifactuMidAPI/api/router.go new file mode 100644 index 0000000..cb42880 --- /dev/null +++ b/VerifactuMidAPI/api/router.go @@ -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"}`)) +} diff --git a/VerifactuMidAPI/cmd/diagcert/main.go b/VerifactuMidAPI/cmd/diagcert/main.go new file mode 100644 index 0000000..af82875 --- /dev/null +++ b/VerifactuMidAPI/cmd/diagcert/main.go @@ -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)) + } +} diff --git a/VerifactuMidAPI/cmd/register_cert/main.go b/VerifactuMidAPI/cmd/register_cert/main.go new file mode 100644 index 0000000..1a883b3 --- /dev/null +++ b/VerifactuMidAPI/cmd/register_cert/main.go @@ -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 ") + 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)) + } +} diff --git a/VerifactuMidAPI/config.yml b/VerifactuMidAPI/config.yml new file mode 100644 index 0000000..fec4621 --- /dev/null +++ b/VerifactuMidAPI/config.yml @@ -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" \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/ERRORES.md b/VerifactuMidAPI/documentacion/ERRORES.md new file mode 100644 index 0000000..1b6783c --- /dev/null +++ b/VerifactuMidAPI/documentacion/ERRORES.md @@ -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: ` | Error comunicando con la AEAT | +| `aeat_fault: ` | 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. diff --git a/VerifactuMidAPI/documentacion/PREREQUISITES.md b/VerifactuMidAPI/documentacion/PREREQUISITES.md new file mode 100644 index 0000000..382c82b --- /dev/null +++ b/VerifactuMidAPI/documentacion/PREREQUISITES.md @@ -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 +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":""} +``` + +--- + +## 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. diff --git a/VerifactuMidAPI/documentacion/README.md b/VerifactuMidAPI/documentacion/README.md new file mode 100644 index 0000000..06b8e93 --- /dev/null +++ b/VerifactuMidAPI/documentacion/README.md @@ -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 | diff --git a/VerifactuMidAPI/documentacion/api.md b/VerifactuMidAPI/documentacion/api.md new file mode 100644 index 0000000..8ff07f1 --- /dev/null +++ b/VerifactuMidAPI/documentacion/api.md @@ -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 | \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/arqui.md b/VerifactuMidAPI/documentacion/arqui.md new file mode 100644 index 0000000..5c0be5d --- /dev/null +++ b/VerifactuMidAPI/documentacion/arqui.md @@ -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. \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/certificado_pruebas.md b/VerifactuMidAPI/documentacion/certificado_pruebas.md new file mode 100644 index 0000000..60e451b --- /dev/null +++ b/VerifactuMidAPI/documentacion/certificado_pruebas.md @@ -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 \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/config.md b/VerifactuMidAPI/documentacion/config.md new file mode 100644 index 0000000..e3ec870 --- /dev/null +++ b/VerifactuMidAPI/documentacion/config.md @@ -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) \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/formato_datos.md b/VerifactuMidAPI/documentacion/formato_datos.md new file mode 100644 index 0000000..57c9578 --- /dev/null +++ b/VerifactuMidAPI/documentacion/formato_datos.md @@ -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. \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/formatos.md b/VerifactuMidAPI/documentacion/formatos.md new file mode 100644 index 0000000..a8f8632 --- /dev/null +++ b/VerifactuMidAPI/documentacion/formatos.md @@ -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. diff --git a/VerifactuMidAPI/documentacion/seguridad.md b/VerifactuMidAPI/documentacion/seguridad.md new file mode 100644 index 0000000..701396a --- /dev/null +++ b/VerifactuMidAPI/documentacion/seguridad.md @@ -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/.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 \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/testing.md b/VerifactuMidAPI/documentacion/testing.md new file mode 100644 index 0000000..dfc97d9 --- /dev/null +++ b/VerifactuMidAPI/documentacion/testing.md @@ -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. \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/tokens.md b/VerifactuMidAPI/documentacion/tokens.md new file mode 100644 index 0000000..a17e5fc --- /dev/null +++ b/VerifactuMidAPI/documentacion/tokens.md @@ -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. \ No newline at end of file diff --git a/VerifactuMidAPI/documentacion/verifactu.md b/VerifactuMidAPI/documentacion/verifactu.md new file mode 100644 index 0000000..0d18416 --- /dev/null +++ b/VerifactuMidAPI/documentacion/verifactu.md @@ -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 `S`. Para el resto, se envía `` 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 \ No newline at end of file diff --git a/VerifactuMidAPI/go.mod b/VerifactuMidAPI/go.mod new file mode 100644 index 0000000..30f68fa --- /dev/null +++ b/VerifactuMidAPI/go.mod @@ -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 +) diff --git a/VerifactuMidAPI/go.sum b/VerifactuMidAPI/go.sum new file mode 100644 index 0000000..f808ce8 --- /dev/null +++ b/VerifactuMidAPI/go.sum @@ -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= diff --git a/VerifactuMidAPI/internal/cert/storage.go b/VerifactuMidAPI/internal/cert/storage.go new file mode 100644 index 0000000..ef430fd --- /dev/null +++ b/VerifactuMidAPI/internal/cert/storage.go @@ -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") +} diff --git a/VerifactuMidAPI/internal/cert/validate_p12.py b/VerifactuMidAPI/internal/cert/validate_p12.py new file mode 100644 index 0000000..0d0a34a --- /dev/null +++ b/VerifactuMidAPI/internal/cert/validate_p12.py @@ -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) \ No newline at end of file diff --git a/VerifactuMidAPI/internal/cert/validator.go b/VerifactuMidAPI/internal/cert/validator.go new file mode 100644 index 0000000..3f84401 --- /dev/null +++ b/VerifactuMidAPI/internal/cert/validator.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/cert/validator_test.go b/VerifactuMidAPI/internal/cert/validator_test.go new file mode 100644 index 0000000..20bbcbf --- /dev/null +++ b/VerifactuMidAPI/internal/cert/validator_test.go @@ -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) + } +} diff --git a/VerifactuMidAPI/internal/config/config.go b/VerifactuMidAPI/internal/config/config.go new file mode 100644 index 0000000..a5d8d47 --- /dev/null +++ b/VerifactuMidAPI/internal/config/config.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/crypto/crypto.go b/VerifactuMidAPI/internal/crypto/crypto.go new file mode 100644 index 0000000..08f25f3 --- /dev/null +++ b/VerifactuMidAPI/internal/crypto/crypto.go @@ -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) +} diff --git a/VerifactuMidAPI/internal/factura.go b/VerifactuMidAPI/internal/factura.go new file mode 100644 index 0000000..9fef4a6 --- /dev/null +++ b/VerifactuMidAPI/internal/factura.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/formats/dolibarr/format.go b/VerifactuMidAPI/internal/formats/dolibarr/format.go new file mode 100644 index 0000000..dea057c --- /dev/null +++ b/VerifactuMidAPI/internal/formats/dolibarr/format.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/formats/native/format.go b/VerifactuMidAPI/internal/formats/native/format.go new file mode 100644 index 0000000..b858eff --- /dev/null +++ b/VerifactuMidAPI/internal/formats/native/format.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/formats/registry.go b/VerifactuMidAPI/internal/formats/registry.go new file mode 100644 index 0000000..10619d3 --- /dev/null +++ b/VerifactuMidAPI/internal/formats/registry.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/hash.go b/VerifactuMidAPI/internal/hash.go new file mode 100644 index 0000000..f154aef --- /dev/null +++ b/VerifactuMidAPI/internal/hash.go @@ -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, "_") +} diff --git a/VerifactuMidAPI/internal/hash_test.go b/VerifactuMidAPI/internal/hash_test.go new file mode 100644 index 0000000..fc8cb28 --- /dev/null +++ b/VerifactuMidAPI/internal/hash_test.go @@ -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) + } +} diff --git a/VerifactuMidAPI/internal/models.go b/VerifactuMidAPI/internal/models.go new file mode 100644 index 0000000..143ea52 --- /dev/null +++ b/VerifactuMidAPI/internal/models.go @@ -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) +} diff --git a/VerifactuMidAPI/internal/models_test.go b/VerifactuMidAPI/internal/models_test.go new file mode 100644 index 0000000..243789e --- /dev/null +++ b/VerifactuMidAPI/internal/models_test.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/transformer.go b/VerifactuMidAPI/internal/transformer.go new file mode 100644 index 0000000..8ca3a39 --- /dev/null +++ b/VerifactuMidAPI/internal/transformer.go @@ -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 +} diff --git a/VerifactuMidAPI/internal/transformer_test.go b/VerifactuMidAPI/internal/transformer_test.go new file mode 100644 index 0000000..1edcdff --- /dev/null +++ b/VerifactuMidAPI/internal/transformer_test.go @@ -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) + } +} diff --git a/VerifactuMidAPI/main.go b/VerifactuMidAPI/main.go new file mode 100644 index 0000000..d207e72 --- /dev/null +++ b/VerifactuMidAPI/main.go @@ -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") + } +} diff --git a/VerifactuMidAPI/presentacion/demo/demo.py b/VerifactuMidAPI/presentacion/demo/demo.py new file mode 100644 index 0000000..abca2ed --- /dev/null +++ b/VerifactuMidAPI/presentacion/demo/demo.py @@ -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() diff --git a/VerifactuMidAPI/presentacion/slides.html b/VerifactuMidAPI/presentacion/slides.html new file mode 100644 index 0000000..60920b0 --- /dev/null +++ b/VerifactuMidAPI/presentacion/slides.html @@ -0,0 +1,312 @@ + + + + +VeriFactu — Presentación del proyecto + + + + + +
+
BYolivia · 2025
+
+

Integración VeriFactu

+

Facturación electrónica obligatoria con la AEAT

+

Presentación del sistema de envío de facturas al sistema VeriFactu de la Agencia Tributaria, integrado en la suite de gestión empresarial.

+
+ + +
+

¿Por qué VeriFactu?

+
+
+

Obligación legal

+
    +
  • Reglamento de facturación electrónica (RD 1007/2023)
  • +
  • Obligatorio para empresas y autónomos en España
  • +
  • Cada factura debe enviarse a la AEAT en tiempo real
  • +
  • Las facturas se encadenan con hash para garantizar integridad
  • +
+
+
+

El reto técnico

+
    +
  • La AEAT usa SOAP/XML con firma digital
  • +
  • Requiere certificado digital de empresa (FNMT/ACCV)
  • +
  • TLS mútuo (mTLS) para autenticarse
  • +
  • El software ERP (Dolibarr) no lo soporta de forma nativa
  • +
+
+
+
+ + +
+

El ecosistema del proyecto

+ + +
+
Dolibarr ERP
datos de facturas
+
+
Backend
dolibarr-bff :5269
+
+
Front
doli-front :5173
+
+
VerifactuMidAPI
Go :6789
+
- - →
+
AEAT
VeriFactu
+
+ +
+
+ Implementado + Dolibarr → Backend → Front → VerifactuMidAPI. El backend almacena el token que devuelve la API tras registrar el certificado. +
+
+ Sin probar + VerifactuMidAPI → AEAT. El envío real a VeriFactu no ha podido validarse por el tema del certificado. Es el único tramo pendiente de verificar. +
+
+
+ + +
+

VerifactuMidAPI — Qué hace

+
+
    +
  • Recibe la factura en JSON (formato nativo o Dolibarr)
  • +
  • Calcula el hash SHA-256 encadenado (norma AEAT)
  • +
  • Genera el XML SOAP con todos los campos requeridos
  • +
  • Firma digitalmente con el certificado de empresa
  • +
  • Envía a la AEAT por SOAP con mTLS (TLS 1.2)
  • +
  • Si la AEAT no responde → guarda en fallback local
  • +
  • Devuelve el resultado al BFF
  • +
+
+
+

Endpoints

+ + + + + + + +
GET /healthEstado
GET /auth/public-keyClave RSA pública
POST /auth/registerSubir certificado P12
GET /formatsFormatos aceptados
POST /facturasAlta de factura
POST /facturas/anularAnulación
+
+
+
+
+ + +
+

Seguridad — Certificados y tokens

+
+
+

Registro del certificado

+
    +
  • El front cifra la contraseña del .p12 con RSA (clave pública de la API)
  • +
  • La API descifra, valida el P12 y lo almacena
  • +
  • Soporta certificados FNMT y ACCV (multi-cert)
  • +
  • La API extrae automáticamente los datos del emisor del propio certificado
  • +
  • Devuelve un token de sesión para usar en los envíos
  • +
+
+
+

Flujo de registro

+
+
Front: selecciona .p12 + contraseña
+
+
Front: cifra pass con RSA pública
+
+
API: valida, almacena, genera token
+
+
BFF: guarda el token para futuros envíos
+
+
+
+
+ + +
+

Estado actual del proyecto

+
+
+

VerifactuMidAPI (este repo — rama main)

+ + + + + + + + +
Alta de facturas con hash encadenado
Anulación de facturas
Registro y validación de certificados P12
Soporte multi-cert (FNMT / ACCV)
Fallback local si AEAT no responde
TLS 1.2 + auto-fill datos emisor del cert
Consultas y subsanación (pendiente)
+
+
+

Integración en el ecosistema (rama verifactu)

+ + + + + + +
Front: subida del certificado P12
Front: cifrado RSA de contraseña
Front: recepción y uso del token
BFF: guardado del token en backend
Pendiente de merge a main
+
La integración completa estaba bloqueada por el problema del certificado en la API, ya resuelto.
+
+
+
+ + + +
+

Próximos pasos

+
+
+

Inmediatos

+
    +
  • Merge de rama verifactu a main en front y BFF
  • +
  • Pruebas end-to-end con el ecosistema completo
  • +
  • Validar con certificado real en entorno de pruebas AEAT
  • +
+
+
+

Siguientes funcionalidades

+
    +
  • Consultas a la AEAT (estado de facturas enviadas)
  • +
  • Subsanación de facturas incorrectas
  • +
  • Panel de estado VeriFactu en el front
  • +
+
+
+
El núcleo de la integración está completo y probado. El bloqueo original (gestión del certificado) está resuelto.
+
+ + + + + + + diff --git a/VerifactuMidAPI/test/check_password.py b/VerifactuMidAPI/test/check_password.py new file mode 100644 index 0000000..622d6ca --- /dev/null +++ b/VerifactuMidAPI/test/check_password.py @@ -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!") \ No newline at end of file diff --git a/VerifactuMidAPI/test/generate_certs.py b/VerifactuMidAPI/test/generate_certs.py new file mode 100644 index 0000000..cfacebf --- /dev/null +++ b/VerifactuMidAPI/test/generate_certs.py @@ -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") \ No newline at end of file diff --git a/VerifactuMidAPI/test/invoice.json b/VerifactuMidAPI/test/invoice.json new file mode 100644 index 0000000..0b0f4df --- /dev/null +++ b/VerifactuMidAPI/test/invoice.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" + } +} \ No newline at end of file diff --git a/VerifactuMidAPI/test/run_tests.py b/VerifactuMidAPI/test/run_tests.py new file mode 100644 index 0000000..7556749 --- /dev/null +++ b/VerifactuMidAPI/test/run_tests.py @@ -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()) \ No newline at end of file diff --git a/VerifactuMidAPI/test/simulate.py b/VerifactuMidAPI/test/simulate.py new file mode 100644 index 0000000..9eddcfc --- /dev/null +++ b/VerifactuMidAPI/test/simulate.py @@ -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() \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_cert.py b/VerifactuMidAPI/test/test_cert.py new file mode 100644 index 0000000..c9e27d5 --- /dev/null +++ b/VerifactuMidAPI/test/test_cert.py @@ -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") \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_direct_aeat.py b/VerifactuMidAPI/test/test_direct_aeat.py new file mode 100644 index 0000000..3e5bd4c --- /dev/null +++ b/VerifactuMidAPI/test/test_direct_aeat.py @@ -0,0 +1,79 @@ +import urllib.request +import urllib.parse + +URL = "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP" + +soap_request = """ + + + + + + + TEST + 53950250R + + + + + 1.0 + + 53950250R + FV2026/001 + 17-04-2026 + + TEST EMPRESA + F1 + Factura de prueba + + + 01 + S1 + 01 + 100.00 + 21.00 + + + 21.00 + 121.00 + + S + + + TEST + 53950250R + TEST + 1 + 1.0 + 1 + S + + 17-04-2026T12:00:00 + SHA-256 + TESTHASH + + + + +""" + +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}") \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_direct_no_cert.py b/VerifactuMidAPI/test/test_direct_no_cert.py new file mode 100644 index 0000000..951aec9 --- /dev/null +++ b/VerifactuMidAPI/test/test_direct_no_cert.py @@ -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 = """ + + + + + + + TEST EMPRESA SL + 53950250R + + + + + 1.0 + + 53950250R + FV2026/TEST001 + 17-04-2026 + + TEST EMPRESA SL + F1 + Factura de prueba test + + + 01 + S1 + 01 + 100.00 + 21.00 + + + 21.00 + 121.00 + + S + + + TEST API + 53950250R + TEST-API + 1 + 1.0 + 1 + S + + 17-04-2026T12:00:00 + SHA-256 + 0A1B2C3D4E5F6 + + + + +""" + +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}") \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_directo_aeat.py b/VerifactuMidAPI/test/test_directo_aeat.py new file mode 100644 index 0000000..73d9162 --- /dev/null +++ b/VerifactuMidAPI/test/test_directo_aeat.py @@ -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""" + + + + + + + {NOMBRE} + {NIF} + + + + + 1.0 + + {NIF} + {NUM_SERIE} + {FECHA_EXP} + + {NOMBRE} + {TIPO_FACTURA} + Factura de prueba directa + + + {NOMBRE} + {NIF} + + + + + 01 + S1 + 21.00 + {BASE:.2f} + {CUOTA:.2f} + + + {CUOTA:.2f} + {TOTAL:.2f} + + + {NIF} + {PREV_NUM_SERIE} + {PREV_FECHA_EXP} + {PREV_HUELLA} + + + + {NOMBRE} + {NIF} + VerifactuMidAPI + 01 + 1.0.0 + 1 + S + N + N + + {FECHA_GEN} + 01 + {huella} + + + + +""" + +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}") diff --git a/VerifactuMidAPI/test/test_invoice.py b/VerifactuMidAPI/test/test_invoice.py new file mode 100644 index 0000000..5644f49 --- /dev/null +++ b/VerifactuMidAPI/test/test_invoice.py @@ -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}") \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_openssl.py b/VerifactuMidAPI/test/test_openssl.py new file mode 100644 index 0000000..4918fff --- /dev/null +++ b/VerifactuMidAPI/test/test_openssl.py @@ -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}") \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_personal.py b/VerifactuMidAPI/test/test_personal.py new file mode 100644 index 0000000..c3e69fe --- /dev/null +++ b/VerifactuMidAPI/test/test_personal.py @@ -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}") \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_simulate.py b/VerifactuMidAPI/test/test_simulate.py new file mode 100644 index 0000000..5d92e16 --- /dev/null +++ b/VerifactuMidAPI/test/test_simulate.py @@ -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") \ No newline at end of file diff --git a/VerifactuMidAPI/test/test_validate.py b/VerifactuMidAPI/test/test_validate.py new file mode 100644 index 0000000..80f3499 --- /dev/null +++ b/VerifactuMidAPI/test/test_validate.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}") \ No newline at end of file diff --git a/VerifactuMidAPI/test/validate_temp.py b/VerifactuMidAPI/test/validate_temp.py new file mode 100644 index 0000000..a25b1af --- /dev/null +++ b/VerifactuMidAPI/test/validate_temp.py @@ -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) \ No newline at end of file diff --git a/VerifactuMidAPI/verifactu/client.go b/VerifactuMidAPI/verifactu/client.go new file mode 100644 index 0000000..8a0a2ed --- /dev/null +++ b/VerifactuMidAPI/verifactu/client.go @@ -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" +} diff --git a/VerifactuMidAPI/verifactu/soap.go b/VerifactuMidAPI/verifactu/soap.go new file mode 100644 index 0000000..e2f3f5c --- /dev/null +++ b/VerifactuMidAPI/verifactu/soap.go @@ -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) +} diff --git a/VerifactuMidAPI/verifactu/xml.go b/VerifactuMidAPI/verifactu/xml.go new file mode 100644 index 0000000..6b6a99d --- /dev/null +++ b/VerifactuMidAPI/verifactu/xml.go @@ -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 +} diff --git a/doli-front b/doli-front deleted file mode 160000 index 7c3b05f..0000000 --- a/doli-front +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7c3b05f2dae87dddb3ca9d704539dc7a063e9c0e diff --git a/doli-front/.gitignore b/doli-front/.gitignore new file mode 100755 index 0000000..25ed449 --- /dev/null +++ b/doli-front/.gitignore @@ -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 diff --git a/doli-front/README.md b/doli-front/README.md new file mode 100644 index 0000000..cd91285 --- /dev/null +++ b/doli-front/README.md @@ -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. diff --git a/doli-front/ajustesDeJuanjo.txt b/doli-front/ajustesDeJuanjo.txt new file mode 100644 index 0000000..4c83cac --- /dev/null +++ b/doli-front/ajustesDeJuanjo.txt @@ -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) \ No newline at end of file diff --git a/doli-front/index.html b/doli-front/index.html new file mode 100755 index 0000000..09136a6 --- /dev/null +++ b/doli-front/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Doli + + + + + + + +
+ + + + \ No newline at end of file diff --git a/doli-front/package.json b/doli-front/package.json new file mode 100755 index 0000000..075cc94 --- /dev/null +++ b/doli-front/package.json @@ -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" + } +} diff --git a/doli-front/pnpm-lock.yaml b/doli-front/pnpm-lock.yaml new file mode 100644 index 0000000..5daff66 --- /dev/null +++ b/doli-front/pnpm-lock.yaml @@ -0,0 +1,1338 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@huggingface/transformers': + specifier: ^3.8.1 + version: 3.8.1 + chart.js: + specifier: ^4.5.1 + version: 4.5.1 + node-forge: + specifier: ^1.4.0 + version: 1.4.0 + devDependencies: + vite: + specifier: ^7.2.4 + version: 7.3.3(@types/node@25.7.0) + +packages: + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@huggingface/jinja@0.5.9': + resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} + engines: {node: '>=18'} + + '@huggingface/transformers@3.8.1': + resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@25.7.0': + resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + onnxruntime-common@1.21.0: + resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} + + onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: + resolution: {integrity: sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==} + + onnxruntime-node@1.21.0: + resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==} + os: [win32, darwin, linux] + + onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: + resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + protobufjs@7.5.8: + resolution: {integrity: sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==} + engines: {node: '>=12.0.0'} + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + undici-types@7.21.0: + resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + +snapshots: + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@huggingface/jinja@0.5.9': {} + + '@huggingface/transformers@3.8.1': + dependencies: + '@huggingface/jinja': 0.5.9 + onnxruntime-node: 1.21.0 + onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 + sharp: 0.34.5 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@kurkle/color@0.3.4': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.1 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.1': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@types/estree@1.0.8': {} + + '@types/node@25.7.0': + dependencies: + undici-types: 7.21.0 + + boolean@3.2.0: {} + + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + + chownr@3.0.0: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: {} + + detect-node@2.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es6-error@4.1.1: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escape-string-regexp@4.0.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + flatbuffers@25.9.23: {} + + fsevents@2.3.3: + optional: true + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.0 + serialize-error: 7.0.1 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + guid-typescript@1.0.9: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + json-stringify-safe@5.0.1: {} + + long@5.3.2: {} + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + nanoid@3.3.12: {} + + node-forge@1.4.0: {} + + object-keys@1.1.1: {} + + onnxruntime-common@1.21.0: {} + + onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: {} + + onnxruntime-node@1.21.0: + dependencies: + global-agent: 3.0.0 + onnxruntime-common: 1.21.0 + tar: 7.5.15 + + onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: + dependencies: + flatbuffers: 25.9.23 + guid-typescript: 1.0.9 + long: 5.3.2 + onnxruntime-common: 1.22.0-dev.20250409-89f8206ba4 + platform: 1.3.6 + protobufjs: 7.5.8 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + platform@1.3.6: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + protobufjs@7.5.8: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.7.0 + long: 5.3.2 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + semver-compare@1.0.0: {} + + semver@7.8.0: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.0 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + source-map-js@1.2.1: {} + + sprintf-js@1.1.3: {} + + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + type-fest@0.13.1: {} + + undici-types@7.21.0: {} + + vite@7.3.3(@types/node@25.7.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.7.0 + fsevents: 2.3.3 + + yallist@5.0.0: {} diff --git a/doli-front/pnpm-workspace.yaml b/doli-front/pnpm-workspace.yaml new file mode 100644 index 0000000..e363a5a --- /dev/null +++ b/doli-front/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: false + onnxruntime-node: false + protobufjs: false + sharp: false diff --git a/doli-front/presentacion/guion_presentacion.md b/doli-front/presentacion/guion_presentacion.md new file mode 100644 index 0000000..10d0f4c --- /dev/null +++ b/doli-front/presentacion/guion_presentacion.md @@ -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. diff --git a/doli-front/presentacion/index.html b/doli-front/presentacion/index.html new file mode 100644 index 0000000..6137bf2 --- /dev/null +++ b/doli-front/presentacion/index.html @@ -0,0 +1,332 @@ + + + + + + doli-front — Frontend SPA para Dolibarr + + + + + + +
+ + +
+

2DAM — Proyecto Grupal — 2026

+

doli-front

+

SPA Vanilla JS · Vite · Sin frameworks

+

El frontend que Dolibarr nunca tuvo

+
+ + +
+

Dolibarr tiene todo. Menos una interfaz decente.

+

El ERP funciona, pero su interfaz tiene 20 años. El objetivo: dar una experiencia moderna sin tocar el backend del ERP.

+
+
+

Antes

+
+ Interfaz PHP de 2004
Sin dark mode
Sin búsqueda en tiempo real
Sin notificaciones
Imposible de personalizar +
+
+
+

Ahora

+
+ SPA moderna con dark mode
Búsqueda y filtros reactivos
Notificaciones Teams / Slack
Asistente de voz integrado
Soporte VeriFactu +
+
+
+
+ + +
+

Sin React. Sin Vue. Sin Angular.

+

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.

+
+
+

Control total

+

Cada elemento del DOM lo creamos nosotros. Sin magia, sin diffing invisible.

+
+
+

Dependencias mínimas

+

Solo 3: Chart.js (gráficas), @huggingface/transformers (voz) y node-forge (cifrado RSA).

+
+
+

Aprendizaje real

+

Entendemos qué hace el navegador. Un framework encima de esto es trivial; al revés, no tanto.

+
+
+
+ + +
+

Estructura del proyecto

+
+
src/ +├── pages/ ← render*() → HTMLElement +│ ├── DashboardPage.js +│ ├── FacturasPage.js +│ └── pagesRegistry.js ← auto-glob +├── components/ ← reutilizables +│ ├── InvoiceModal.js +│ └── Sidebar.js +├── services/ ← lógica +│ ├── apiClient.js ← fetch+JWT +│ └── pagesConfig.js +├── styles/ +├── main.js ← entrada +└── router.js ← hash routing
+
+
+

Una página = una función

+

render*() devuelve un HTMLElement.
Estado local en closure.
El router la monta y destruye.

+
+
+

Sin estado global

+

No hay store ni contexto.
Cada página habla con el BFF
de forma independiente.

+
+
+
+
+ + +
+

Routing sin servidor

+

El router escucha el hash de la URL. Navegar a #invoices desmonta la página actual y monta la nueva.

+
// 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);
+});
+
+

cleanup() evita memory leaks. Si una página tiene un setInterval (ej.: countdown del JWT en Configuración), se cancela al navegar.

+
+
+ + +
+

Añadir una página es crear un archivo

+

Con import.meta.glob de Vite, cualquier *Page.js en /pages se registra solo en el sidebar.

+
+
// 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
+
+
+

Páginas explícitas

+

En pagesConfig.js: ruta, nombre, icono, voice patterns.

+
+
+

Páginas nuevas

+

Crear MiPaginaPage.js → aparece en el sidebar con ruta #mi-pagina automáticamente.

+
+
+
+
+ + +
+

Lo que puede hacer el usuario

+
+
+

Dashboard

+

KPIs, gráfica trimestral, últimos movimientos, tabla con colores.

+
+
+

Facturas

+

CRUD completo. Plantillas, líneas editables, pagos, cambio de estado.

+
+
+

Fact. Proveedores

+

Gestión de compras. Total por línea en tiempo real.

+
+
+

Terceros

+

Clientes y proveedores con rol. Contactos anidados.

+
+
+

Banco

+

Cuentas bancarias y movimientos. Alta con selector de país.

+
+
+

Configuración

+

Tema, JWT countdown, webhook Teams/Slack, VeriFactu.

+
+
+
+ + +
+

Whisper en el navegador

+

El asistente de voz ejecuta Whisper base (Xenova/whisper-base) directamente en el navegador con @huggingface/transformers. Sin API key, sin servidor de voz.

+
+
Micrófono
+ +
Web Audio API
+ +
Whisper (WASM)
+ +
Navegación
+
+
+

"Ir a facturas", "abrir banco", "dashboard" → el router navega sin tocar el teclado. Los patrones de voz se definen en pagesConfig.js junto a la ruta.

+
+
+ + +
+

Firma de facturas: VeriFactu

+

Flujo de tres pasos que ocurre en el navegador antes de llegar al servidor.

+
+
+

1 — Certificado

+

El usuario selecciona su .p12. La FileReader API lo convierte a base64 en el navegador. Nunca toca el disco del servidor.

+
+
+

2 — Contraseña cifrada

+

El BFF expone una clave pública RSA. El frontend cifra la contraseña con ella. Nunca viaja en claro.

+
+
+

3 — Registro

+

El BFF reenvía al microservicio Go, que valida el .p12, lo almacena y devuelve un token de sesión.

+
+
+
+ + +
+

Una sola función para hablar con el backend

+

Todo el tráfico HTTP pasa por apiClient.js. JWT automático, 401 redirige al login, errores normalizados.

+
// 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();
+}
+
+

Las páginas nunca manejan tokens ni status HTTP. Solo llaman a apiGet(), apiPost()… y reciben datos o un error.

+
+
+ + +
+

Decisiones que valió la pena tomar

+
+
+

Hash routing sin servidor

+

Archivos estáticos. Funciona en cualquier CDN sin configurar rutas.

+
+
+

Cleanup en cada página

+

container.cleanup() cancela intervals al navegar. Sin memory leaks.

+
+
+

Vite + import.meta.glob

+

HMR en desarrollo. Cada página es un chunk en producción. Registro dinámico.

+
+
+

FileReader + RSA en cliente

+

Certificado y contraseña procesados en el navegador. Nunca viajan en claro.

+
+
+
+ + +
+

Preguntas

+

doli-front · Vanilla JS · Vite · 2026

+
+ +
+ + + + + + diff --git a/doli-front/presentacion/que_subir.md b/doli-front/presentacion/que_subir.md new file mode 100644 index 0000000..59a4077 --- /dev/null +++ b/doli-front/presentacion/que_subir.md @@ -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. diff --git a/doli-front/public/doli-favicon.svg b/doli-front/public/doli-favicon.svg new file mode 100644 index 0000000..ca7cd64 --- /dev/null +++ b/doli-front/public/doli-favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/doli-front/public/vite.svg b/doli-front/public/vite.svg new file mode 100755 index 0000000..e7b8dfb --- /dev/null +++ b/doli-front/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doli-front/src/components/BlockedNavigationToast.js b/doli-front/src/components/BlockedNavigationToast.js new file mode 100644 index 0000000..18668c9 --- /dev/null +++ b/doli-front/src/components/BlockedNavigationToast.js @@ -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*/` +
+ ${icons.info} + ${message} +
+ `; + + 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; +} \ No newline at end of file diff --git a/doli-front/src/components/ClientItem.js b/doli-front/src/components/ClientItem.js new file mode 100644 index 0000000..d2a25b1 --- /dev/null +++ b/doli-front/src/components/ClientItem.js @@ -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*/` + +
+ ${getInitials()} +
+ + +
+ ${displayValue(client.name)} + ${displayValue(client.codeClient)} +
+ + ${roleLabel(client.role)} + + ${getStatusText(client.status)} + + + + + ${displayValue(client.phone)} + + + + `; + + 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; +} diff --git a/doli-front/src/components/ConfirmExitModal.js b/doli-front/src/components/ConfirmExitModal.js new file mode 100644 index 0000000..db0b33a --- /dev/null +++ b/doli-front/src/components/ConfirmExitModal.js @@ -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*/` +
+
+ + + + + +
+

${title}

+

${message}

+
+ + +
+
+ `; + + 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} 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 = []; + } +} diff --git a/doli-front/src/components/InvoiceItem.js b/doli-front/src/components/InvoiceItem.js new file mode 100644 index 0000000..bf46301 --- /dev/null +++ b/doli-front/src/components/InvoiceItem.js @@ -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*/` + + + + + + + + + ${escapeHtml(getStatusText(invoice.status))} + + + + ${icons.user} + ${escapeHtml(invoice.clientName || 'Sin nombre')} + + ${formatDate(invoice.date)} + ${formatCurrency(invoice.total)} + ${invoice.status === 'paid' ? '—' : formatCurrency(invoice.remainToPay)} + + + + `; + + item.querySelector('.btn-invoice-num').addEventListener('click', () => onView?.(invoice.id)); + + const viewBtn = item.querySelector('.btn-view'); + viewBtn.addEventListener('click', () => onView?.(invoice.id)); + + return item; +} \ No newline at end of file diff --git a/doli-front/src/components/InvoiceModal.js b/doli-front/src/components/InvoiceModal.js new file mode 100644 index 0000000..7182790 --- /dev/null +++ b/doli-front/src/components/InvoiceModal.js @@ -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 = ` + + + `; + modalContent.querySelector('.btn-close')?.addEventListener('click', handleClose); + return; + } + + if (!invoice) { + modalContent.innerHTML = ` + + + `; + 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 = ` + + + + + + `; + + 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 = '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 = ` + + + + + — + + + + + `; + + 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 = ''; + } + + 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 = ''; + } + } + }; + + // 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 = '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 = ` + + `; + + // 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; +} diff --git a/doli-front/src/components/Login.js b/doli-front/src/components/Login.js new file mode 100755 index 0000000..d05e12a --- /dev/null +++ b/doli-front/src/components/Login.js @@ -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 = ` + + `; + + 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; +} \ No newline at end of file diff --git a/doli-front/src/components/Sidebar.js b/doli-front/src/components/Sidebar.js new file mode 100755 index 0000000..f61bc01 --- /dev/null +++ b/doli-front/src/components/Sidebar.js @@ -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*/` + + + `; + + 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*/` + ${page.icon} + ${page.name} + `; + 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*/` + + + `; + + 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; +} \ No newline at end of file diff --git a/doli-front/src/components/VoiceAssistant.js b/doli-front/src/components/VoiceAssistant.js new file mode 100644 index 0000000..b34cecf --- /dev/null +++ b/doli-front/src/components/VoiceAssistant.js @@ -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*/` + + + + `; + + 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*/` + ${cmd.icon} + + ${cmd.label} + ${cmd.hint} + + `; + 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(); +} diff --git a/doli-front/src/counter.js b/doli-front/src/counter.js new file mode 100755 index 0000000..881e2d7 --- /dev/null +++ b/doli-front/src/counter.js @@ -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) +} diff --git a/doli-front/src/javascript.svg b/doli-front/src/javascript.svg new file mode 100755 index 0000000..f9abb2b --- /dev/null +++ b/doli-front/src/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doli-front/src/main.js b/doli-front/src/main.js new file mode 100755 index 0000000..3117418 --- /dev/null +++ b/doli-front/src/main.js @@ -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() diff --git a/doli-front/src/pages/BancoPage.js b/doli-front/src/pages/BancoPage.js new file mode 100644 index 0000000..dff95df --- /dev/null +++ b/doli-front/src/pages/BancoPage.js @@ -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*/` +
+

Banco

+

Gestión de cuentas bancarias y movimientos

+
+ +
+
+
+ + + +
+
+ Saldo total + +
+
+ +
+
+ + + + +
+
+ Ingresos + +
+
+ +
+
+ + + + +
+
+ Gastos + +
+
+ +
+
+ + + + +
+
+ Cuentas + +
+
+
+ +
+
+
+

Cuentas bancarias

+ +
+
+
+
+
+
+
+
+
+ +
+
+

Últimos movimientos

+ +
+
+
+
+
+
+
+
+
+
+
+ `; + + let allMovements = []; + let selectedAccountId = null; + + // ── Render helpers ──────────────────────────────────────────────── + + function renderAccounts(list) { + const el = container.querySelector('#banco-accounts'); + if (!list || list.length === 0) { + el.innerHTML = `
+ + + +

No hay cuentas bancarias registradas

+
`; + 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 = ` + + + + `; + 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 = `

No hay movimientos${subtitle ? ' para esta cuenta' : ' recientes'}

`; + 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 ` +
+
+
+ ${cleanLabel(tx.label)} + ${fmtDate(tx.dateValue || tx.date)} +
+ + ${amount >= 0 ? '+' : ''}${fmt(amount)} + +
+ `; + }).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 = `
+ + + +

Módulo bancario no disponible aún

+ Próximamente +
`; + } + + // ── 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 = `
`; + 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 = `

No se pudieron cargar los movimientos

`; + } + } + + 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*/` +
+
+
+
+ + + + +
+
+

Editar cuenta bancaria

+

${acc.label || acc.ref || 'Cuenta sin nombre'}

+
+
+ +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + 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 = ''; + 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 = ` + + + + + + + `; + 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 = ' 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 = ' Guardar cambios'; + } + }); + } + + // ── Create account modal ────────────────────────────────────────── + + function showCreateAccountModal() { + const overlay = document.createElement('div'); + overlay.className = 'banco-modal-overlay'; + overlay.innerHTML = /*html*/` +
+
+
+
+ + + +
+
+

Nueva cuenta bancaria

+

Los campos marcados con * son obligatorios

+
+
+ +
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + 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 = ''; + 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 = ` + + + + + + + `; + } + })(); + + 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 = ' 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 = ' Crear cuenta'; + } + }); + } + + container.querySelector('#btn-new-account').addEventListener('click', showCreateAccountModal); + + loadData(); + return container; +} diff --git a/doli-front/src/pages/ClientesPage.js b/doli-front/src/pages/ClientesPage.js new file mode 100644 index 0000000..1be5449 --- /dev/null +++ b/doli-front/src/pages/ClientesPage.js @@ -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*/` +
+

Terceros

+
+ +
+
+ ${icons.search} + +
+ +
+ +
+ + + + + + + + + + + + + + + +
Nombre / CódigoTipoEstadoEmailTeléfonoAcciones
Cargando terceros...
+
+ + + `; + + 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 = `
${icons.emptyClients}

No hay terceros

Los terceros aparecerán aquí una vez añadidos.

`; + 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 => ` +
+
+
${icons.user}
+ ${escapeHtml(displayValue(contact.firstname))} ${escapeHtml(displayValue(contact.lastname))} +
+
+ ${contact.email ? `
${icons.mail}${escapeHtml(displayValue(contact.email))}
` : ''} + ${contact.phoneMobile ? `
${icons.mobile}${escapeHtml(displayValue(contact.phoneMobile))}
` : ''} + ${contact.phonePro ? `
${icons.phone}${escapeHtml(displayValue(contact.phonePro))}
` : ''} + ${contact.phonePerso ? `
${icons.phone}${escapeHtml(displayValue(contact.phonePerso))}
` : ''} +
+
+ `).join('') + : '

No hay contactos registrados

'; + + modal.innerHTML = /*html*/` +
+
+

${escapeHtml(displayValue(client.name))}

+ +
+
+
+

Información del Tercero

+
+
+ ID + ${escapeHtml(client.id)} +
+
+ Código + ${escapeHtml(displayValue(client.codeClient))} +
+
+ Tipo + ${{ client: 'Cliente', supplier: 'Proveedor', both: 'Ambos' }[client.role] || 'N/A'} +
+
+ Estado + ${escapeHtml(getClientStatusText(client.status))} +
+
+ Email + ${escapeHtml(displayValue(client.email))} +
+
+ Teléfono + ${escapeHtml(displayValue(client.phone))} +
+
+
+
+

Contactos (${client.contacts?.length || 0})

+
+ ${contactsHtml} +
+
+
+
+ `; + + 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*/` +
+
+

Nuevo tercero

+ +
+
+
+
+

Información básica

+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Dirección

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Notas

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ `; + + 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 = 'Cargando terceros...'; + + 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 = `
${icons.error}

Error al cargar

No se pudieron cargar los terceros. Inténtalo de nuevo.

`; + } + } + + 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; +} \ No newline at end of file diff --git a/doli-front/src/pages/ContactsPage.js b/doli-front/src/pages/ContactsPage.js new file mode 100644 index 0000000..d388bcb --- /dev/null +++ b/doli-front/src/pages/ContactsPage.js @@ -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*/` +
+

Contactos

+
+ +
+
+ ${icons.search} + +
+ +
+ +
+ + + + + + + + + + + + + +
NombreEmailTeléfonoMóvilAcciones
Cargando...
+
+ + + + + + + `; + + 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 = `No hay contactos`; + return; + } + tbody.innerHTML = filteredContacts.map(c => ` + + + ${icons.user} + + + ${escHtml(c.email || '—')} + ${escHtml(c.phonePro || '—')} + ${escHtml(c.phoneMobile || '—')} + + + + + + `).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 = '
Cargando...
'; + + 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 = `
No se pudo cargar: ${escHtml(err.message)}
`; + } + } + + function renderDetailView(c) { + detailBody.innerHTML = /*html*/` +
+
+
Apellidos${escHtml(c.lastname || '—')}
+
Nombre${escHtml(c.firstname || '—')}
+
Email${escHtml(c.email || '—')}
+
Tel. profesional${escHtml(c.phonePro || '—')}
+
Tel. personal${escHtml(c.phonePerso || '—')}
+
Móvil${escHtml(c.phoneMobile || '—')}
+ ${c.address ? `
Dirección${escHtml(c.address)}
` : ''} + ${(c.zip || c.town) ? `
Ciudad${escHtml([c.zip, c.town].filter(Boolean).join(' '))}
` : ''} +
+
+ +
+
+ `; + detailBody.querySelector('#contact-edit-btn').addEventListener('click', () => renderEditView(c)); + } + + function renderEditView(c) { + detailBody.innerHTML = /*html*/` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + 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 = ''; + 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 = `Error al cargar: ${escHtml(err.message)}`; + showToast('Error al cargar contactos', 'error'); + } + })(); + + return container; +} diff --git a/doli-front/src/pages/CreateInvoicePage.js b/doli-front/src/pages/CreateInvoicePage.js new file mode 100644 index 0000000..80ab5a2 --- /dev/null +++ b/doli-front/src/pages/CreateInvoicePage.js @@ -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*/` +
+

Nueva Factura

+
+ + +
+
+ + + + +
+
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+

Líneas de Factura

+ +
+ +
+
+ +
+ + +
+
+ `; + + const clientSelect = container.querySelector('#clientId'); + + async function loadClients() { + try { + const clients = await apiGet('/api/Clients?limit=1000'); + + clientSelect.innerHTML = ''; + 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 = ''; + } + } + + 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 = '

Cargando plantillas...

'; + try { + templatesCache = await getInvoiceTemplates(); + } catch { + listEl.innerHTML = '

Error al cargar plantillas.

'; + return; + } + } + + if (!templatesCache.length) { + listEl.innerHTML = '

No hay plantillas disponibles.

'; + return; + } + + listEl.innerHTML = templatesCache.map(t => ` + + `).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 = '

Cargando detalle...

'; + + 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 = '

Error al cargar la plantilla.

'; + } + } + + 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*/` + + + + +
0.00 €
+ + `; + + 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; +} diff --git a/doli-front/src/pages/DashboardPage.js b/doli-front/src/pages/DashboardPage.js new file mode 100644 index 0000000..3f856d1 --- /dev/null +++ b/doli-front/src/pages/DashboardPage.js @@ -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 `${s.label}`; +} + +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 = '

Sin datos

'; 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 += ` +
+
+ ${year} + ${fmt(tot.total)} · ${tot.count} facturas +
+ + + + + + + ${yr.map(r => ` + + + + + + + + `).join('')} + + + + + + + + + + +
TrimestreFacturasBase imponibleIVATotal emitidoCobradoPte. cobro
T${r.q} · ${qLabel(r.q)}${r.count}${fmt(r.ht)}${fmt(r.tax)}${fmt(r.total)}${fmt(r.paid)}${fmt(r.pending)}
+
`; + }); + 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*/` +
+
+

${getGreeting()}, ${escapeHtml(userName)}

+

Resumen de facturación · ${year}

+
+ +
+ +
+
+
+ Facturas emitidas +
${icons.fileText}
+
+
+
+
+ +
+
+ Total ventas +
${icons.dollarSign}
+
+
+
+
+ +
+
+ Total compras +
${icons.dollarSign}
+
+
+
+
+ +
+
+ Balance neto +
${icons.checkCircle}
+
+
+
+
+ +
+
+ Tasa de cobro +
${icons.checkCircle}
+
+
+
+
+ +
+
+ Pendiente de cobro +
${icons.clock}
+
+
+
+
+
+ +
+
+
+ Balance trimestral + ${year} +
+
+ +
+
+ +
+
+ Últimos movimientos + Ver todas +
+
+ ${[...Array(6)].map(() => ` +
+
+
+
+
`).join('')} +
+
+
+ +
+
+ Facturación trimestral +
+
+

Cargando...

+
+
+ +
+
+ Últimas facturas emitidas + Ver todas +
+
+ + + + + + + + + + + + + + +
NúmeroClienteEstadoFechaVencimientoTotal
Cargando...
+
+
+ `; + + 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 = '

Error al cargar datos.

'; + }); + 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 = + `${paid} pagadas · ` + + `${unpaid} pendientes · ` + + `${draft} borrador`; + + 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 + ? `Resultado positivo` + : `Resultado negativo`; + + 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 + ? `${unpaid} factura${unpaid !== 1 ? 's' : ''} sin cobrar` + : 'Sin importes pendientes'; + + 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 = `
No hay facturas recientes.
`; + } else { + recentList.innerHTML = recent.map(inv => { + const isVenta = inv._type === 'venta'; + const typeBadge = isVenta + ? `V` + : `C`; + return ` +
+
+ ${icons.fileText} +
+
+ ${typeBadge} ${escapeHtml(inv.number || `#${inv.id}`)} + ${escapeHtml(inv._party || '—')} +
+
+ ${formatCurrency(inv.total)} + ${formatDate(inv.dateModification || inv.dateCreation || inv.date)} +
+
+ ${icons.chevron} +
+
+ `; + }).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 = 'No hay facturas'; + } 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 ` + + ${escapeHtml(inv.number || `#${inv.id}`)} + ${escapeHtml(inv.clientName || '—')} + ${getStatusBadge(inv.status)} + ${formatDate(inv.date)} + ${formatDate(inv.expireDate)} + ${formatCurrency(inv.total)} + `; + }).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; +} diff --git a/doli-front/src/pages/FacturasPage.js b/doli-front/src/pages/FacturasPage.js new file mode 100755 index 0000000..e9c4a48 --- /dev/null +++ b/doli-front/src/pages/FacturasPage.js @@ -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*/` +
+

Facturas

+
+ +
+
+ ${icons.search} + +
+ + + +
+ + + + + +
+ + + + + + + + + + + + + + + + +
+ + + Número + + Estado + + Cliente + + Fecha + + Total + + Pendiente + Acciones
Cargando facturas...
+
+ + + + + `; + + 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 = `
${icons.emptyInvoices}

No hay facturas

Crea tu primera factura para comenzar.

`; + 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 = 'Cargando facturas...'; + + 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 = `
${icons.error}

Error al cargar

No se pudieron cargar las facturas. Inténtalo de nuevo.

`; + } + } + + 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 = ` 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 = ` 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 = '

No hay facturas para mostrar

'; + 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 += ` +
+
+ ${year} + ${fmt(totals.total)} · ${totals.count} facturas +
+ + + + + + + + + + + + + + ${yearRows.map(r => { + return ` + + + + + + + + `; + }).join('')} + + + + + + + + + + + + +
TrimestreFacturasBase imponibleIVATotalCobradoPendiente
T${r.q} · ${qLabel(r.q)}${r.count}${fmt(r.ht)}${fmt(r.tax)}${fmt(r.total)}${fmt(r.paid)}${fmt(r.pending)}
+
`; + }); + + 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 = ` Cerrar resumen`; + renderQuarterlySummary(); + } else { + view.style.display = 'none'; + table.style.display = ''; + btn.classList.remove('btn-quarterly--active'); + btn.innerHTML = ` 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; +} \ No newline at end of file diff --git a/doli-front/src/pages/FacturasProveedoresPage.js b/doli-front/src/pages/FacturasProveedoresPage.js new file mode 100644 index 0000000..18312e1 --- /dev/null +++ b/doli-front/src/pages/FacturasProveedoresPage.js @@ -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*/` +
+

Facturas de Proveedores

+
+ +
+
+ ${icons.search} + +
+ + +
+ + + +
+ + + + + + + + + + + + + + + +
NúmeroEstadoProveedorFechaTotalPendienteAcciones
Cargando...
+
+ + + + + `; + + 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 = ` +
+
+

Detalle

+ +
+
+
Cargando...
+
+
`; + 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 = `No hay facturas de proveedores`; + return; + } + tbody.innerHTML = filteredInvoices.map(inv => ` + + + + + + + ${STATUS_TEXT[inv.status] || inv.status} + + + + ${icons.user} + ${escHtml(inv.supplierName || 'Sin nombre')} + + ${fmtDate(inv.date)} + ${fmt(inv.total)} + + ${fmt(inv.remainToPay)} + + + + + + `).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 = `
No se pudo cargar el detalle: ${escHtml(err.message)}
`; + } + } + + 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 || '—')} ${STATUS_TEXT[inv.status] || inv.status}`; + + detailBody.innerHTML = /*html*/` + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ ${inv.notePublic ? ` +
+ + +
` : ''} +
+ + +
+ ${isDraft ? `` : ''} + ${isUnpaid ? `` : ''} + ${inv.status === 'paid' || isUnpaid ? `` : ''} + ${isDraft ? `` : ''} + ${isDraft ? `` : ''} +
+ + +

Líneas de Factura

+
+ + + + + + + + + ${isDraft ? '' : ''} + + + + ${lines.length === 0 + ? `` + : lines.map(l => ` + + + + + + + ${isDraft ? `` : ''} + + `).join('')} + +
DescripciónCant.P. Unit.IVA %Total
Sin líneas
${escHtml(l.description || '—')}${l.quantity}${fmt(l.unitPrice)}${l.taxRate}%${fmt(l.total)} + + +
+
+ ${isDraft ? ` + + + ` : ''} + + +
+ ${inv.totalHt != null ? `
Base imponible:${fmt(inv.totalHt)}
` : ''} + ${inv.totalTax != null ? `
IVA soportado:${fmt(inv.totalTax)}
` : ''} +
Total:${fmt(inv.total)}
+ ${inv.status === 'paid' ? ` +
+ Pagado:${fmt(inv.total || 0)} +
` : ` +
+ Pendiente:${fmt(Math.max(0, inv.remainToPay || 0))} +
`} +
+ + +

Pagos

+
+ + + + + + + + + + + ${payments.length === 0 + ? `` + : payments.map(p => ` + + + + + + + `).join('')} + +
FechaReferenciaTipoImporte
Sin pagos registrados
${p.paymentDate ? new Date(p.paymentDate).toLocaleDateString('es-ES') : '—'}${escHtml(p.ref || '—')}${escHtml(p.type || '—')}${fmt(p.amount)}
+
+ ${isUnpaid ? ` + + + ` : ''} + `; + + // 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*/` +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ `; + + 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*/` + + + + + + + + + + `; + + 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 = `Error al cargar: ${escHtml(err.message)}`; + 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 = ` + + + + +
0.00 €
+ + `; + 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 = ''; + clients.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name; + supplierSelect.appendChild(opt); + }); + } catch { + supplierSelect.innerHTML = ''; + } + } + + 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; +} diff --git a/doli-front/src/pages/LoginPage.js b/doli-front/src/pages/LoginPage.js new file mode 100755 index 0000000..58baea1 --- /dev/null +++ b/doli-front/src/pages/LoginPage.js @@ -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; +} diff --git a/doli-front/src/pages/SettingsPage.js b/doli-front/src/pages/SettingsPage.js new file mode 100644 index 0000000..8f656e7 --- /dev/null +++ b/doli-front/src/pages/SettingsPage.js @@ -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 = ` +
+

Configuración

+

Preferencias visuales y estado de sesión.

+
+ +
+
+

Apariencia

+
+
+

Modo oscuro

+

Cambia entre tema claro y oscuro.

+
+ +
+
+ +
+

Sesión

+
+
+
+ ${icons.info} + Caducidad del token +
+ - +
+
+
+ ${icons.clock} + Tiempo restante +
+ - +
+
+
+ +
+

Notificaciones

+

+ URL de webhook para notificaciones de cambio de estado de facturas (compatible con Teams y Slack). + Déjalo vacío para desactivar. +

+
+ +
+ + +
+

+
+
+ +
+

VeriFactu

+

+ Conexión con VeriFactu MidAPI para validar el enlace, registrar certificados y preparar el envío de facturas. +

+
+
+ + +
+ +
+ + + +
+

+
+
+
+ `; + + 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; +} \ No newline at end of file diff --git a/doli-front/src/pages/pagesRegistry.js b/doli-front/src/pages/pagesRegistry.js new file mode 100755 index 0000000..7dc0c70 --- /dev/null +++ b/doli-front/src/pages/pagesRegistry.js @@ -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); +} diff --git a/doli-front/src/router.js b/doli-front/src/router.js new file mode 100755 index 0000000..fde9a5f --- /dev/null +++ b/doli-front/src/router.js @@ -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*/` + + ${page.name} + `; + 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 +} diff --git a/doli-front/src/services/apiClient.js b/doli-front/src/services/apiClient.js new file mode 100644 index 0000000..45fac35 --- /dev/null +++ b/doli-front/src/services/apiClient.js @@ -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 = ` +
+

Conexion perdida

+

${message}

+

Seras redirigido al login en 3 segundos.

+ +
+ `; + + 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' }); +} \ No newline at end of file diff --git a/doli-front/src/services/auth.js b/doli-front/src/services/auth.js new file mode 100755 index 0000000..81eafce --- /dev/null +++ b/doli-front/src/services/auth.js @@ -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(); diff --git a/doli-front/src/services/clients.js b/doli-front/src/services/clients.js new file mode 100644 index 0000000..e906e7d --- /dev/null +++ b/doli-front/src/services/clients.js @@ -0,0 +1,21 @@ +import { apiDelete, apiGet, apiPost, apiPut } from './apiClient.js'; + +export async function getClients(limit = 50, page = 1) { + return apiGet(`/api/Clients?limit=${limit}&page=${page}`); +} + +export async function getClientById(id) { + return apiGet(`/api/Clients/${id}`); +} + +export async function createClient(data) { + return apiPost('/api/Clients', data); +} + +export async function updateClient(id, data) { + return apiPut(`/api/Clients/${id}`, data); +} + +export async function deleteClient(id) { + return apiDelete(`/api/Clients/${id}`); +} diff --git a/doli-front/src/services/contacts.js b/doli-front/src/services/contacts.js new file mode 100644 index 0000000..7919d6b --- /dev/null +++ b/doli-front/src/services/contacts.js @@ -0,0 +1,21 @@ +import { apiGet, apiPost, apiPut, apiDelete } from './apiClient.js'; + +export async function getContacts({ limit = 100, page = 1 } = {}) { + return apiGet(`/api/Contacts?limit=${limit}&page=${page}`); +} + +export async function getContactById(id) { + return apiGet(`/api/Contacts/${id}`); +} + +export async function createContact(data) { + return apiPost('/api/Contacts', data); +} + +export async function updateContact(id, data) { + return apiPut(`/api/Contacts/${id}`, data); +} + +export async function deleteContact(id) { + return apiDelete(`/api/Contacts/${id}`); +} diff --git a/doli-front/src/services/icons.js b/doli-front/src/services/icons.js new file mode 100644 index 0000000..7967a8a --- /dev/null +++ b/doli-front/src/services/icons.js @@ -0,0 +1,103 @@ +export const doliLogo = (size = 28) => ` + + + + + + + + + + + +`; + +export const doliLogoFull = (height = 24) => ` + + + + + + + + + + + + Doli +`; + +export const icons = { + dashboard: ``, + + invoices: ``, + + supplierInvoices: ``, + + clients: ``, + + settings: ``, + + logout: ``, + + menu: ``, + + chevron: ``, + + search: ``, + + plus: ``, + + close: ``, + + dollarSign: ``, + + checkCircle: ``, + + clock: ``, + + fileText: ``, + + mail: ``, + + phone: ``, + + mobile: ``, + + user: ``, + + calendar: ``, + + eye: ``, + + lock: ``, + + sort: ``, + + emptyInvoices: ` + + + + + + + +`, + + emptyClients: ` + + + + +`, + + error: ``, + + sun: ``, + + moon: ``, + + info: ``, + + bank: `` +}; \ No newline at end of file diff --git a/doli-front/src/services/invoices.js b/doli-front/src/services/invoices.js new file mode 100644 index 0000000..acd0a77 --- /dev/null +++ b/doli-front/src/services/invoices.js @@ -0,0 +1,70 @@ +import { apiDelete, apiGet, apiPatch, apiPost, apiPut, apiRequest } from './apiClient.js'; + +export async function getInvoiceById(id) { + return apiGet(`/api/Invoices/${id}`); +} + +export async function updateInvoice(id, data) { + await apiPut(`/api/Invoices/${id}`, data); + return true; +} + +export async function validateInvoice(id) { + await apiPost(`/api/Invoices/${id}/validate`); + return true; +} + +export async function updateInvoiceStatus(id, status) { + await apiPatch(`/api/Invoices/${id}/status`, { status }); + return true; +} + +export async function addInvoiceLine(id, lineData) { + return apiPost(`/api/Invoices/${id}/lines`, lineData); +} + +export async function deleteInvoiceLine(invoiceId, lineId) { + await apiDelete(`/api/Invoices/${invoiceId}/lines/${lineId}`); + return true; +} + +export async function updateInvoiceLine(invoiceId, lineId, data) { + await apiPut(`/api/Invoices/${invoiceId}/lines/${lineId}`, data); + return true; +} + +export async function addPayment(invoiceId, paymentData) { + return apiPost(`/api/Invoices/${invoiceId}/payments`, paymentData); +} + +export async function getPayments(invoiceId) { + return apiGet(`/api/Invoices/${invoiceId}/payments`); +} + +export async function downloadInvoicePdf(invoiceNumber) { + const response = await apiRequest(`/api/document/invoice/${encodeURIComponent(invoiceNumber)}/pdf`, { + method: 'GET', + headers: { Accept: 'application/pdf' }, + responseType: 'raw' + }); + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/pdf')) { + const nonPdfBody = await response.text().catch(() => ''); + throw new Error(nonPdfBody || 'La API no devolvió un PDF válido.'); + } + + return await response.blob(); +} + +export async function deleteInvoice(id) { + await apiDelete(`/api/Invoices/${id}`); + return true; +} + +export async function getInvoiceTemplates() { + return apiGet('/api/Invoices/templates'); +} + +export async function getTemplateById(id) { + return apiGet(`/api/Invoices/templates/${id}`); +} diff --git a/doli-front/src/services/pagesConfig.js b/doli-front/src/services/pagesConfig.js new file mode 100644 index 0000000..b16aa59 --- /dev/null +++ b/doli-front/src/services/pagesConfig.js @@ -0,0 +1,101 @@ +import { icons } from './icons.js'; +import { renderDashboard } from '../pages/DashboardPage.js'; +import { renderFacturasPage } from '../pages/FacturasPage.js'; +import { renderFacturasProveedoresPage } from '../pages/FacturasProveedoresPage.js'; +import { renderCreateInvoicePage } from '../pages/CreateInvoicePage.js'; +import { renderClientesPage } from '../pages/ClientesPage.js'; +import { renderContactsPage } from '../pages/ContactsPage.js'; +import { renderBancoPage } from '../pages/BancoPage.js'; +import { renderSettingsPage } from '../pages/SettingsPage.js'; + +export const pagesConfig = [ + { + _file: 'DashboardPage', + route: 'dashboard', + name: 'Dashboard', + order: 1, + icon: icons.dashboard, + voicePatterns: ['dashboard', 'inicio', 'ir al inicio', 'ir al dashboard', 'resumen', 'home', 'panel', 'principal'], + requiresAuth: true, + showInSidebar: true, + render: renderDashboard, + }, + { + _file: 'FacturasPage', + route: 'invoices', + name: 'Facturas', + order: 2, + icon: icons.invoices, + voicePatterns: ['facturas', 'ver facturas', 'ir a facturas', 'lista facturas', 'mis facturas', 'listado facturas'], + requiresAuth: true, + showInSidebar: true, + render: renderFacturasPage, + }, + { + _file: 'FacturasProveedoresPage', + route: 'facturas-proveedores', + name: 'Fact. Proveedores', + order: 3, + icon: icons.supplierInvoices, + voicePatterns: ['facturas proveedores', 'proveedores', 'facturas de proveedor', 'compras'], + requiresAuth: true, + showInSidebar: true, + render: renderFacturasProveedoresPage, + }, + { + _file: 'CreateInvoicePage', + route: 'create-invoice', + name: 'Nueva Factura', + order: 4, + icon: icons.plus, + voicePatterns: ['nueva factura', 'crear factura', 'factura nueva', 'añadir factura'], + requiresAuth: true, + showInSidebar: false, + render: renderCreateInvoicePage, + }, + { + _file: 'ClientesPage', + route: 'clients', + name: 'Terceros', + order: 5, + icon: icons.clients, + voicePatterns: ['terceros', 'ver terceros', 'ir a terceros', 'clientes', 'ver clientes'], + requiresAuth: true, + showInSidebar: true, + render: renderClientesPage, + }, + { + _file: 'ContactsPage', + route: 'contacts', + name: 'Contactos', + order: 6, + icon: icons.user, + voicePatterns: ['contactos', 'ver contactos', 'ir a contactos', 'lista contactos'], + requiresAuth: true, + showInSidebar: true, + render: renderContactsPage, + }, + { + _file: 'BancoPage', + route: 'banco', + name: 'Banco', + order: 7, + icon: icons.bank, + voicePatterns: ['banco', 'ir al banco', 'cuentas', 'cuentas bancarias', 'movimientos'], + requiresAuth: true, + showInSidebar: true, + render: renderBancoPage, + }, + { + _file: 'SettingsPage', + route: 'settings', + name: 'Configuración', + order: 8, + icon: icons.settings, + voicePatterns: ['configuración', 'configuracion', 'ajustes', 'settings', 'preferencias'], + requiresAuth: true, + showInSidebar: true, + render: renderSettingsPage, + }, + { _file: 'LoginPage' }, +]; diff --git a/doli-front/src/services/session.js b/doli-front/src/services/session.js new file mode 100644 index 0000000..3b8be00 --- /dev/null +++ b/doli-front/src/services/session.js @@ -0,0 +1,186 @@ +import { auth } from './auth.js'; + +const SESSION_TIMEOUT_MS = Number(import.meta.env.VITE_SESSION_TIMEOUT_MS || 15 * 60 * 1000); +const SESSION_WARNING_MS = Number(import.meta.env.VITE_SESSION_WARNING_MS || 60 * 1000); + +let inactivityTimer = null; +let warningTimer = null; +let warningCountdownTimer = null; +let warningEndsAt = null; +let warningOverlay = null; +let hasInitialized = false; +let fetchIsPatched = false; + +function isAuthenticated() { + return Boolean(localStorage.getItem('token')); +} + +function clearTimers() { + if (inactivityTimer) { + clearTimeout(inactivityTimer); + inactivityTimer = null; + } + + if (warningTimer) { + clearTimeout(warningTimer); + warningTimer = null; + } + + if (warningCountdownTimer) { + clearInterval(warningCountdownTimer); + warningCountdownTimer = null; + } +} + +function removeWarningOverlay() { + if (warningOverlay) { + warningOverlay.remove(); + warningOverlay = null; + } + warningEndsAt = null; + if (warningCountdownTimer) { + clearInterval(warningCountdownTimer); + warningCountdownTimer = null; + } +} + +function getRemainingSeconds() { + if (!warningEndsAt) return 0; + return Math.max(0, Math.ceil((warningEndsAt - Date.now()) / 1000)); +} + +function forceSessionExpiration(message = 'Tu sesion ha expirado. Inicia sesion de nuevo.') { + removeWarningOverlay(); + clearTimers(); + auth.logout(); + sessionStorage.setItem('session-expired-message', message); + window.location.hash = '#login'; +} + +function showWarningOverlay() { + if (!isAuthenticated()) return; + + removeWarningOverlay(); + + const overlay = document.createElement('div'); + overlay.className = 'session-warning-overlay'; + overlay.innerHTML = ` + + `; + + const continueBtn = overlay.querySelector('.session-warning-continue'); + const logoutBtn = overlay.querySelector('.session-warning-logout'); + const countdownEl = overlay.querySelector('#session-warning-countdown'); + + continueBtn?.addEventListener('click', () => { + removeWarningOverlay(); + resetSessionTimers(); + }); + + logoutBtn?.addEventListener('click', () => { + forceSessionExpiration('Sesion finalizada por inactividad.'); + }); + + warningEndsAt = Date.now() + SESSION_WARNING_MS; + + const updateCountdown = () => { + const seconds = getRemainingSeconds(); + const mins = String(Math.floor(seconds / 60)).padStart(2, '0'); + const secs = String(seconds % 60).padStart(2, '0'); + if (countdownEl) { + countdownEl.textContent = `${mins}:${secs}`; + } + + if (seconds <= 0) { + forceSessionExpiration(); + } + }; + + updateCountdown(); + warningCountdownTimer = setInterval(updateCountdown, 1000); + + warningOverlay = overlay; + document.body.appendChild(overlay); +} + +function resetSessionTimers() { + clearTimers(); + + if (!isAuthenticated()) { + removeWarningOverlay(); + return; + } + + const warningDelay = Math.max(SESSION_TIMEOUT_MS - SESSION_WARNING_MS, 1000); + warningTimer = setTimeout(showWarningOverlay, warningDelay); + inactivityTimer = setTimeout(() => forceSessionExpiration(), SESSION_TIMEOUT_MS); +} + +function patchFetchFor401AndActivity() { + if (fetchIsPatched) return; + + const originalFetch = window.fetch.bind(window); + + window.fetch = async (...args) => { + const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0]?.url || ''); + const isLoginEndpoint = requestUrl.includes('/api/Auth/login'); + + if (!isLoginEndpoint && isAuthenticated()) { + resetSessionTimers(); + } + + const response = await originalFetch(...args); + + if (response.status === 401 && !isLoginEndpoint && isAuthenticated()) { + forceSessionExpiration('Tu sesion ha expirado o ya no es valida. Inicia sesion de nuevo.'); + } + + return response; + }; + + fetchIsPatched = true; +} + +function bindActivityListeners() { + const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']; + + const onActivity = () => { + if (isAuthenticated()) { + resetSessionTimers(); + } + }; + + events.forEach(eventName => { + window.addEventListener(eventName, onActivity, { passive: true }); + }); +} + +function bindAuthListeners() { + window.addEventListener('auth:login', () => { + removeWarningOverlay(); + resetSessionTimers(); + }); + + window.addEventListener('auth:logout', () => { + removeWarningOverlay(); + clearTimers(); + }); +} + +export function initSessionManager() { + if (hasInitialized) return; + + patchFetchFor401AndActivity(); + bindActivityListeners(); + bindAuthListeners(); + resetSessionTimers(); + + hasInitialized = true; +} diff --git a/doli-front/src/services/setup.js b/doli-front/src/services/setup.js new file mode 100644 index 0000000..5bef859 --- /dev/null +++ b/doli-front/src/services/setup.js @@ -0,0 +1,25 @@ +import { apiGet } from './apiClient.js'; + +export async function getPaymentTypes() { + return apiGet('/api/Setup/payment-types'); +} + +export async function getPaymentTerms() { + return apiGet('/api/Setup/payment-terms'); +} + +export async function getCompany() { + return apiGet('/api/Setup/company'); +} + +export async function getCountries() { + return apiGet('/api/Setup/countries'); +} + +export async function getCivilities() { + return apiGet('/api/Setup/civilities'); +} + +export async function getContactTypes() { + return apiGet('/api/Setup/contact-types'); +} diff --git a/doli-front/src/services/supplierInvoices.js b/doli-front/src/services/supplierInvoices.js new file mode 100644 index 0000000..eecced4 --- /dev/null +++ b/doli-front/src/services/supplierInvoices.js @@ -0,0 +1,47 @@ +import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from './apiClient.js'; + +export async function getSupplierInvoices({ limit = 50, page = 1, status } = {}) { + let url = `/api/SupplierInvoices?limit=${limit}&page=${page}`; + if (status) url += `&status=${encodeURIComponent(status)}`; + return apiGet(url); +} + +export async function getSupplierInvoiceById(id) { + return apiGet(`/api/SupplierInvoices/${id}`); +} + +export async function createSupplierInvoice(data) { + return apiPost('/api/SupplierInvoices', data); +} + +export async function updateSupplierInvoice(id, data) { + return apiPut(`/api/SupplierInvoices/${id}`, data); +} + +export async function deleteSupplierInvoice(id) { + return apiDelete(`/api/SupplierInvoices/${id}`); +} + +export async function changeSupplierInvoiceStatus(id, status) { + return apiPost(`/api/SupplierInvoices/${id}/status`, { status }); +} + +export async function addSupplierInvoiceLine(id, line) { + return apiPost(`/api/SupplierInvoices/${id}/lines`, line); +} + +export async function updateSupplierInvoiceLine(id, lineId, line) { + return apiPut(`/api/SupplierInvoices/${id}/lines/${lineId}`, line); +} + +export async function deleteSupplierInvoiceLine(id, lineId) { + return apiDelete(`/api/SupplierInvoices/${id}/lines/${lineId}`); +} + +export async function getSupplierInvoicePayments(id) { + return apiGet(`/api/SupplierInvoices/${id}/payments`); +} + +export async function addSupplierInvoicePayment(id, payment) { + return apiPost(`/api/SupplierInvoices/${id}/payments`, payment); +} diff --git a/doli-front/src/services/theme.js b/doli-front/src/services/theme.js new file mode 100644 index 0000000..73476f6 --- /dev/null +++ b/doli-front/src/services/theme.js @@ -0,0 +1,78 @@ +const THEME_KEY = 'theme-preference'; + +function getStoredUser() { + try { + const raw = localStorage.getItem('user'); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +function getUserThemeKey() { + const user = getStoredUser(); + if (!user) return null; + + const identity = user.id || user.userId || user.email || user.username || user.identifier; + if (!identity) return null; + + return `${THEME_KEY}:user:${String(identity).toLowerCase()}`; +} + +function setRootTheme(theme) { + const root = document.documentElement; + const finalTheme = theme === 'dark' ? 'dark' : 'light'; + root.setAttribute('data-theme', finalTheme); + root.style.colorScheme = finalTheme; +} + +function getSystemPrefersDark() { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +export function getSavedTheme() { + const userKey = getUserThemeKey(); + const saved = userKey ? localStorage.getItem(userKey) : localStorage.getItem(THEME_KEY); + if (saved === 'dark' || saved === 'light') return saved; + return 'light'; +} + +export function applyTheme(theme) { + const finalTheme = theme === 'dark' ? 'dark' : 'light'; + setRootTheme(finalTheme); + + const userKey = getUserThemeKey(); + if (userKey) { + localStorage.setItem(userKey, finalTheme); + return; + } + + localStorage.setItem(THEME_KEY, finalTheme); +} + +export function initTheme() { + applyTheme(getSavedTheme()); +} + +export function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') || getSavedTheme(); + const next = current === 'dark' ? 'light' : 'dark'; + applyTheme(next); + return next; +} + +export function isDarkTheme() { + const current = document.documentElement.getAttribute('data-theme') || getSavedTheme(); + return current === 'dark'; +} + +// Login should remain neutral and should not persist or inherit cross-user preferences. +export function applyLoginTheme() { + setRootTheme('light'); +} + +export function applySavedTheme() { + setRootTheme(getSavedTheme()); +} diff --git a/doli-front/src/services/toast.js b/doli-front/src/services/toast.js new file mode 100644 index 0000000..da48f90 --- /dev/null +++ b/doli-front/src/services/toast.js @@ -0,0 +1,54 @@ +let container = null; + +function getContainer() { + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container'; + document.body.appendChild(container); + } + return container; +} + +const ICONS = { + success: ``, + error: ``, + warning: ``, + info: ``, +}; + +export function showToast(message, type = 'info', duration = 3500) { + const c = getContainer(); + const toast = document.createElement('div'); + toast.className = `toast toast--${type}`; + toast.innerHTML = ` + ${ICONS[type] ?? ICONS.info} + ${message} + + `; + + c.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('toast--visible')); + + const remove = () => { + if (!toast.isConnected) return; + toast.classList.remove('toast--visible'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + }; + + const timer = setTimeout(remove, duration); + toast.querySelector('.toast-close').addEventListener('click', () => { + clearTimeout(timer); + remove(); + }); + + return remove; +} + +export const toast = { + success: (msg, d) => showToast(msg, 'success', d), + error: (msg, d) => showToast(msg, 'error', d), + warning: (msg, d) => showToast(msg, 'warning', d), + info: (msg, d) => showToast(msg, 'info', d), +}; diff --git a/doli-front/src/services/verifactu.js b/doli-front/src/services/verifactu.js new file mode 100644 index 0000000..f96c5c1 --- /dev/null +++ b/doli-front/src/services/verifactu.js @@ -0,0 +1,94 @@ +import forge from 'node-forge'; +import { apiGet, apiPost } from './apiClient.js'; + +let cachedPublicKeyPem = null; + +const VERIFACTU_ERROR_MESSAGES = { + missing_fields: 'Faltan campos obligatorios para registrar el certificado.', + invalid_json: 'La peticion enviada a VeriFactu no tiene un JSON valido.', + invalid_password_encrypted: 'La contraseña cifrada no tiene un formato valido.', + decrypt_failed: 'No se pudo descifrar la contraseña del certificado.', + file_not_found: 'No se encontro el archivo del certificado.', + invalid_password_or_format: 'La contraseña del .p12 es incorrecta o el archivo no es valido.', + certificate_not_yet_valid: 'El certificado aun no es valido por fecha.', + certificate_expired: 'El certificado ha caducado.', + temp_storage_failed: 'No se pudo guardar el certificado temporalmente.', + storage_failed: 'No se pudo guardar el certificado de forma permanente.', + token_generation_failed: 'No se pudo generar el token de sesion del certificado.', + hash_storage_error: 'Error leyendo el hash previo de facturacion.', + hash_save_error: 'Error guardando el hash de la factura.', + internal_server_error: 'VeriFactu devolvio un error interno del servidor.' +}; + +export async function getVeriFactuHealth() { + return apiGet('/api/VeriFactu/health', { auth: false }); +} + +export async function getVeriFactuPublicKey() { + return apiGet('/api/VeriFactu/public-key', { auth: false }); +} + +async function getVeriFactuPublicKeyPem() { + if (cachedPublicKeyPem) { + return cachedPublicKeyPem; + } + + const response = await getVeriFactuPublicKey(); + if (!response?.public_key) { + throw new Error('La API no devolvio la clave publica de VeriFactu'); + } + + cachedPublicKeyPem = atob(response.public_key); + return cachedPublicKeyPem; +} + +export async function encryptVeriFactuPassword(password) { + if (!password) { + throw new Error('La contraseña del certificado no puede estar vacia'); + } + + console.log('[ENCRYPT] Input password:', password); + const publicKeyPem = await getVeriFactuPublicKeyPem(); + console.log('[ENCRYPT] Got public key'); + const publicKey = forge.pki.publicKeyFromPem(publicKeyPem); + console.log('[ENCRYPT] Parsed public key'); + const encryptedBytes = publicKey.encrypt(forge.util.encodeUtf8(password), 'RSAES-PKCS1-V1_5'); + console.log('[ENCRYPT] RSA encrypted, bytes length:', encryptedBytes.length); + const result = forge.util.encode64(encryptedBytes); + console.log('[ENCRYPT] Base64 encoded, result length:', result.length); + return result; +} + +export async function getVeriFactuFormats() { + return apiGet('/api/VeriFactu/formats', { auth: false }); +} + +export async function registerVeriFactuCertificate(payload) { + return apiPost('/api/VeriFactu/certificates/register', payload); +} + +export function formatVeriFactuError(errorLike) { + const raw = String(errorLike?.message || errorLike || '').trim(); + if (!raw) { + return 'Error desconocido en VeriFactu.'; + } + + // Preserve backend text format for these errors to avoid changing JSON/detail structure. + if (raw === 'invalid_json' || raw.startsWith('validation_failed') || raw.startsWith('aeat_error:') || raw.startsWith('aeat_fault:')) { + return raw; + } + + if (VERIFACTU_ERROR_MESSAGES[raw]) { + return VERIFACTU_ERROR_MESSAGES[raw]; + } + + return raw; +} + +export async function sendVeriFactuInvoice(payload) { + return apiPost('/api/VeriFactu/facturas', payload); +} + +export async function cancelVeriFactuInvoice(payload) { + return apiPost('/api/VeriFactu/facturas/anular', payload); +} \ No newline at end of file diff --git a/doli-front/src/services/voiceInput.js b/doli-front/src/services/voiceInput.js new file mode 100644 index 0000000..9ed79e7 --- /dev/null +++ b/doli-front/src/services/voiceInput.js @@ -0,0 +1,244 @@ +import { pipeline } from '@huggingface/transformers'; + +// ── Iconos ──────────────────────────────────────────────────────────────────── + +const MIC_ICON = /*html*/` + + + + + + `; + +const STOP_ICON = /*html*/` + + + `; + +const SPIN_ICON = /*html*/` + + + `; + +// ── Modelo (singleton) ──────────────────────────────────────────────────────── + +let transcriberPromise = null; + +function loadModel(onProgress) { + if (!transcriberPromise) { + transcriberPromise = pipeline( + 'automatic-speech-recognition', + 'Xenova/whisper-base', + { progress_callback: onProgress } + ).catch(err => { + transcriberPromise = null; // permitir reintento + throw err; + }); + } + return transcriberPromise; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function showTip(btn, text, duration = 3000) { + btn.querySelectorAll('.voice-tip').forEach(t => t.remove()); + const tip = document.createElement('span'); + tip.className = 'voice-tip'; + tip.textContent = text; + btn.appendChild(tip); + if (duration > 0) setTimeout(() => tip.remove(), duration); + return tip; +} + +async function startMediaRecorder(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; + const SILENCE_DELAY_MS = 1800; + const MIN_RECORD_MS = 700; + + 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(); + const ctx = new AudioContext({ sampleRate: 16000 }); + const audioBuffer = await ctx.decodeAudioData(arrayBuffer); + await ctx.close(); + return audioBuffer.getChannelData(0); // mono Float32Array a 16kHz +} + +// ── API pública ─────────────────────────────────────────────────────────────── + +/** + * Adjunta un botón de voz (Whisper local) a un input. + * Devuelve el botón, o null si el navegador no soporta MediaRecorder. + */ +export function attachVoiceInput(inputEl, { lang = 'es' } = {}) { + if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { + console.warn('[VoiceInput] MediaRecorder no disponible en este navegador.'); + return null; + } + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'voice-btn'; + btn.setAttribute('aria-label', 'Buscar por voz'); + btn.title = 'Buscar por voz (Whisper local)'; + btn.innerHTML = MIC_ICON; + + let mediaRecorder = null; // { stop() } + let state = 'idle'; // idle | recording | processing + + const setState = (s) => { + state = s; + btn.className = `voice-btn voice-btn--${s}`; + if (s === 'idle') { btn.innerHTML = MIC_ICON; btn.disabled = false; } + if (s === 'recording') { btn.innerHTML = STOP_ICON; btn.disabled = false; } + if (s === 'processing') { btn.innerHTML = SPIN_ICON; btn.disabled = true; } + }; + + btn.addEventListener('click', async () => { + // ── Detener grabación ────────────────────────────────────────────────── + if (state === 'recording') { + showTip(btn, 'Procesando…', 0); + setState('processing'); + const blob = await mediaRecorder.stop(); + mediaRecorder = null; + await transcribe(blob); + return; + } + + if (state !== 'idle') return; + + // ── Iniciar grabación ────────────────────────────────────────────────── + try { + mediaRecorder = await startMediaRecorder(async () => { + if (state !== 'recording') return; + showTip(btn, 'Procesando…', 0); + setState('processing'); + const blob = await mediaRecorder.stop(); + mediaRecorder = null; + await transcribe(blob); + }); + setState('recording'); + showTip(btn, 'Hablando… para solo'); + + // Pre-carga el modelo en paralelo mientras el usuario habla + if (!transcriberPromise) loadModel(); + } catch (e) { + console.error('[VoiceInput] Error al acceder al micrófono:', e); + showTip(btn, 'Sin acceso al micrófono'); + setState('idle'); + } + }); + + async function transcribe(blob) { + try { + // Carga / espera el modelo (primera vez descarga ~40 MB, se cachea) + const tipLoad = showTip(btn, 'Cargando modelo…', 0); + const transcriber = await loadModel(progress => { + if (progress.status === 'progress') { + const pct = Math.round(progress.progress ?? 0); + btn.title = `Cargando modelo: ${pct}% (~140 MB primera vez)`; + } + }); + tipLoad.remove(); + + showTip(btn, 'Transcribiendo…', 0); + const audioData = await blobToFloat32(blob); + const result = await transcriber(audioData, { language: lang, task: 'transcribe' }); + const text = (result.text ?? '').trim(); + + if (text) { + inputEl.value = text; + inputEl.dispatchEvent(new Event('input', { bubbles: true })); + inputEl.focus(); + showTip(btn, `"${text}"`); + } else { + showTip(btn, 'No se detectó texto'); + } + } catch (e) { + console.error('[VoiceInput] Error en transcripción:', e); + showTip(btn, 'Error al transcribir'); + } finally { + btn.title = 'Buscar por voz'; + setState('idle'); + } + } + + return btn; +} + +/** + * Envuelve el input en .search-wrapper y adjunta el botón de voz. + */ +export function wrapWithVoice(inputEl, options = {}) { + const voiceBtn = attachVoiceInput(inputEl, options); + if (!voiceBtn) return; + + const wrapper = document.createElement('div'); + wrapper.className = 'search-wrapper'; + inputEl.parentNode.insertBefore(wrapper, inputEl); + wrapper.appendChild(inputEl); + wrapper.appendChild(voiceBtn); +} diff --git a/doli-front/src/style.css b/doli-front/src/style.css new file mode 100755 index 0000000..ce8047d --- /dev/null +++ b/doli-front/src/style.css @@ -0,0 +1,464 @@ +/* ============================================ + Design Tokens + ============================================ */ +:root { + /* Typography */ + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; + font-weight: 400; + + /* Color tokens */ + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-300: #d1d5db; + --gray-400: #9ca3af; + --gray-500: #6b7280; + --gray-600: #4b5563; + --gray-700: #374151; + --gray-800: #1f2937; + --gray-900: #111827; + + --blue-50: #eff6ff; + --blue-100: #dbeafe; + --blue-500: #3b82f6; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + + --green-50: #f0fdf4; + --green-100: #dcfce7; + --green-600: #16a34a; + --green-700: #15803d; + + --amber-50: #fffbeb; + --amber-100: #fef3c7; + --amber-600: #d97706; + + --red-50: #fef2f2; + --red-100: #fee2e2; + --red-500: #ef4444; + --red-600: #dc2626; + --red-700: #b91c1c; + + --primary: var(--blue-600); + --primary-hover: var(--blue-700); + --primary-light: rgba(37, 99, 235, 0.06); + --primary-subtle: rgba(37, 99, 235, 0.08); + --success: var(--green-600); + --success-hover: var(--green-700); + --warning: var(--amber-600); + --warning-hover: #a16207; + --danger: var(--red-500); + --danger-hover: var(--red-600); + + --card-bg: #ffffff; + --border-color: var(--gray-200); + --border-subtle: var(--gray-100); + --text-primary: var(--gray-900); + --text-secondary: var(--gray-500); + --text-tertiary: var(--gray-400); + --bg-page: var(--gray-50); + --bg-subtle: var(--gray-100); + + /* Spacing (4px grid) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + + /* Radius */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + + /* Shadows */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.06), 0 4px 6px -4px rgba(0, 0, 0, 0.04); + + /* Transitions */ + --transition-fast: 120ms ease; + --transition-base: 150ms ease; + + color-scheme: light; + color: var(--text-primary); + background-color: var(--bg-page); + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Large / Presentation Displays ── */ +@media (min-width: 1600px) { + :root { + font-size: 15px; + } +} + +@media (min-width: 2200px) { + :root { + font-size: 16px; + } +} + +:root[data-theme='dark'] { + --gray-50: #0f172a; + --gray-100: #111827; + --gray-200: #1f2937; + --gray-300: #374151; + --gray-400: #94a3b8; + --gray-500: #cbd5e1; + --gray-600: #e2e8f0; + --gray-700: #f1f5f9; + --gray-800: #f8fafc; + --gray-900: #ffffff; + + --card-bg: #0b1220; + --border-color: #1f2a3d; + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --bg-page: #070d18; + --bg-subtle: #0f172a; + --border-subtle: #1f2a3d; + color-scheme: dark; +} + +:root[data-theme='dark'] .status-paid, +:root[data-theme='dark'] .status-badge-client.status-active { + background-color: #15803d; + color: #fff; +} + +:root[data-theme='dark'] .status-overdue, +:root[data-theme='dark'] .status-unpaid, +:root[data-theme='dark'] .status-badge-client.status-inactive { + background-color: #c2410c; + color: #fff; +} + +:root[data-theme='dark'] .status-pending { + background-color: #b45309; + color: #fff; +} + +:root[data-theme='dark'] .status-draft, +:root[data-theme='dark'] .status-badge-client.status-unknown { + background-color: #475569; + color: #fff; +} + +:root[data-theme='dark'] .status-canceled { + background-color: #991b1b; + color: #fff; + opacity: 0.75; +} + +:root[data-theme='dark'] .status-validated { + background-color: #1d4ed8; + color: #fff; +} + +:root[data-theme='dark'] .invoices-table thead, +:root[data-theme='dark'] .clients-table thead, +:root[data-theme='dark'] .db-monitoring-table thead th { + background: rgba(15, 23, 42, 0.85); +} + +:root[data-theme='dark'] .db-recent-item:hover, +:root[data-theme='dark'] .invoices-table tbody tr:hover, +:root[data-theme='dark'] .clients-table tbody tr:hover { + background-color: rgba(30, 41, 59, 0.65); +} + +/* ============================================ + Reset & Base + ============================================ */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +a { + font-weight: 500; + color: var(--primary); + text-decoration: none; +} + +a:hover { + color: var(--primary-hover); +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-weight: 600; + line-height: 1.3; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +h1 { + font-size: 1.5rem; +} + +h2 { + font-size: 1.25rem; +} + +h3 { + font-size: 1rem; +} + +#app { + width: 100%; + min-height: 100vh; +} + +/* ============================================ + Buttons + ============================================ */ +button { + border-radius: var(--radius-md); + border: 1px solid transparent; + padding: var(--space-2) var(--space-4); + font-size: 14px; + font-weight: 500; + font-family: inherit; + background-color: var(--primary); + color: white; + cursor: pointer; + transition: background-color var(--transition-fast); + line-height: 1.4; +} + +button:hover { + background-color: var(--primary-hover); +} + +button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ============================================ + Status Badges + ============================================ */ +.status-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 99px; + font-size: 11px; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; + letter-spacing: 0.02em; + border: none; +} + +.status-pending { + background-color: #d97706; + color: #fff; +} + +.status-paid { + background-color: #16a34a; + color: #fff; +} + +.status-overdue, +.status-unpaid { + background-color: #ea580c; + color: #fff; +} + +.status-draft { + background-color: #64748b; + color: #fff; +} + +.status-validated { + background-color: #2563eb; + color: #fff; +} + +.status-canceled { + background-color: #dc2626; + color: #fff; + opacity: 0.7; +} + +/* ============================================ + Amount & Stats + ============================================ */ +.amount-display { + font-variant-numeric: tabular-nums; + font-weight: 500; + color: var(--text-primary); +} + +.card-stat { + background: var(--card-bg); + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); +} + +.card-stat-label { + color: var(--text-secondary); + font-size: 12px; + margin-bottom: var(--space-1); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.card-stat-value { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.card-stat.positive .card-stat-value { + color: var(--success); +} + +.card-stat.negative .card-stat-value { + color: var(--danger); +} + + +/* ============================================ + Voice Input + ============================================ */ +.search-wrapper { + position: relative; + display: flex; + align-items: center; + flex: 1; + max-width: 320px; +} + +.search-wrapper .search-input { + width: 100%; + max-width: 100%; + flex: none; + padding-right: 34px; +} + +.clientes-filters .search-wrapper { + max-width: 400px; +} + +.voice-btn { + position: absolute; + right: 6px; + background: none; + border: none; + padding: 4px; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--gray-400); + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + transition: color var(--transition-fast), background-color var(--transition-fast); + flex-shrink: 0; +} + +.voice-btn:hover:not(:disabled) { + color: var(--primary); + background: var(--blue-50); + border-color: transparent; +} + +.voice-btn:disabled { + cursor: default; + opacity: 0.7; +} + +/* Estado: grabando */ +.voice-btn--recording { + color: var(--red-500); + animation: voice-pulse 1.1s ease-in-out infinite; +} + +/* Estado: procesando */ +.voice-btn--processing .voice-spin { + transform-origin: center; + animation: voice-spin 0.8s linear infinite; +} + +@keyframes voice-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(1.25); } +} + +@keyframes voice-spin { + to { transform: rotate(360deg); } +} + +/* Tooltip flotante del botón de voz */ +.voice-tip { + position: absolute; + bottom: calc(100% + 7px); + left: 50%; + transform: translateX(-50%); + background: var(--gray-900); + color: #fff; + font-size: 11px; + white-space: nowrap; + padding: 3px 8px; + border-radius: 4px; + pointer-events: none; + z-index: 9999; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; +} + +.logout-button { + background-color: transparent; + color: var(--text-secondary); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: color var(--transition-fast), border-color var(--transition-fast); +} + +.logout-button:hover { + color: var(--danger); + border-color: var(--danger); + background-color: var(--red-50); +} diff --git a/doli-front/src/styles/banco.css b/doli-front/src/styles/banco.css new file mode 100644 index 0000000..8a88411 --- /dev/null +++ b/doli-front/src/styles/banco.css @@ -0,0 +1,685 @@ +/* ============================================ + Banco Page + ============================================ */ + +.banco-page { + padding: var(--space-6); + max-width: 1200px; + margin: 0 auto; +} + +.banco-header { + margin-bottom: var(--space-5); +} + +.banco-header h1 { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 var(--space-1); +} + +.banco-subtitle { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +/* ── Summary cards ── */ +.banco-summary-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-3); + margin-bottom: var(--space-5); +} + +.banco-stat-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: var(--space-4); + display: flex; + align-items: center; + gap: var(--space-3); + box-shadow: var(--shadow-xs); +} + +.banco-stat-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.banco-stat-card--balance .banco-stat-icon { background: var(--blue-50); color: var(--primary); } +.banco-stat-card--income .banco-stat-icon { background: var(--green-50); color: var(--success); } +.banco-stat-card--expense .banco-stat-icon { background: var(--red-50); color: var(--danger); } +.banco-stat-card--accounts .banco-stat-icon { background: var(--amber-50); color: var(--warning); } + +.banco-stat-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.banco-stat-label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.banco-stat-value { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Content grid ── */ +.banco-content-grid { + display: grid; + grid-template-columns: 1fr 1.6fr; + gap: var(--space-4); +} + +.banco-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xs); + overflow: hidden; +} + +.banco-card-header { + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.banco-card-header h2 { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.btn-banco-new { + background: var(--blue-600) !important; + color: white !important; + border: none !important; + border-radius: var(--radius-md); + padding: 5px 12px; + font-size: 12px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 0.15s; +} +.btn-banco-new:hover { background: var(--blue-700) !important; } + +/* ── Accounts list ── */ +.banco-accounts-list { + padding: var(--space-2) 0; +} + +.banco-account-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-5); + cursor: pointer; + transition: background-color 0.15s, border-left-color 0.15s; + border-left: 3px solid transparent; +} + +.banco-account-item:hover { + background: var(--gray-50); +} + +.banco-account-item--active { + background: var(--blue-50) !important; + border-left-color: var(--primary); +} + +:root[data-theme='dark'] .banco-account-item:hover { background: rgba(30,58,110,0.2); } +:root[data-theme='dark'] .banco-account-item--active { background: rgba(37,99,235,0.12) !important; } + +.banco-account-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + background: var(--blue-50); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; +} + +.banco-account-item--active .banco-account-icon { + background: var(--primary); + color: white; +} + +.banco-account-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.banco-account-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.banco-account-sub { + font-size: 11px; + color: var(--text-secondary); + font-family: ui-monospace, 'Cascadia Code', monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.banco-account-right { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +.banco-account-balance { + font-size: 13px; + font-weight: 700; + color: var(--success); + font-variant-numeric: tabular-nums; +} + +.banco-account-balance--negative { color: var(--danger); } + +.banco-account-item--closed { + opacity: 0.55; +} + +.banco-account-closed-badge { + display: inline-block; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--gray-500); + background: var(--gray-100); + border: 1px solid var(--gray-200); + border-radius: 3px; + padding: 1px 5px; + margin-left: 6px; + vertical-align: middle; +} + +:root[data-theme='dark'] .banco-account-closed-badge { + color: var(--gray-400); + background: rgba(255,255,255,0.07); + border-color: rgba(255,255,255,0.12); +} + +.banco-account-arrow { + color: var(--gray-300); + transition: color 0.15s, transform 0.15s; +} + +.banco-account-item:hover .banco-account-arrow, +.banco-account-item--active .banco-account-arrow { + color: var(--primary); + transform: translateX(2px); +} + +.btn-account-edit { + display: none; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: var(--radius-sm); + background: transparent !important; + border: 1px solid var(--border-color) !important; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + flex-shrink: 0; + padding: 0 !important; +} + +.btn-account-edit svg { + stroke: var(--gray-500); + transition: stroke 0.15s; + display: block; +} + +.banco-account-item:hover .btn-account-edit, +.banco-account-item--active .btn-account-edit { + display: inline-flex; +} + +.btn-account-edit:hover { + background: var(--blue-50) !important; + border-color: var(--primary) !important; +} + +.btn-account-edit:hover svg { + stroke: var(--primary); +} + +:root[data-theme='dark'] .btn-account-edit svg { stroke: var(--gray-400); } +:root[data-theme='dark'] .btn-account-edit:hover { background: rgba(37,99,235,0.15) !important; } +:root[data-theme='dark'] .btn-account-edit:hover svg { stroke: var(--blue-400, #60a5fa); } + +/* ── Back button in tx header ── */ +.btn-banco-back { + display: inline-flex; + align-items: center; + gap: 4px; + background: none !important; + border: 1px solid var(--border-color) !important; + color: var(--text-secondary) !important; + border-radius: var(--radius-md); + padding: 3px 10px; + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.btn-banco-back:hover { background: var(--gray-100) !important; color: var(--text-primary) !important; } +:root[data-theme='dark'] .btn-banco-back:hover { background: rgba(30,58,110,0.3) !important; } + +.banco-account-balance--negative { + color: var(--danger); +} + +/* ── Transactions list ── */ +.banco-transactions-list { + padding: var(--space-2) 0; +} + +.banco-tx-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-5); + transition: background-color var(--transition-fast); +} + +.banco-tx-item:hover { + background: var(--bg-subtle); +} + +.banco-tx-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.banco-tx-indicator--credit { background: var(--success); } +.banco-tx-indicator--debit { background: var(--danger); } + +.banco-tx-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.banco-tx-label { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.banco-tx-date { + font-size: 11px; + color: var(--text-secondary); +} + +.banco-tx-amount { + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.banco-tx-amount--credit { color: var(--success); } +.banco-tx-amount--debit { color: var(--danger); } + +/* ── Loading skeleton ── */ +.banco-loading { + padding: var(--space-3) var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.banco-skeleton { + height: 36px; + border-radius: var(--radius-md); + background: linear-gradient(90deg, var(--bg-subtle) 25%, var(--gray-100) 50%, var(--bg-subtle) 75%); + background-size: 200% 100%; + animation: banco-shimmer 1.4s ease infinite; +} + +@keyframes banco-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ── Empty state ── */ +.banco-empty { + padding: var(--space-8) var(--space-5); + text-align: center; + color: var(--text-secondary); + font-size: 13px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); +} + +.banco-soon-tag { + display: inline-block; + background: var(--amber-50); + color: var(--warning); + border: 1px solid rgba(217, 119, 6, 0.2); + border-radius: 99px; + font-size: 11px; + font-weight: 600; + padding: 2px 10px; + letter-spacing: 0.04em; +} + +/* ── Dark mode ── */ +:root[data-theme='dark'] .banco-stat-card, +:root[data-theme='dark'] .banco-card { + background: #081226; + border-color: #1e2f4d; +} + +:root[data-theme='dark'] .banco-card-header { + border-color: #1e2f4d; +} + +:root[data-theme='dark'] .banco-account-icon { + background: rgba(30, 58, 110, 0.4); +} + +:root[data-theme='dark'] .banco-account-item--active .banco-account-icon { + background: var(--primary); +} + +:root[data-theme='dark'] .banco-stat-card--balance .banco-stat-icon { background: rgba(37, 99, 235, 0.15); } +:root[data-theme='dark'] .banco-stat-card--income .banco-stat-icon { background: rgba(22, 163, 74, 0.15); } +:root[data-theme='dark'] .banco-stat-card--expense .banco-stat-icon { background: rgba(220, 38, 38, 0.15); } +:root[data-theme='dark'] .banco-stat-card--accounts .banco-stat-icon { background: rgba(217, 119, 6, 0.15); } + +:root[data-theme='dark'] .banco-skeleton { + background: linear-gradient(90deg, rgba(15,23,42,0.8) 25%, rgba(30,47,77,0.8) 50%, rgba(15,23,42,0.8) 75%); + background-size: 200% 100%; +} + +:root[data-theme='dark'] .banco-soon-tag { + background: rgba(217, 119, 6, 0.12); + border-color: rgba(217, 119, 6, 0.3); + color: #fbbf24; +} + +/* ── Create account modal ── */ +.banco-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: var(--space-4); + backdrop-filter: blur(2px); +} + +.banco-modal { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.18); + animation: fadeScaleIn 0.15s ease; +} + +@keyframes fadeScaleIn { + from { opacity: 0; transform: scale(0.97) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.banco-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: var(--space-5) var(--space-5) var(--space-4); + border-bottom: 1px solid var(--border-color); + gap: var(--space-3); +} + +.banco-modal-title-group { + display: flex; + align-items: flex-start; + gap: var(--space-3); +} + +.banco-modal-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: var(--blue-50); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; +} + +.banco-modal-icon--edit { + background: #f0fdf4; + color: #16a34a; +} + +:root[data-theme='dark'] .banco-modal-icon--edit { + background: rgba(22,163,74,0.12); + color: #4ade80; +} + +.banco-modal-header h2 { + margin: 0 0 2px; + font-size: 15px; + font-weight: 600; + line-height: 1.3; +} + +.banco-modal-subtitle { + margin: 0; + font-size: 12px; + color: var(--text-secondary); +} + +.banco-modal-close { + background: none !important; + border: none !important; + color: var(--text-tertiary, var(--gray-400)); + cursor: pointer; + font-size: 16px; + padding: 4px 8px; + border-radius: var(--radius-sm); + line-height: 1; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; +} +.banco-modal-close:hover { background: var(--gray-100) !important; color: var(--text-primary); } + +.banco-modal-form { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); } + +/* ── Form sections ── */ +.banco-form-section { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.banco-form-section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: var(--space-2); +} + +.banco-form-section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-color); +} + +.banco-form-section-optional { + font-weight: 400; + text-transform: none; + letter-spacing: 0; + font-size: 11px; + color: var(--text-tertiary, var(--gray-400)); +} + +.banco-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); +} + +.banco-form-field { display: flex; flex-direction: column; gap: 5px; } +.banco-form-field--full { grid-column: 1 / -1; } + +.banco-form-label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} +.banco-form-label .required { color: var(--danger); margin-left: 1px; } + +.banco-form-input { + padding: 8px var(--space-3); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 13px; + font-family: inherit; + background: var(--card-bg); + color: var(--text-primary); + transition: border-color 0.15s, box-shadow 0.15s; + width: 100%; + box-sizing: border-box; +} +.banco-form-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59,130,246,0.1); +} +.banco-form-input::placeholder { color: var(--gray-400); } + +select.banco-form-input { cursor: pointer; } + +.banco-modal-footer { + display: flex; + gap: var(--space-2); + justify-content: flex-end; + align-items: center; + padding-top: var(--space-4); + border-top: 1px solid var(--border-color); +} + +.btn-banco-cancel { + background: transparent !important; + color: var(--text-secondary) !important; + border: 1px solid var(--border-color) !important; + border-radius: var(--radius-md); + padding: 8px 18px; + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.btn-banco-cancel:hover { background: var(--gray-50) !important; color: var(--text-primary) !important; } + +.btn-banco-submit { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--blue-600) !important; + color: white !important; + border: none !important; + border-radius: var(--radius-md); + padding: 8px 18px; + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, box-shadow 0.15s; +} +.btn-banco-submit:hover:not(:disabled) { + background: var(--blue-700) !important; + box-shadow: 0 4px 12px rgba(37,99,235,0.3); +} +.btn-banco-submit:disabled { opacity: 0.65; cursor: not-allowed; } + +:root[data-theme='dark'] .banco-modal { background: #0d1a2e; border-color: #1e2f4d; } +:root[data-theme='dark'] .banco-modal-header { border-color: #1e2f4d; } +:root[data-theme='dark'] .banco-modal-footer { border-color: #1e2f4d; } +:root[data-theme='dark'] .banco-modal-icon { background: rgba(37,99,235,0.15); } +:root[data-theme='dark'] .banco-form-section-label::after { background: #1e2f4d; } +:root[data-theme='dark'] .banco-form-input { background: #081226; border-color: #1e2f4d; } +:root[data-theme='dark'] .banco-form-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,0.12); } +:root[data-theme='dark'] .banco-modal-close:hover { background: rgba(30,58,110,0.3) !important; } +:root[data-theme='dark'] .btn-banco-cancel:hover { background: rgba(30,47,77,0.5) !important; } + +/* ── Responsive ── */ +@media (max-width: 1024px) { + .banco-summary-grid { grid-template-columns: repeat(2, 1fr); } + .banco-content-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .banco-page { padding: var(--space-3); } + .banco-summary-grid { grid-template-columns: 1fr 1fr; } +} diff --git a/doli-front/src/styles/base.css b/doli-front/src/styles/base.css new file mode 100644 index 0000000..6568950 --- /dev/null +++ b/doli-front/src/styles/base.css @@ -0,0 +1,372 @@ +/* ====== BASE / VARIABLES ====== */ +:root { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + font-weight: 400; + color-scheme: light; + color: var(--text-primary); + background-color: var(--bg-page); + + --primary: #2563eb; + --primary-hover: #1d4ed8; + --primary-light: rgba(37, 99, 235, 0.06); + --primary-subtle: rgba(37, 99, 235, 0.08); + --success: #16a34a; + --success-hover: #15803d; + --warning: #ca8a04; + --warning-hover: #a16207; + --danger: #dc2626; + --danger-hover: #b91c1c; + --green-600: #16a34a; + --amber-600: #d97706; + --red-600: #dc2626; + --card-bg: #ffffff; + --border-color: #e2e8f0; + --border-subtle: #f1f5f9; + --text-primary: #0f172a; + --text-secondary: #64748b; + --text-tertiary: #94a3b8; + --sidebar-bg: #0f172a; + --sidebar-text: #94a3b8; + --sidebar-active: #3b82f6; + --sidebar-hover: rgba(255, 255, 255, 0.04); + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.08); + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, *::before, *::after { box-sizing: border-box; } + +a { font-weight: 500; color: var(--primary); text-decoration: none; } +a:hover { color: var(--primary-hover); } + +body { margin: 0; min-width: 320px; min-height: 100vh; } + +h1 { font-size: 1.625rem; line-height: 1.25; font-weight: 600; letter-spacing: -0.02em; } + +#app { width: 100%; min-height: 100vh; } + +button { + border-radius: var(--radius-md); + border: 1px solid transparent; + padding: 0.5em 1em; + font-size: 0.8125rem; + font-weight: 500; + font-family: inherit; + background-color: var(--primary); + color: white; + cursor: pointer; + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} +button:hover { background-color: var(--primary-hover); } +button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + +/* Status badges */ +.status-badge { + display: inline-flex; + align-items: center; + padding: 0.175rem 0.55rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} +.status-pending { background: #fef9c3; color: #854d0e; } +.status-paid { background: #dcfce7; color: #166534; } +.status-overdue { background: #fee2e2; color: #991b1b; } +.status-draft { background: #f1f5f9; color: #475569; } +.status-validated { background: #dbeafe; color: #1e40af; } +.status-unpaid { background: #fef2f2; color: #991b1b; } +.status-canceled { background: #f1f5f9; color: #94a3b8; text-decoration: line-through; } + +/* Form group */ +.form-group { margin-bottom: 1.25rem; } +.form-group label { + display: block; + color: var(--text-primary); + margin-bottom: 0.375rem; + font-weight: 500; + font-size: 0.8125rem; +} +.form-group input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: #fff; + color: var(--text-primary); + font-size: 0.875rem; + box-sizing: border-box; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.form-group input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); +} + +/* View button */ +.btn-view { + background: transparent; + border: none; + padding: 0.35rem; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-tertiary); + transition: color 0.15s ease, background 0.15s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} +.btn-view:hover { color: var(--primary); background: var(--primary-light); } + +/* Loading */ +.loading, .error, .no-invoices, .no-clients { + padding: 3rem; + text-align: center; + color: var(--text-secondary); + font-size: 0.9rem; +} +.error { color: var(--danger); } + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 0.75rem; + margin-top: 1.25rem; + padding: 0.75rem 1rem; + background: var(--card-bg); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} +.pagination-info { + font-weight: 500; + color: var(--text-secondary); + font-size: 0.8125rem; + display: flex; + gap: 0.3rem; + align-items: center; +} +.pagination-info .current-page, +.pagination-info .total-pages { + font-weight: 600; + color: var(--text-primary); + min-width: 20px; + text-align: center; +} +.btn-pagination { + padding: 0.4rem 0.9rem; + background: var(--card-bg); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: 500; + font-size: 0.8125rem; + transition: all 0.15s ease; +} +.btn-pagination:hover:not(:disabled) { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} +.btn-pagination:disabled { + background: #f8fafc; + color: #cbd5e1; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .pagination { flex-wrap: wrap; gap: 0.5rem; } + .btn-pagination { flex: 1; min-width: 90px; } + .pagination-info { order: 3; width: 100%; justify-content: center; } +} + +/* Skeleton */ +@keyframes skeleton-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} +.skeleton { + background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.8s ease-in-out infinite; + border-radius: 4px; +} +.skeleton-row { + display: flex; gap: 1rem; + padding: 0.875rem 1.25rem; + border-bottom: 1px solid var(--border-subtle); +} +.skeleton-cell { height: 14px; border-radius: 3px; } +.skeleton-cell.w-sm { width: 60px; } +.skeleton-cell.w-md { width: 120px; } +.skeleton-cell.w-lg { width: 200px; } +.skeleton-cell.w-xl { width: 100%; } + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 3.5rem 2rem; + text-align: center; + color: var(--text-secondary); +} +.empty-state-icon { + width: 56px; height: 56px; + border-radius: 14px; + background: var(--primary-light); + display: flex; + align-items: center; justify-content: center; + margin-bottom: 1rem; + color: var(--primary); +} +.empty-state h3 { + margin: 0 0 0.25rem; + color: var(--text-primary); + font-size: 0.95rem; + font-weight: 600; +} +.empty-state p { + margin: 0 0 1rem; + font-size: 0.8125rem; + max-width: 320px; + line-height: 1.5; +} +.empty-state .btn-empty-action { + padding: 0.45rem 1rem; + background: var(--primary); + color: #fff; + border: none; + border-radius: var(--radius-sm); + font-weight: 500; + font-size: 0.8125rem; + cursor: pointer; + transition: background 0.15s ease; +} +.empty-state .btn-empty-action:hover { background: var(--primary-hover); } + +/* ── Page entrance animation ── */ +.page-enter { + animation: pageEnter 0.3s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes pageEnter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Stagger animation for KPI cards ── */ +.db-kpi-card { animation: fadeInUp 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; } +.db-kpi-card:nth-child(1) { animation-delay: 0ms; } +.db-kpi-card:nth-child(2) { animation-delay: 80ms; } +.db-kpi-card:nth-child(3) { animation-delay: 160ms; } +.db-kpi-card:nth-child(4) { animation-delay: 240ms; } + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Hover lift for cards ── */ +.db-kpi-card, +.settings-card, +.db-chart-card, +.db-recent-card, +.db-table-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.db-kpi-card:hover, +.db-chart-card:hover, +.db-recent-card:hover, +.db-table-card:hover { + transform: translateY(-1px); +} + +/* ── Status badge enhanced ── */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1.6; +} + +.badge::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; +} + +.badge-draft { + background: var(--gray-100); + color: var(--gray-600); +} +.badge-draft::before { background: var(--gray-400); } + +.badge-unpaid { + background: var(--amber-50); + color: var(--amber-600); +} +.badge-unpaid::before { background: var(--amber-600); } + +.badge-paid { + background: var(--green-50); + color: var(--green-600); +} +.badge-paid::before { background: var(--green-600); } + +:root[data-theme='dark'] .badge-draft { + background: rgba(148, 163, 184, 0.14); + color: #cbd5e1; +} +:root[data-theme='dark'] .badge-draft::before { background: #64748b; } +:root[data-theme='dark'] .badge-unpaid { + background: rgba(245, 158, 11, 0.15); + color: #fcd34d; +} +:root[data-theme='dark'] .badge-unpaid::before { background: #fcd34d; } +:root[data-theme='dark'] .badge-paid { + background: rgba(34, 197, 94, 0.18); + color: #86efac; +} +:root[data-theme='dark'] .badge-paid::before { background: #86efac; } + +/* Fix select dropdown visibility in dark mode: + Native