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
184
api/handler.go
184
api/handler.go
|
|
@ -2,26 +2,37 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"VerifactuMidAPI/internal"
|
||||
"VerifactuMidAPI/internal/cert"
|
||||
"VerifactuMidAPI/internal/config"
|
||||
"VerifactuMidAPI/internal/crypto"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
cert *cert.Storage
|
||||
crypto *crypto.KeyPair
|
||||
type RegisterInput struct {
|
||||
CertName string `json:"cert_name"`
|
||||
CertPath string `json:"cert_path"`
|
||||
PasswordEncrypted string `json:"password_encrypted"`
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, certStorage *cert.Storage, keyPair *crypto.KeyPair) *Handler {
|
||||
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,
|
||||
cfg: cfg,
|
||||
cert: certStorage,
|
||||
crypto: keyPair,
|
||||
facturaSvc: facturaSvc,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +64,161 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
var input RegisterInput
|
||||
if err := json.Unmarshal(body, &input); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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(body)
|
||||
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.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
|
||||
|
||||
verifactu:
|
||||
environment: test
|
||||
production: false
|
||||
|
||||
certificates:
|
||||
storage_path: ./certs/
|
||||
storage_path: ./data/certs/
|
||||
cert_file: ./data/certs/personal.p12
|
||||
cert_password: Mecedora12
|
||||
|
||||
crypto:
|
||||
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
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package cert
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
|
|
@ -16,10 +18,18 @@ type Storage struct {
|
|||
}
|
||||
|
||||
type Certificate struct {
|
||||
ID string
|
||||
OriginalPath string
|
||||
StoredPath string
|
||||
Password string
|
||||
ID string `json:"id"`
|
||||
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
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewStorage(basePath string) *Storage {
|
||||
|
|
@ -36,27 +46,49 @@ func (s *Storage) Init() error {
|
|||
if err := os.MkdirAll(s.basePath, 0700); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
cert := &Certificate{
|
||||
ID: id,
|
||||
StoredPath: storedPath,
|
||||
}
|
||||
s.certs[id] = cert
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Store(id, origPath, password string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
exists := false
|
||||
for _, c := range s.certs {
|
||||
if c.ID == id {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
return "", fmt.Errorf("certificate already exists with this ID")
|
||||
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)
|
||||
storedFilename := fmt.Sprintf("%s%s", id, ext)
|
||||
storedPath := filepath.Join(s.basePath, storedFilename)
|
||||
storedPath := filepath.Join(tmpPath, storedFilename)
|
||||
|
||||
data, err := os.ReadFile(origPath)
|
||||
if err != nil {
|
||||
|
|
@ -67,17 +99,44 @@ func (s *Storage) Store(id, origPath, password string) (string, error) {
|
|||
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{
|
||||
ID: id,
|
||||
OriginalPath: origPath,
|
||||
StoredPath: storedPath,
|
||||
Password: password,
|
||||
ID: id,
|
||||
StoredPath: storedPath,
|
||||
Password: password,
|
||||
}
|
||||
s.certs[id] = cert
|
||||
|
||||
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()
|
||||
|
|
@ -106,7 +165,68 @@ func (s *Storage) Delete(id string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func GenerateID() string {
|
||||
hash := sha256.Sum256([]byte(fmt.Sprintf("%d", os.Getpid())))
|
||||
return hex.EncodeToString(hash[:])[:16]
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
Environment string `yaml:"environment"`
|
||||
Production bool `yaml:"production"`
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -46,8 +48,8 @@ func Load(path string) (*Config, error) {
|
|||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.VeriFactu.Environment == "" {
|
||||
cfg.VeriFactu.Environment = "test"
|
||||
if !cfg.VeriFactu.Production {
|
||||
cfg.VeriFactu.Production = false
|
||||
}
|
||||
if cfg.Certificates.StoragePath == "" {
|
||||
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) {
|
||||
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 (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -65,3 +69,60 @@ 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, "_")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,11 +119,8 @@ func ValidateInvoiceInput(in *InvoiceInput) []error {
|
|||
errs = append(errs, &ValidationError{"factura.importe_total", "must be greater than 0"})
|
||||
}
|
||||
|
||||
if in.Factura.Destinatario != nil {
|
||||
if in.Factura.Destinatario.NIF != "" && !isValidNIF(in.Factura.Destinatario.NIF) {
|
||||
errs = append(errs, &ValidationError{"factura.destinatario.nif", "invalid NIF format"})
|
||||
}
|
||||
}
|
||||
// Destinatario es opcional, no se valida NIF
|
||||
_ = in.Factura.Destinatario
|
||||
|
||||
if in.Sistema.Nombre == "" {
|
||||
errs = append(errs, &ValidationError{"sistema.nombre", "cannot be empty"})
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) {
|
|||
cuotaTotal += iva.Cuota
|
||||
}
|
||||
|
||||
descripcion := in.Factura.Descripcion
|
||||
if descripcion == "" {
|
||||
descripcion = "Factura"
|
||||
}
|
||||
|
||||
sistema := Sistema{
|
||||
Nombre: in.Sistema.Nombre,
|
||||
NIFProveedor: in.Sistema.NIFProveedor,
|
||||
|
|
@ -89,7 +94,7 @@ func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) {
|
|||
NumSerie: in.Factura.NumSerie,
|
||||
Fecha: fecha,
|
||||
TipoFactura: in.Factura.TipoFactura,
|
||||
Descripcion: in.Factura.Destinatario.NIF,
|
||||
Descripcion: descripcion,
|
||||
Destinatario: dest,
|
||||
IVA: ivaList,
|
||||
CuotaTotal: cuotaTotal,
|
||||
|
|
|
|||
40
main.go
40
main.go
|
|
@ -5,11 +5,14 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"VerifactuMidAPI/api"
|
||||
"VerifactuMidAPI/internal"
|
||||
"VerifactuMidAPI/internal/cert"
|
||||
"VerifactuMidAPI/internal/config"
|
||||
"VerifactuMidAPI/internal/crypto"
|
||||
"VerifactuMidAPI/verifactu"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -28,7 +31,42 @@ func main() {
|
|||
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()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
|
|
|||
|
|
@ -3,85 +3,188 @@ package verifactu
|
|||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string
|
||||
CertPath string
|
||||
CertPass string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Certificate *tls.Certificate
|
||||
}
|
||||
|
||||
func NewClient(cfg Config) *Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
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 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{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Transport: tr},
|
||||
}
|
||||
BaseURL: cfg.BaseURL,
|
||||
HTTPClient: httpClient,
|
||||
Certificate: cert,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetEndpoint() string {
|
||||
if c.cfg.Environment == "production" {
|
||||
return "https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl"
|
||||
}
|
||||
return "https://prewww2.aeat.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl"
|
||||
}
|
||||
func LoadCertificate(certPath, password string) (*tls.Certificate, error) {
|
||||
dir := filepath.Dir(certPath)
|
||||
keyPath := filepath.Join(dir, "cert_key.pem")
|
||||
certPath2 := filepath.Join(dir, "cert_cert.pem")
|
||||
|
||||
type XMLRequest struct {
|
||||
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"`
|
||||
}
|
||||
pyScript := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\convert_cert.py"
|
||||
|
||||
type XMLBody struct {
|
||||
Content interface{} `xml:",any"`
|
||||
}
|
||||
|
||||
func (c *Client) Send(xmlPayload interface{}) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := xml.NewEncoder(&buf)
|
||||
err := enc.Encode(xmlPayload)
|
||||
cmd := exec.Command("python", pyScript, certPath, password, keyPath, certPath2)
|
||||
out, err := cmd.CombinedOutput()
|
||||
log.Printf("cert convert: out=%s err=%v", string(out), err)
|
||||
if err != nil {
|
||||
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 {
|
||||
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("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 {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
|
||||
if resp.StatusCode != 200 {
|
||||
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 (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AltaRequest struct {
|
||||
|
|
@ -25,7 +26,6 @@ type RegistroAlta struct {
|
|||
NombreRazonEmisor string `xml:"sum1:NombreRazonEmisor"`
|
||||
TipoFactura string `xml:"sum1:TipoFactura"`
|
||||
DescripcionOperacion string `xml:"sum1:DescripcionOperacion"`
|
||||
Destinatarios *Destinatarios `xml:"sum1:Desglose>sum1:DetalleDesglose"`
|
||||
Desglose Desglose `xml:"sum1:Desglose"`
|
||||
CuotaTotal string `xml:"sum1:CuotaTotal"`
|
||||
ImporteTotal string `xml:"sum1:ImporteTotal"`
|
||||
|
|
@ -129,10 +129,164 @@ type RegistroRespuesta struct {
|
|||
Estado string `xml:"Estado"`
|
||||
}
|
||||
|
||||
func BuildAltaRequest(emisorNombre string, data interface{}) (*AltaRequest, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
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
|
||||
}
|
||||
|
||||
func BuildAnulacionRequest(emisorNombre string, data interface{}) (*AnulacionRequest, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
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("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