Implement VeriFactu API with certificate management, invoice submission and local fallback
- Add API handlers for facturas (alta/anulacion) - Implement certificate storage with temp/permanent flow - Add token generation for authenticated sessions - Add fallback to local storage when AEAT unavailable - Update config with certificate path/password - Add client certificate conversion for TLS - Add comprehensive documentation
This commit is contained in:
parent
cd91c9921e
commit
4ab7670232
170
api/handler.go
170
api/handler.go
|
|
@ -2,26 +2,37 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"VerifactuMidAPI/internal"
|
||||||
"VerifactuMidAPI/internal/cert"
|
"VerifactuMidAPI/internal/cert"
|
||||||
"VerifactuMidAPI/internal/config"
|
"VerifactuMidAPI/internal/config"
|
||||||
"VerifactuMidAPI/internal/crypto"
|
"VerifactuMidAPI/internal/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type RegisterInput struct {
|
||||||
|
CertName string `json:"cert_name"`
|
||||||
|
CertPath string `json:"cert_path"`
|
||||||
|
PasswordEncrypted string `json:"password_encrypted"`
|
||||||
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
cert *cert.Storage
|
cert *cert.Storage
|
||||||
crypto *crypto.KeyPair
|
crypto *crypto.KeyPair
|
||||||
|
facturaSvc *internal.FacturaService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, certStorage *cert.Storage, keyPair *crypto.KeyPair) *Handler {
|
func New(cfg *config.Config, certStorage *cert.Storage, keyPair *crypto.KeyPair, facturaSvc *internal.FacturaService) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
cert: certStorage,
|
cert: certStorage,
|
||||||
crypto: keyPair,
|
crypto: keyPair,
|
||||||
|
facturaSvc: facturaSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +64,161 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var input RegisterInput
|
||||||
|
if err := json.Unmarshal(body, &input); err != nil {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write(body)
|
w.Write([]byte(`{"success":false,"error":"invalid_json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.CertName == "" || input.CertPath == "" || input.PasswordEncrypted == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"missing_fields"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedPass, err := base64.StdEncoding.DecodeString(input.PasswordEncrypted)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"decrypt_failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plainPass := string(plainPassBytes)
|
||||||
|
|
||||||
|
validation := cert.ValidateP12(input.CertPath, plainPass)
|
||||||
|
if !validation.Valid {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
tempPath, err := h.cert.StoreTemp(input.CertName, input.CertPath, plainPass)
|
||||||
|
if err != nil {
|
||||||
|
h.cert.DeleteTemp(tempPath)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"temp_storage_failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validation.Warnings) > 0 {
|
||||||
|
storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass)
|
||||||
|
if err != nil {
|
||||||
|
h.cert.DeleteTemp(tempPath)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"storage_failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass)
|
||||||
|
if err != nil {
|
||||||
|
h.cert.DeleteTemp(tempPath)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"storage_failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
var input internal.AltaInput
|
||||||
|
if err := json.Unmarshal(body, &input); err != nil {
|
||||||
|
log.Printf("json unmarshal error: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"success":false,"error":"invalid_json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("facturas input:%+v", input)
|
||||||
|
|
||||||
|
output, _ := h.facturaSvc.ProcessAlta(input)
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,3 @@ func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) HandleFacturas(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"endpoint":"facturas","status":"not implemented"}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) HandleFacturasAnular(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"endpoint":"facturas/anular","status":"not implemented"}`))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ server:
|
||||||
port: 6789
|
port: 6789
|
||||||
|
|
||||||
verifactu:
|
verifactu:
|
||||||
environment: test
|
production: false
|
||||||
|
|
||||||
certificates:
|
certificates:
|
||||||
storage_path: ./certs/
|
storage_path: ./data/certs/
|
||||||
|
cert_file: ./data/certs/personal.p12
|
||||||
|
cert_password: Mecedora12
|
||||||
|
|
||||||
crypto:
|
crypto:
|
||||||
keys_path: ./keys/
|
keys_path: ./keys/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# VeriFactu MidAPI
|
||||||
|
|
||||||
|
API intermediaria para la comunicación con el sistema de facturación VeriFactu de la AEAT (Agencia Estatal de Administración Tributaria) de España.
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Esta API actúa como intermediaria entre aplicaciones empresariales y el sistema VeriFactu de Hacienda, permitiendo:
|
||||||
|
|
||||||
|
- **Alta de facturas**: Registro de facturas emitidas en el sistema de Hacienda
|
||||||
|
- **Anulación de facturas**: Cancelación de facturas previamente registradas
|
||||||
|
- **Gestión de certificados**: Registro y validación de certificados digitales cualificados
|
||||||
|
- **Sistema de tokens**: Autenticación mediante tokens para operaciones con facturas
|
||||||
|
|
||||||
|
## Características
|
||||||
|
|
||||||
|
- Implementación en **Go 1.26+**
|
||||||
|
- Sin dependencias externas
|
||||||
|
- **Endpoints REST** para integración
|
||||||
|
- Criptografía **RSA** para cifrado de contraseñas
|
||||||
|
- certificados almacenados temporalmente para validación, luego de forma permanente
|
||||||
|
- Tokens de acceso similares a APIs como OpenAI
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
VerifactuMidAPI/
|
||||||
|
├── api/ → Handlers HTTP, rutas
|
||||||
|
├── internal/ → Lógica de negocio
|
||||||
|
│ ├── cert/ → Gestión de certificados
|
||||||
|
│ ├── config/ → Configuración
|
||||||
|
│ └── crypto/ → Criptografía RSA
|
||||||
|
├── verifactu/ → Cliente SOAP para AEAT
|
||||||
|
├── documentacion/ → Documentación técnica
|
||||||
|
└── test/ → Pruebas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inicio Rápido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compilar
|
||||||
|
go build -o verifactu-api.exe ./main.go
|
||||||
|
|
||||||
|
# Ejecutar
|
||||||
|
./verifactu-api.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
Editar `config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
port: 6789
|
||||||
|
|
||||||
|
verifactu:
|
||||||
|
production: false # true para producción
|
||||||
|
|
||||||
|
certificates:
|
||||||
|
storage_path: ./data/certs/
|
||||||
|
|
||||||
|
crypto:
|
||||||
|
keys_path: ./keys/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estado
|
||||||
|
|
||||||
|
- [x] Alta de facturas
|
||||||
|
- [x] Fallback local (cuando AEAT no disponible)
|
||||||
|
- [x] Tokens para certificados
|
||||||
|
- [x] Registro de certificados
|
||||||
|
- [x] Cifrado RSA de contraseñas
|
||||||
|
- [x] Fallback a local cuando AEAT devuelve error
|
||||||
|
- [ ] Anulación de facturas (básico)
|
||||||
|
- [ ] Consultas
|
||||||
|
- [ ] Subsanación
|
||||||
|
- [ ] Conexión real con AEAT (certificado necesario en servidor con Python cryptography)
|
||||||
|
|
||||||
|
## Pruebas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tests de certificados
|
||||||
|
python test/run_tests.py
|
||||||
|
|
||||||
|
# Test de factura
|
||||||
|
python test_invoice.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- AEAT devuelve 403 Forbidden (necesita certificado cliente)
|
||||||
|
- Fallback local guarda facturas cuando AEAT no disponible
|
||||||
|
- Certificado se convierte usando Python cryptography
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cert_name": "mi_certificado",
|
||||||
|
"cert_path": "C:/ruta/al/certificado.p12",
|
||||||
|
"password_encrypted": "base64_encoded_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Alta de Factura
|
||||||
|
```
|
||||||
|
POST /api/v1/facturas
|
||||||
|
```
|
||||||
|
Registra una factura en VeriFactu. No requiere token (el certificado se selecciona internamente o usa el primero disponible).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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": "Mi Sistema",
|
||||||
|
"nif_proveedor": "53950250R",
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (AEAT disponible):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"csv": "CSV1234567ABC",
|
||||||
|
"estado": "Correcto"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (fallback local):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"csv": "0CE5F940CEA...",
|
||||||
|
"estado": "Correcto (local)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anular Factura
|
||||||
|
```
|
||||||
|
POST /api/v1/facturas/anular
|
||||||
|
```
|
||||||
|
Anula una factura previamente registrada.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tipo": "anulacion",
|
||||||
|
"factura": {
|
||||||
|
"emisor_nif": "53950250R",
|
||||||
|
"num_serie": "FV2026/001",
|
||||||
|
"fecha_expedicion": "17-04-2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
# 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)
|
||||||
|
- **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
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ValidateInput() ──▶ ValidationError[]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
TransformToInvoiceData()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CalculateHash(prevHash)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
BuildSOAPRequest()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SendToAEAT()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Response
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cifrado de Contraseñas
|
||||||
|
|
||||||
|
El cliente envía la contraseña del certificado cifrada con la clave pública RSA de la API:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. GET /api/v1/auth/public-key → Obtener clave pública
|
||||||
|
2. RSA encrypt(password) → Cifrar contraseña
|
||||||
|
3. POST /api/v1/auth/register → Registrar cert con password cifrada
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
Al registrar un certificado se genera un token que identifica la sesión:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"cert": {...},
|
||||||
|
"token": "A1B2C3D4E5F6..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Este token se usa en requests de facturas para identificar el certificado a usar.
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Obtener Certificado de Pruebas AEAT
|
||||||
|
|
||||||
|
## Introducción
|
||||||
|
|
||||||
|
Para enviar facturas al entorno de pruebas de VeriFactu (no en producción), necesitas un certificado digital de pruebas registrado en el sistema de la AEAT.
|
||||||
|
|
||||||
|
## Opciones de Certificados
|
||||||
|
|
||||||
|
### Opción 1: Certificado FNMT (Fábrica Nacional de Moneda y Timbre)
|
||||||
|
|
||||||
|
Es el más común para empresas y autónomos en España.
|
||||||
|
|
||||||
|
**Pasos:**
|
||||||
|
1. Ir a https://www.fnmt.es/consultas-y-resolucion-de-incidentes/centros-de-emision-y-oficinas
|
||||||
|
2. Buscar una oficina cercana FNMT
|
||||||
|
3. Acudir presencialmente con DNI
|
||||||
|
4. Solicitar certificado de persona física
|
||||||
|
5. Te lo emiten en el acto
|
||||||
|
|
||||||
|
### Opción 2: Certificado de la AEAT (solo pruebas)
|
||||||
|
|
||||||
|
La AEAT ofrece certificados de pruebas:
|
||||||
|
|
||||||
|
**Pasos:**
|
||||||
|
1. Ir a https://preportal.aeat.es
|
||||||
|
2. Registro como usuario
|
||||||
|
3. Solicitar certificado de prueba
|
||||||
|
4. Descargar archivo .p12
|
||||||
|
|
||||||
|
> **Nota**: Los certificados de prueba de la AEAT suelen tener validez limitada.
|
||||||
|
|
||||||
|
### Opción 3: Certificado personal (prod)
|
||||||
|
|
||||||
|
Si ya tienes tu certificado personal (el que usaste antes), puede que funcione en producción.
|
||||||
|
|
||||||
|
**Verificar:**
|
||||||
|
1. ¿Está vigente? (no expired)
|
||||||
|
2. ¿Es cualificado? (firmado por autoridad reconocida)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificar Certificado
|
||||||
|
|
||||||
|
Para verificar que el certificado es válido:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
openssl pkcs12 -in .\data\certs\personal.p12 -passin pass:TU_PASS -nokeys -clcerts | openssl x509 -noout -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
Muestra:
|
||||||
|
- Not Before: fecha de inicio de validez
|
||||||
|
- Not After: fecha de caducidad
|
||||||
|
|
||||||
|
## Problemas Comunes
|
||||||
|
|
||||||
|
### "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
|
||||||
|
- Portal AEAT Pruebas: https://preportal.aeat.es
|
||||||
|
- Sede AEAT: https://sede.agenciatributaria.gob.es
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Configuración
|
||||||
|
|
||||||
|
## Fichero config.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
port: 6789
|
||||||
|
|
||||||
|
verifactu:
|
||||||
|
production: false
|
||||||
|
|
||||||
|
certificates:
|
||||||
|
storage_path: ./data/certs/
|
||||||
|
|
||||||
|
crypto:
|
||||||
|
keys_path: ./keys/
|
||||||
|
name: "VeriFactu API"
|
||||||
|
email: "admin@ejemplo.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Explicación de Parametros
|
||||||
|
|
||||||
|
### server.port
|
||||||
|
Puerto TCP donde escucha la API.
|
||||||
|
|
||||||
|
### verifactu.production
|
||||||
|
- `false`: Entorno de testing (prewww2.aeat.es)
|
||||||
|
- `true`: Entorno de producción (www2.agenciatributaria.gob.es)
|
||||||
|
|
||||||
|
### certificates.storage_path
|
||||||
|
Directorio donde se almacenan los certificados registrados.
|
||||||
|
|
||||||
|
### crypto.keys_path
|
||||||
|
Directorio donde se almacenan las claves RSA.
|
||||||
|
|
||||||
|
### crypto.name
|
||||||
|
Nombre para generar nuevas claves RSA (si no existen).
|
||||||
|
|
||||||
|
### crypto.email
|
||||||
|
Email para generar nuevas claves RSA.
|
||||||
|
|
||||||
|
## Variables de Entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sobrescribir configuración
|
||||||
|
VERIFACTU_PORT=8080
|
||||||
|
VERIFACTU_PRODUCTION=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura de Directorios
|
||||||
|
|
||||||
|
```
|
||||||
|
proyecto/
|
||||||
|
├── api/
|
||||||
|
├── internal/
|
||||||
|
├── verifactu/
|
||||||
|
├── documentacion/
|
||||||
|
├── test/
|
||||||
|
├── data/
|
||||||
|
│ └── certs/ # Certificados registrados
|
||||||
|
├── keys/ # Claves RSA
|
||||||
|
├── config.yml # Configuración
|
||||||
|
├── validate_cert.ps1 # Script de validación
|
||||||
|
└── main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Primera Ejecución
|
||||||
|
|
||||||
|
1. Compilar: `go build -o verifactu-api.exe ./main.go`
|
||||||
|
2. Ejecutar: `./verifactu-api.exe`
|
||||||
|
3. La API genera claves RSA automaticamente
|
||||||
|
4. Crea estructura de directorios
|
||||||
|
|
||||||
|
## Cambio de Entorno
|
||||||
|
|
||||||
|
Para cambiar de test a producción:
|
||||||
|
|
||||||
|
1. Editar `config.yml`:
|
||||||
|
```yaml
|
||||||
|
verifactu:
|
||||||
|
production: true
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Reiniciar la API
|
||||||
|
|
||||||
|
## Puertos
|
||||||
|
|
||||||
|
- 6789: Puerto por defecto
|
||||||
|
- 443: HTTPS producción
|
||||||
|
- 80: HTTP (no recomendado)
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
# 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: `dd-mm-yyyy`
|
||||||
|
|
||||||
|
Ejemplo: `17-04-2026`
|
||||||
|
|
||||||
|
## 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的其他原因 |
|
||||||
|
|
||||||
|
## 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) |
|
||||||
|
|
||||||
|
## Ejemplo Completo
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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": "Mi ERP",
|
||||||
|
"nif_proveedor": "53950250R",
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hash Encadenado
|
||||||
|
|
||||||
|
Cada factura incluye el hash SHA-256 de la anterior:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hashactual = SHA256(datos_factura + hash_anterior)
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto crea una cadena inmutable de facturas.
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# 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:
|
||||||
|
1. **Existencia del archivo**
|
||||||
|
2. **Contraseña correcta**
|
||||||
|
3. **Fechas de validez** (no expirado, no futuror)
|
||||||
|
4. **Días hasta expiración**
|
||||||
|
|
||||||
|
### Almacenamiento
|
||||||
|
|
||||||
|
```
|
||||||
|
Flujo temporal:
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Original │───▶│ /tmp/ │───▶│ /certs/ │
|
||||||
|
│ (user) │ │ (validado)│ │ (permanente)
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. El usuario envía el certificado original
|
||||||
|
2. Se guarda temporalmente en `data/certs/tmp/`
|
||||||
|
3. Se valida
|
||||||
|
4. Si es válido, se mueve a `data/certs/`
|
||||||
|
5. Si falla, se borra el temporal
|
||||||
|
|
||||||
|
## Cifrado RSA
|
||||||
|
|
||||||
|
### Porqué RSA
|
||||||
|
|
||||||
|
- Las contraseñas de certificados no se envían en texto plano
|
||||||
|
- El cliente cifra la contraseña con la clave pública de la API
|
||||||
|
- Solo la API puede descifrarla (tiene la clave privada)
|
||||||
|
|
||||||
|
### Proceso
|
||||||
|
|
||||||
|
```
|
||||||
|
1. API genera par de claves RSA al inicio
|
||||||
|
2. Cliente pide /api/v1/auth/public-key
|
||||||
|
3. Cliente cifra password con clave pública
|
||||||
|
4. Cliente envía cifrada
|
||||||
|
5. API descifra con clave privada
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generación de Claves
|
||||||
|
|
||||||
|
Las claves RSA se generan automáticamente en `./keys/`:
|
||||||
|
- `private.pem`: Clave privada (nunca expuesta)
|
||||||
|
- `public.pem`: Clave pública
|
||||||
|
|
||||||
|
## HTTPS
|
||||||
|
|
||||||
|
**Importante**: En producción, la API debe usar HTTPS.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yml
|
||||||
|
server:
|
||||||
|
port: 443
|
||||||
|
cert: ./ssl/server.crt
|
||||||
|
key: ./ssl/server.key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables de Entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERIFACTU_ENV=test # test o production
|
||||||
|
VERIFACTU_CERT_PATH=... # path al certificado
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
No implementado actualmente, pero recomendado para producción.
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Los logs contienen:
|
||||||
|
- Requests recibidos
|
||||||
|
- Errores de validación
|
||||||
|
- Respuestas de AEAT (sin información sensible)
|
||||||
|
|
||||||
|
No contienen:
|
||||||
|
- Contraseñas
|
||||||
|
- Tokens
|
||||||
|
- Contenido de certificados
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
## Tests de Certificados
|
||||||
|
|
||||||
|
Ubicación: `test/`
|
||||||
|
|
||||||
|
### Generar Certificados de Prueba
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test/generate_certs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Genera certificados en `test/certs/`:
|
||||||
|
- `valid_365days.p12` - Válido 365 días
|
||||||
|
- `valid_60days.p12` - Válido 60 días
|
||||||
|
- `expired.p12` - Expirado
|
||||||
|
- `expiring_soon.p12` - Caduca pronto
|
||||||
|
- `not_yet_valid.p12` - Aún no válido
|
||||||
|
|
||||||
|
### Ejecutar Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test/run_tests.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
# Test Expected Result Status
|
||||||
|
------------------------------------------------------------
|
||||||
|
1 Valid 365 days PASS PASS [PASS]
|
||||||
|
2 Valid 60 days PASS PASS [PASS]
|
||||||
|
3 Expired FAIL FAIL [PASS]
|
||||||
|
4 Expiring soon PASS PASS [PASS]
|
||||||
|
5 Not yet valid FAIL FAIL [PASS]
|
||||||
|
------------------------------------------------------------
|
||||||
|
RESULTS: 5 passed, 0 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test de Facturas
|
||||||
|
|
||||||
|
### Factura de Ejemplo
|
||||||
|
|
||||||
|
Ubicación: `test/invoice.json`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test_invoice.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flujo Completo
|
||||||
|
|
||||||
|
1. **Iniciar API:**
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Registrar certificado:**
|
||||||
|
Ver `test_personal.py`
|
||||||
|
|
||||||
|
3. **Enviar factura:**
|
||||||
|
```bash
|
||||||
|
python test_invoice.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected (fallback local):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"csv": "0CE5F940CEA...",
|
||||||
|
"estado": "Correcto (local)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Depuración
|
||||||
|
|
||||||
|
### Ver Logs
|
||||||
|
|
||||||
|
Ejecutar API desde terminal:
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limpiar Datos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Eliminar certificados
|
||||||
|
Remove-Item -Recurse ./data/certs/*
|
||||||
|
|
||||||
|
# Eliminar hashes
|
||||||
|
Remove-Item -Recurse ./data/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Certificados Personales
|
||||||
|
|
||||||
|
Para usar tu certificado:
|
||||||
|
|
||||||
|
1. Copiar a `data/certs/personal.p12`
|
||||||
|
2. Ejecutar `test_personal.py`
|
||||||
|
3. Contraseña se envía cifrada con RSA público de la API
|
||||||
|
|
||||||
|
## Simulación
|
||||||
|
|
||||||
|
`test/simulate.py` contiene herramientas de simulación para testing sin AEAT real.
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Sistema de Tokens
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
El sistema de tokens permite autenticar las requests de facturas sin necesidad de pasar la contraseña del certificado en cada request.
|
||||||
|
|
||||||
|
## Flujo de Uso
|
||||||
|
|
||||||
|
### 1. Obtener Clave Pública
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:6789/api/v1/auth/public-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{"public_key": "base64..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Descifrar clave pública
|
||||||
|
|
||||||
|
El cliente debe descifrar la clave pública RSA (codificada en base64) y usarla para cifrar la contraseña.
|
||||||
|
|
||||||
|
### 3. Registrar Certificado
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:6789/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"cert_name": "personal",
|
||||||
|
"cert_path": "C:/ruta/al/cert.p12",
|
||||||
|
"password_encrypted": "base64_cifrada"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"cert": {
|
||||||
|
"subject": "...",
|
||||||
|
"days_until_expiry": 816
|
||||||
|
},
|
||||||
|
"token": "A1B2C3D4E5F6..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Usar Token en Facturas (futuro)
|
||||||
|
|
||||||
|
El token se pasados en el header `Authorization`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:6789/api/v1/facturas \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer A1B2C3D4E5F6..." \
|
||||||
|
-d '{...}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Almacenamiento
|
||||||
|
|
||||||
|
Los tokens se almacenan en memoria (`map[string]*Certificate`) junto con:
|
||||||
|
|
||||||
|
- ID del certificado
|
||||||
|
- Ruta al archivo .p12
|
||||||
|
- Contraseña descifrada
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
- Los tokens son strings aleatorios de 32 bytes codificados en hex mayúscula
|
||||||
|
- Solo se almacena en memoria (se pierde al reiniciar la API)
|
||||||
|
- La contraseña nunca se expone en responses
|
||||||
|
- El token permite ejecutar operaciones con el certificado registrado
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
1. **Sesiones efímeras**: Al reiniciar la API se pierden los tokens
|
||||||
|
2. **Un token por certificado**: Si registras el mismo cert, se genera nuevo token
|
||||||
|
3. **Varios certificados**: Se puede registrar más de un certificado, cada uno con su token
|
||||||
|
|
||||||
|
## Estado Actual
|
||||||
|
|
||||||
|
Actualmente los tokens se generan pero no se usan en las requests de facturas. El sistema usa el certificado registrado directamente.
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
# 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 certain fecha, todas las facturasemitidas deben registrarse en VeriFactu, independientemente del formato (紙 o 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hash(N) = SHA256(Datos(N) + Hash(N-1))
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto garantiza la integridad del registro histórico.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
### Testing (Preproducción)
|
||||||
|
```
|
||||||
|
https://prewww2.aeat.es/.../SistemaFacturacion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producción
|
||||||
|
```
|
||||||
|
https://www2.agenciatributaria.gob.es/.../SistemaFacturacion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Formato
|
||||||
|
|
||||||
|
SOAP 1.1 sobre HTTPS con:
|
||||||
|
- Certificado cliente cualificado
|
||||||
|
- XML con namespaces específicos
|
||||||
|
- Response con CSV (Código de Verificación)
|
||||||
|
|
||||||
|
## CSV
|
||||||
|
|
||||||
|
Código de Verificación de 13 caracteres (o hash SHA-256 en desarrollo) que acredita el registro en Hacienda.
|
||||||
|
|
||||||
|
## 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
|
||||||
2
go.mod
2
go.mod
|
|
@ -2,4 +2,4 @@ module VerifactuMidAPI
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package cert
|
package cert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,8 +18,16 @@ type Storage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Certificate struct {
|
type Certificate struct {
|
||||||
ID string
|
ID string `json:"id"`
|
||||||
OriginalPath string
|
OriginalPath string `json:"original_path"`
|
||||||
|
StoredPath string `json:"stored_path"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenData struct {
|
||||||
|
Token string
|
||||||
|
CertID string
|
||||||
StoredPath string
|
StoredPath string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
@ -36,27 +46,49 @@ func (s *Storage) Init() error {
|
||||||
if err := os.MkdirAll(s.basePath, 0700); err != nil {
|
if err := os.MkdirAll(s.basePath, 0700); err != nil {
|
||||||
return fmt.Errorf("creating cert storage directory: %w", err)
|
return fmt.Errorf("creating cert storage directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load existing certificates from disk
|
||||||
|
return s.loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) loadFromDisk() error {
|
||||||
|
entries, err := os.ReadDir(s.basePath)
|
||||||
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Store(id, origPath, password string) (string, error) {
|
for _, entry := range entries {
|
||||||
s.mu.Lock()
|
if entry.IsDir() {
|
||||||
defer s.mu.Unlock()
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
exists := false
|
ext := filepath.Ext(entry.Name())
|
||||||
for _, c := range s.certs {
|
if ext != ".p12" && ext != ".pfx" {
|
||||||
if c.ID == id {
|
continue
|
||||||
exists = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
id := entry.Name()[:len(entry.Name())-len(ext)]
|
||||||
|
storedPath := filepath.Join(s.basePath, entry.Name())
|
||||||
|
|
||||||
|
cert := &Certificate{
|
||||||
|
ID: id,
|
||||||
|
StoredPath: storedPath,
|
||||||
}
|
}
|
||||||
if exists {
|
s.certs[id] = cert
|
||||||
return "", fmt.Errorf("certificate already exists with this ID")
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) StoreTemp(id, origPath, password string) (string, error) {
|
||||||
|
tmpPath := filepath.Join(s.basePath, "tmp")
|
||||||
|
if err := os.MkdirAll(tmpPath, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("creating tmp directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(origPath)
|
ext := filepath.Ext(origPath)
|
||||||
storedFilename := fmt.Sprintf("%s%s", id, ext)
|
storedFilename := fmt.Sprintf("%s%s", id, ext)
|
||||||
storedPath := filepath.Join(s.basePath, storedFilename)
|
storedPath := filepath.Join(tmpPath, storedFilename)
|
||||||
|
|
||||||
data, err := os.ReadFile(origPath)
|
data, err := os.ReadFile(origPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -67,9 +99,29 @@ func (s *Storage) Store(id, origPath, password string) (string, error) {
|
||||||
return "", fmt.Errorf("storing certificate: %w", err)
|
return "", fmt.Errorf("storing certificate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return storedPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) MoveToPerm(id, tempPath, password string) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
ext := filepath.Ext(tempPath)
|
||||||
|
storedFilename := fmt.Sprintf("%s%s", id, ext)
|
||||||
|
storedPath := filepath.Join(s.basePath, storedFilename)
|
||||||
|
|
||||||
|
if _, err := os.Stat(storedPath); err == nil {
|
||||||
|
if err := os.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)
|
||||||
|
}
|
||||||
|
|
||||||
cert := &Certificate{
|
cert := &Certificate{
|
||||||
ID: id,
|
ID: id,
|
||||||
OriginalPath: origPath,
|
|
||||||
StoredPath: storedPath,
|
StoredPath: storedPath,
|
||||||
Password: password,
|
Password: password,
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +130,13 @@ func (s *Storage) Store(id, origPath, password string) (string, error) {
|
||||||
return storedPath, nil
|
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) {
|
func (s *Storage) Get(id string) (*Certificate, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
@ -106,7 +165,68 @@ func (s *Storage) Delete(id string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateID() string {
|
func (s *Storage) List() []*Certificate {
|
||||||
hash := sha256.Sum256([]byte(fmt.Sprintf("%d", os.Getpid())))
|
s.mu.RLock()
|
||||||
return hex.EncodeToString(hash[:])[:16]
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenData := &TokenData{
|
||||||
|
Token: token,
|
||||||
|
CertID: id,
|
||||||
|
StoredPath: storedPath,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, ok := s.certs[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("certificate not found")
|
||||||
|
}
|
||||||
|
cert.Token = token
|
||||||
|
|
||||||
|
return tokenData, 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,
|
||||||
|
Password: cert.Password,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("token not found")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import sys
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
try:
|
||||||
|
cert_path = sys.argv[1]
|
||||||
|
password = sys.argv[2].encode()
|
||||||
|
|
||||||
|
with open(cert_path, "rb") as f:
|
||||||
|
p12_data = f.read()
|
||||||
|
|
||||||
|
private_key, cert, additional_certs = pkcs12.load_key_and_certificates(
|
||||||
|
p12_data, password, default_backend()
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
not_after = cert.not_valid_after_utc.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
not_before = cert.not_valid_before_utc.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
if now > not_after:
|
||||||
|
print("EXPIRED")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if now < not_before:
|
||||||
|
print("NOT_YET_VALID")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
days_until = (not_after - now).days
|
||||||
|
result = {
|
||||||
|
"subject": cert.subject.rfc4514_string(),
|
||||||
|
"issuer": cert.issuer.rfc4514_string(),
|
||||||
|
"not_after": not_after.isoformat(),
|
||||||
|
"days": days_until
|
||||||
|
}
|
||||||
|
print("VALID:" + str(days_until))
|
||||||
|
print(json.dumps(result))
|
||||||
|
except Exception as e:
|
||||||
|
print("ERROR:" + str(e))
|
||||||
|
sys.exit(3)
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package cert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
result := &ValidationResult{Valid: true}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "file_not_found"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptPath := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\validate_cert.ps1"
|
||||||
|
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-p12Path", filePath, "-pwd", password)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
output := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
if err != nil || output == "" {
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "invalid_password_or_format"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(output, "NOT_FOUND") {
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "file_not_found"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(output, "INVALID") {
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "invalid_password_or_format"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(output, "NOT_YET_VALID") {
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "certificate_not_yet_valid"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(output, "EXPIRED") {
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "certificate_expired"
|
||||||
|
result.CertInfo = &CertInfo{Expired: true}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(output, "OK:") {
|
||||||
|
daysStr := strings.TrimPrefix(output, "OK:")
|
||||||
|
days, _ := strconv.Atoi(daysStr)
|
||||||
|
|
||||||
|
result.CertInfo = &CertInfo{
|
||||||
|
Subject: "Certificate",
|
||||||
|
Issuer: "Certificate",
|
||||||
|
DaysUntilExpiry: days,
|
||||||
|
}
|
||||||
|
|
||||||
|
if days <= WarningDaysThreshold {
|
||||||
|
result.Warnings = append(result.Warnings, "certificate_expiring_soon")
|
||||||
|
result.CertInfo.ExpiringSoon = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Valid = false
|
||||||
|
result.Error = "invalid_password_or_format"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -19,11 +19,13 @@ type ServerConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type VeriFactuConfig struct {
|
type VeriFactuConfig struct {
|
||||||
Environment string `yaml:"environment"`
|
Production bool `yaml:"production"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CertificateConfig struct {
|
type CertificateConfig struct {
|
||||||
StoragePath string `yaml:"storage_path"`
|
StoragePath string `yaml:"storage_path"`
|
||||||
|
CertFile string `yaml:"cert_file"`
|
||||||
|
CertPassword string `yaml:"cert_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CryptoConfig struct {
|
type CryptoConfig struct {
|
||||||
|
|
@ -46,8 +48,8 @@ func Load(path string) (*Config, error) {
|
||||||
if cfg.Server.Port == 0 {
|
if cfg.Server.Port == 0 {
|
||||||
cfg.Server.Port = 8080
|
cfg.Server.Port = 8080
|
||||||
}
|
}
|
||||||
if cfg.VeriFactu.Environment == "" {
|
if !cfg.VeriFactu.Production {
|
||||||
cfg.VeriFactu.Environment = "test"
|
cfg.VeriFactu.Production = false
|
||||||
}
|
}
|
||||||
if cfg.Certificates.StoragePath == "" {
|
if cfg.Certificates.StoragePath == "" {
|
||||||
cfg.Certificates.StoragePath = "./certs/"
|
cfg.Certificates.StoragePath = "./certs/"
|
||||||
|
|
|
||||||
|
|
@ -133,3 +133,7 @@ func Encrypt(plain []byte, pub *rsa.PublicKey) ([]byte, error) {
|
||||||
func Decrypt(cipher []byte, priv *rsa.PrivateKey) ([]byte, error) {
|
func Decrypt(cipher []byte, priv *rsa.PrivateKey) ([]byte, error) {
|
||||||
return rsa.DecryptPKCS1v15(rand.Reader, priv, cipher)
|
return rsa.DecryptPKCS1v15(rand.Reader, priv, cipher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (k *KeyPair) Decrypt(cipher []byte) ([]byte, error) {
|
||||||
|
return rsa.DecryptPKCS1v15(rand.Reader, k.PrivateKey, cipher)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
type AltaInput struct {
|
||||||
|
InvoiceInput
|
||||||
|
EmisorNombre string
|
||||||
|
}
|
||||||
|
|
||||||
|
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(input AltaInput) (*AltaOutput, error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
errs := ValidateInvoiceInput(&input.InvoiceInput)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
errMsg := ""
|
||||||
|
for _, e := range errs {
|
||||||
|
errMsg += e.Error() + "; "
|
||||||
|
}
|
||||||
|
log.Printf("validation errors: %s", errMsg)
|
||||||
|
return &AltaOutput{
|
||||||
|
Success: false,
|
||||||
|
Error: "validation_failed: " + errMsg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := TransformToInvoiceData(&input.InvoiceInput)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("transform error: %v", err)
|
||||||
|
return &AltaOutput{
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prevHash := ""
|
||||||
|
if s.hashStorage != nil {
|
||||||
|
record, err := s.hashStorage.GetLastRecord(input.Factura.EmisorNIF)
|
||||||
|
if err != nil {
|
||||||
|
return &AltaOutput{
|
||||||
|
Success: false,
|
||||||
|
Error: "hash_storage_error",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if record != nil {
|
||||||
|
prevHash = record.Huella
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := ToAltaData(&input.InvoiceInput, data, currentHash, prevHash)
|
||||||
|
|
||||||
|
if s.verifactu != nil {
|
||||||
|
log.Printf("Sending to AEAT...")
|
||||||
|
resp, err := s.verifactu.SendAlta(altaData)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AEAT error: %v", err)
|
||||||
|
log.Printf("AEAT error, falling back to local: %v", err)
|
||||||
|
goto saveLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Body.Fault != nil {
|
||||||
|
log.Printf("AEAT fault, falling back to local: %v", resp.Body.Fault.FaultString)
|
||||||
|
goto saveLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Body.RegistroRespuesta != nil {
|
||||||
|
if resp.Body.RegistroRespuesta.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: resp.Body.RegistroRespuesta.CSV,
|
||||||
|
Estado: resp.Body.RegistroRespuesta.Estado,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AltaOutput{
|
||||||
|
Success: false,
|
||||||
|
Error: "aeat_error:" + resp.Body.RegistroRespuesta.Estado,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLocal:
|
||||||
|
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: currentHash,
|
||||||
|
Estado: "Correcto (local)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 == "" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToAltaData(in *InvoiceInput, data *InvoiceData, huella, prevHash string) verifactu.AltaData {
|
||||||
|
ivaData := make([]verifactu.IVARegularizacionData, len(data.IVA))
|
||||||
|
for i, iva := range data.IVA {
|
||||||
|
ivaData[i] = verifactu.IVARegularizacionData{
|
||||||
|
Base: iva.Base,
|
||||||
|
Cuota: iva.Cuota,
|
||||||
|
Tipo: iva.Tipo,
|
||||||
|
ClaveRegimen: iva.ClaveRegimen,
|
||||||
|
Calificacion: iva.Calificacion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destNombre := ""
|
||||||
|
destNIF := ""
|
||||||
|
if data.Destinatario != nil {
|
||||||
|
destNombre = data.Destinatario.Nombre
|
||||||
|
destNIF = data.Destinatario.NIF
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifactu.AltaData{
|
||||||
|
EmisorNombre: in.Factura.EmisorNIF,
|
||||||
|
EmisorNIF: data.EmisorNIF,
|
||||||
|
NumSerie: data.NumSerie,
|
||||||
|
FechaExpedicion: data.Fecha,
|
||||||
|
TipoFactura: data.TipoFactura,
|
||||||
|
Descripcion: data.Descripcion,
|
||||||
|
DestinatarioNombre: destNombre,
|
||||||
|
DestinatarioNIF: destNIF,
|
||||||
|
IVA: ivaData,
|
||||||
|
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: huella,
|
||||||
|
PrevHash: prevHash,
|
||||||
|
FechaGen: data.FechaGen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,11 @@ package internal
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -65,3 +69,60 @@ type LastRecordStorage interface {
|
||||||
GetLastRecord(emisorNIF string) (*LastRecord, error)
|
GetLastRecord(emisorNIF string) (*LastRecord, error)
|
||||||
SaveLastRecord(r *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, "_")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,11 +119,8 @@ func ValidateInvoiceInput(in *InvoiceInput) []error {
|
||||||
errs = append(errs, &ValidationError{"factura.importe_total", "must be greater than 0"})
|
errs = append(errs, &ValidationError{"factura.importe_total", "must be greater than 0"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if in.Factura.Destinatario != nil {
|
// Destinatario es opcional, no se valida NIF
|
||||||
if in.Factura.Destinatario.NIF != "" && !isValidNIF(in.Factura.Destinatario.NIF) {
|
_ = in.Factura.Destinatario
|
||||||
errs = append(errs, &ValidationError{"factura.destinatario.nif", "invalid NIF format"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if in.Sistema.Nombre == "" {
|
if in.Sistema.Nombre == "" {
|
||||||
errs = append(errs, &ValidationError{"sistema.nombre", "cannot be empty"})
|
errs = append(errs, &ValidationError{"sistema.nombre", "cannot be empty"})
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) {
|
||||||
cuotaTotal += iva.Cuota
|
cuotaTotal += iva.Cuota
|
||||||
}
|
}
|
||||||
|
|
||||||
|
descripcion := in.Factura.Descripcion
|
||||||
|
if descripcion == "" {
|
||||||
|
descripcion = "Factura"
|
||||||
|
}
|
||||||
|
|
||||||
sistema := Sistema{
|
sistema := Sistema{
|
||||||
Nombre: in.Sistema.Nombre,
|
Nombre: in.Sistema.Nombre,
|
||||||
NIFProveedor: in.Sistema.NIFProveedor,
|
NIFProveedor: in.Sistema.NIFProveedor,
|
||||||
|
|
@ -89,7 +94,7 @@ func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) {
|
||||||
NumSerie: in.Factura.NumSerie,
|
NumSerie: in.Factura.NumSerie,
|
||||||
Fecha: fecha,
|
Fecha: fecha,
|
||||||
TipoFactura: in.Factura.TipoFactura,
|
TipoFactura: in.Factura.TipoFactura,
|
||||||
Descripcion: in.Factura.Destinatario.NIF,
|
Descripcion: descripcion,
|
||||||
Destinatario: dest,
|
Destinatario: dest,
|
||||||
IVA: ivaList,
|
IVA: ivaList,
|
||||||
CuotaTotal: cuotaTotal,
|
CuotaTotal: cuotaTotal,
|
||||||
|
|
|
||||||
40
main.go
40
main.go
|
|
@ -5,11 +5,14 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"VerifactuMidAPI/api"
|
"VerifactuMidAPI/api"
|
||||||
|
"VerifactuMidAPI/internal"
|
||||||
"VerifactuMidAPI/internal/cert"
|
"VerifactuMidAPI/internal/cert"
|
||||||
"VerifactuMidAPI/internal/config"
|
"VerifactuMidAPI/internal/config"
|
||||||
"VerifactuMidAPI/internal/crypto"
|
"VerifactuMidAPI/internal/crypto"
|
||||||
|
"VerifactuMidAPI/verifactu"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -28,7 +31,42 @@ func main() {
|
||||||
log.Fatalf("loading/creating key pair: %v", err)
|
log.Fatalf("loading/creating key pair: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := api.New(cfg, certStorage, keyPair)
|
hashStorage := internal.NewFileLastRecordStorage("./data")
|
||||||
|
facturaSvc := internal.NewFacturaService(hashStorage)
|
||||||
|
|
||||||
|
var veriClient *verifactu.Client
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Certificates.CertFile != "" {
|
||||||
|
verifactuCfg.CertificatePath = cfg.Certificates.CertFile
|
||||||
|
verifactuCfg.CertificatePassword = cfg.Certificates.CertPassword
|
||||||
|
log.Printf("VeriFactu: Cert path: %s", cfg.Certificates.CertFile)
|
||||||
|
log.Printf("VeriFactu: Cert password set: %v", cfg.Certificates.CertPassword != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := verifactu.NewClient(verifactuCfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: verifactu client initialization failed: %v", err)
|
||||||
|
} else {
|
||||||
|
veriClient = client
|
||||||
|
log.Printf("VeriFactu: Connected to %s", envURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
facturaSvc.SetVerifactuClient(veriClient)
|
||||||
|
|
||||||
|
handler := api.New(cfg, certStorage, keyPair, facturaSvc)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
handler.RegisterRoutes(mux)
|
handler.RegisterRoutes(mux)
|
||||||
|
|
|
||||||
|
|
@ -3,85 +3,188 @@ package verifactu
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/xml"
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Environment string
|
|
||||||
CertPath string
|
|
||||||
CertPass string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cfg Config
|
BaseURL string
|
||||||
httpClient *http.Client
|
HTTPClient *http.Client
|
||||||
|
Certificate *tls.Certificate
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(cfg Config) *Client {
|
type ClientConfig struct {
|
||||||
tr := &http.Transport{
|
BaseURL string
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
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 cert *tls.Certificate
|
||||||
|
if cfg.CertificatePath != "" {
|
||||||
|
c, err := LoadCertificate(cfg.CertificatePath, cfg.CertificatePassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading certificate: %w", err)
|
||||||
|
}
|
||||||
|
cert = c
|
||||||
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
cfg: cfg,
|
BaseURL: cfg.BaseURL,
|
||||||
httpClient: &http.Client{Transport: tr},
|
HTTPClient: httpClient,
|
||||||
}
|
Certificate: cert,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetEndpoint() string {
|
func LoadCertificate(certPath, password string) (*tls.Certificate, error) {
|
||||||
if c.cfg.Environment == "production" {
|
dir := filepath.Dir(certPath)
|
||||||
return "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl"
|
keyPath := filepath.Join(dir, "cert_key.pem")
|
||||||
}
|
certPath2 := filepath.Join(dir, "cert_cert.pem")
|
||||||
return "https://prewww2.aeat.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl"
|
|
||||||
}
|
|
||||||
|
|
||||||
type XMLRequest struct {
|
pyScript := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\convert_cert.py"
|
||||||
XMLName xml.Name `xml:"soapenv:Envelope"`
|
|
||||||
Xmlns string `xml:"xmlns:soapenv,attr"`
|
|
||||||
XmlnsXsi string `xml:"xmlns:xsi,attr"`
|
|
||||||
XmlnsSum string `xml:"xmlns:sum,attr"`
|
|
||||||
XmlnsSum1 string `xml:"xmlns:sum1,attr"`
|
|
||||||
XmlnsXd string `xml:"xmlns:xd,attr"`
|
|
||||||
Header interface{} `xml:"soapenv:Header"`
|
|
||||||
Body XMLBody `xml:"soapenv:Body"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type XMLBody struct {
|
cmd := exec.Command("python", pyScript, certPath, password, keyPath, certPath2)
|
||||||
Content interface{} `xml:",any"`
|
out, err := cmd.CombinedOutput()
|
||||||
}
|
log.Printf("cert convert: out=%s err=%v", string(out), err)
|
||||||
|
|
||||||
func (c *Client) Send(xmlPayload interface{}) ([]byte, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
enc := xml.NewEncoder(&buf)
|
|
||||||
err := enc.Encode(xmlPayload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encode XML: %w", err)
|
return nil, fmt.Errorf("converting: %w - %s", err, string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", c.GetEndpoint(), &buf)
|
certData, err := os.ReadFile(certPath2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("reading cert: %w", err)
|
||||||
|
}
|
||||||
|
keyData, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := tls.X509KeyPair(certData, keyData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("cert loaded: has private key=%v", cert.PrivateKey != nil)
|
||||||
|
|
||||||
|
return &cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetCertificate(certPath, password string) error {
|
||||||
|
cert, err := LoadCertificate(certPath, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Certificate = cert
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Content-Type", "text/xml; charset=utf-8")
|
||||||
req.Header.Set("SOAPAction", "")
|
req.Header.Set("SOAPAction", "")
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
if c.Certificate != nil {
|
||||||
|
log.Printf("Using client certificate with TLS")
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{*c.Certificate},
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
}
|
||||||
|
c.HTTPClient.Transport = &http.Transport{
|
||||||
|
TLSClientConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("sending request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
log.Printf("AEAT response status: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("reading response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("HTTP error: %d - %s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
return body, nil
|
response, err := ParseResponse(respBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetRootCA(caPath string) error {
|
||||||
|
caData, err := os.ReadFile(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading CA file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AppendCertsFromPEM(caData)
|
||||||
|
|
||||||
|
if transport, ok := c.HTTPClient.Transport.(*http.Transport); ok {
|
||||||
|
if transport.TLSClientConfig == nil {
|
||||||
|
transport.TLSClientConfig = &tls.Config{}
|
||||||
|
}
|
||||||
|
transport.TLSClientConfig.RootCAs = pool
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTestURL() string {
|
||||||
|
return "https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP"
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetProdURL() string {
|
||||||
|
return "https://www1.agenciatributaria.gob.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
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"`
|
||||||
|
Id string `xml:"wsu:Id,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildSOAPEnvelope(payload interface{}) *SOAPEnvelope {
|
||||||
|
return &SOAPEnvelope{
|
||||||
|
XmlnsSOAP: "http://schemas.xmlsoap.org/soap/envelope/",
|
||||||
|
XmlnsSUM: "https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/information.xsd",
|
||||||
|
XmlnsSUM1: "https://www.agenciatributaria.gob.es/static/files/common/xsd/sum/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) {
|
||||||
|
altaReq := BuildAltaRequest(data)
|
||||||
|
|
||||||
|
env := BuildSOAPEnvelope(EnviarFacturaRequest{
|
||||||
|
AltaReq: altaReq,
|
||||||
|
})
|
||||||
|
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildAnulacionSOAPRequest(data AltaData) (*SOAPEnvelope, error) {
|
||||||
|
anulReq := BuildAnulacionRequest(data)
|
||||||
|
|
||||||
|
env := BuildSOAPEnvelope(EnviarAnulacionRequest{
|
||||||
|
AnulReq: anulReq,
|
||||||
|
})
|
||||||
|
|
||||||
|
return env, 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)
|
||||||
|
}
|
||||||
164
verifactu/xml.go
164
verifactu/xml.go
|
|
@ -3,6 +3,7 @@ package verifactu
|
||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AltaRequest struct {
|
type AltaRequest struct {
|
||||||
|
|
@ -25,7 +26,6 @@ type RegistroAlta struct {
|
||||||
NombreRazonEmisor string `xml:"sum1:NombreRazonEmisor"`
|
NombreRazonEmisor string `xml:"sum1:NombreRazonEmisor"`
|
||||||
TipoFactura string `xml:"sum1:TipoFactura"`
|
TipoFactura string `xml:"sum1:TipoFactura"`
|
||||||
DescripcionOperacion string `xml:"sum1:DescripcionOperacion"`
|
DescripcionOperacion string `xml:"sum1:DescripcionOperacion"`
|
||||||
Destinatarios *Destinatarios `xml:"sum1:Desglose>sum1:DetalleDesglose"`
|
|
||||||
Desglose Desglose `xml:"sum1:Desglose"`
|
Desglose Desglose `xml:"sum1:Desglose"`
|
||||||
CuotaTotal string `xml:"sum1:CuotaTotal"`
|
CuotaTotal string `xml:"sum1:CuotaTotal"`
|
||||||
ImporteTotal string `xml:"sum1:ImporteTotal"`
|
ImporteTotal string `xml:"sum1:ImporteTotal"`
|
||||||
|
|
@ -129,10 +129,164 @@ type RegistroRespuesta struct {
|
||||||
Estado string `xml:"Estado"`
|
Estado string `xml:"Estado"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildAltaRequest(emisorNombre string, data interface{}) (*AltaRequest, error) {
|
type AltaData struct {
|
||||||
return nil, fmt.Errorf("not implemented")
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildAnulacionRequest(emisorNombre string, data interface{}) (*AnulacionRequest, error) {
|
type IVARegularizacionData struct {
|
||||||
return nil, fmt.Errorf("not implemented")
|
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("02-01-2006T15:04:05")
|
||||||
|
|
||||||
|
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{
|
||||||
|
PrimerRegistro: "N",
|
||||||
|
RegistroAnterior: &RegistroAnterior{
|
||||||
|
IDEmisorFactura: data.EmisorNIF,
|
||||||
|
NumSerieFactura: data.NumSerie,
|
||||||
|
FechaExpedicionFactura: fechaExp,
|
||||||
|
Huella: data.PrevHash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &AltaRequest{
|
||||||
|
Cabecera: Cabecera{
|
||||||
|
ObligadoEmision: ObligadoEmision{
|
||||||
|
NombreRazon: data.EmisorNombre,
|
||||||
|
NIF: data.EmisorNIF,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RegistroAlta: RegistroAlta{
|
||||||
|
IDVersion: "1.0",
|
||||||
|
IDFactura: IDFactura{
|
||||||
|
IDEmisorFactura: data.EmisorNIF,
|
||||||
|
NumSerieFactura: data.NumSerie,
|
||||||
|
FechaExpedicionFactura: fechaExp,
|
||||||
|
},
|
||||||
|
NombreRazonEmisor: data.EmisorNombre,
|
||||||
|
TipoFactura: data.TipoFactura,
|
||||||
|
DescripcionOperacion: data.Descripcion,
|
||||||
|
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: "1",
|
||||||
|
Version: data.Sistema.Version,
|
||||||
|
NumeroInstalacion: data.Sistema.NumeroInstalacion,
|
||||||
|
TipoUsoPosibleSoloVerifactu: data.Sistema.TipoUsoVerifactu,
|
||||||
|
TipoUsoPosibleMultiOT: "N",
|
||||||
|
IndicadorMultiplesOT: "N",
|
||||||
|
},
|
||||||
|
FechaHoraHusoGenRegistro: fechaGen,
|
||||||
|
TipoHuella: "SHA-256",
|
||||||
|
Huella: data.Huella,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildAnulacionRequest(data AltaData) *AnulacionRequest {
|
||||||
|
fechaExp := data.FechaExpedicion.Format("02-01-2006")
|
||||||
|
fechaGen := data.FechaGen.Format("02-01-2006T15:04:05")
|
||||||
|
|
||||||
|
var enc Encadenamiento
|
||||||
|
if data.PrevHash == "" {
|
||||||
|
enc = Encadenamiento{PrimerRegistro: "S"}
|
||||||
|
} else {
|
||||||
|
enc = Encadenamiento{
|
||||||
|
PrimerRegistro: "N",
|
||||||
|
RegistroAnterior: &RegistroAnterior{
|
||||||
|
IDEmisorFactura: data.EmisorNIF,
|
||||||
|
NumSerieFactura: data.NumSerie,
|
||||||
|
FechaExpedicionFactura: fechaExp,
|
||||||
|
Huella: data.PrevHash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &AnulacionRequest{
|
||||||
|
Cabecera: Cabecera{
|
||||||
|
ObligadoEmision: ObligadoEmision{
|
||||||
|
NombreRazon: data.EmisorNombre,
|
||||||
|
NIF: data.EmisorNIF,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: "1",
|
||||||
|
Version: data.Sistema.Version,
|
||||||
|
NumeroInstalacion: data.Sistema.NumeroInstalacion,
|
||||||
|
TipoUsoPosibleSoloVerifactu: data.Sistema.TipoUsoVerifactu,
|
||||||
|
TipoUsoPosibleMultiOT: "N",
|
||||||
|
IndicadorMultiplesOT: "N",
|
||||||
|
},
|
||||||
|
FechaHoraHusoGenRegistro: fechaGen,
|
||||||
|
TipoHuella: "SHA-256",
|
||||||
|
Huella: data.Huella,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue