feat: add format system with auto-detection

- Add internal/formats/registry.go with Transformer interface
- Add internal/formats/native/ for current API format
- Add internal/formats/dolibarr/ for Dolibarr BFF format
- Refactor factura.go to use TransformAuto() for auto-detection
- Remove format query param — detection is now automatic
This commit is contained in:
lite 2026-05-17 16:27:27 -04:00
parent 74487015df
commit 16049fa3ef
5 changed files with 433 additions and 71 deletions

View File

@ -1,10 +1,12 @@
package internal
import (
"encoding/json"
"fmt"
"log"
"time"
"VerifactuMidAPI/internal/formats"
"VerifactuMidAPI/verifactu"
)
@ -23,11 +25,6 @@ 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"`
@ -35,39 +32,81 @@ type AltaOutput struct {
Error string `json:"error,omitempty"`
}
func (s *FacturaService) ProcessAlta(input AltaInput) (*AltaOutput, error) {
func (s *FacturaService) ProcessAlta(raw json.RawMessage) (*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)
result, formatName, err := formats.TransformAuto(raw)
if err != nil {
return &AltaOutput{
Success: false,
Error: "validation_failed: " + errMsg,
Error: "transform_error: " + err.Error(),
}, nil
}
data, err := TransformToInvoiceData(&input.InvoiceInput)
if err != nil {
log.Printf("transform error: %v", err)
return &AltaOutput{
Success: false,
Error: err.Error(),
}, nil
log.Printf("detected format: %s", formatName)
return s.processInvoiceData(result)
}
func (s *FacturaService) processInvoiceData(result *formats.TransformResult) (*AltaOutput, error) {
fecha, _ := time.Parse("02-01-2006", result.FechaExpedicion)
ivaList := make([]IVARegularizacion, len(result.IVA))
for i, v := range result.IVA {
ivaList[i] = IVARegularizacion{
Base: v.Base,
Cuota: v.Cuota,
Tipo: v.Tipo,
ClaveRegimen: "01",
Calificacion: "S1",
}
}
cuotaTotal := 0.0
for _, v := range result.IVA {
cuotaTotal += v.Cuota
}
var dest *Destinatario
if result.Destinatario != nil {
dest = &Destinatario{
Nombre: result.Destinatario.Nombre,
NIF: result.Destinatario.NIF,
}
}
data := &InvoiceData{
Tipo: "alta",
EmisorNIF: result.EmisorNIF,
NumSerie: result.NumSerie,
Fecha: fecha,
TipoFactura: result.TipoFactura,
Descripcion: result.Descripcion,
Destinatario: dest,
IVA: ivaList,
CuotaTotal: cuotaTotal,
ImporteTotal: result.ImporteTotal,
Sistema: Sistema{
Nombre: result.Sistema.Nombre,
NIFProveedor: result.Sistema.NIFProveedor,
NombreSistema: result.Sistema.Nombre,
IDSistema: "01",
Version: result.Sistema.Version,
NumeroInstalacion: "1",
TipoUsoVerifactu: "S",
TipoUsoMultiOT: "N",
IndicadorMultiOT: "N",
},
FechaGen: time.Now(),
}
var prevHash, prevNumSerie string
var prevFecha time.Time
if s.hashStorage != nil {
record, err := s.hashStorage.GetLastRecord(input.Factura.EmisorNIF)
record, err := s.hashStorage.GetLastRecord(result.EmisorNIF)
if err != nil {
return &AltaOutput{
Success: false,
@ -96,14 +135,38 @@ func (s *FacturaService) ProcessAlta(input AltaInput) (*AltaOutput, error) {
data.Huella = currentHash
data.FechaGen = now
altaData := ToAltaData(&input.InvoiceInput, data, currentHash, prevHash, prevNumSerie, prevFecha)
altaData := verifactu.AltaData{
EmisorNombre: result.EmisorNombre,
EmisorNIF: data.EmisorNIF,
NumSerie: data.NumSerie,
FechaExpedicion: data.Fecha,
TipoFactura: data.TipoFactura,
Descripcion: data.Descripcion,
DestinatarioNombre: destNombre(data.Destinatario),
DestinatarioNIF: destNIF(data.Destinatario),
IVA: toIVAData(data.IVA),
CuotaTotal: data.CuotaTotal,
ImporteTotal: data.ImporteTotal,
Sistema: verifactu.SistemaData{
Nombre: data.Sistema.Nombre,
NIFProveedor: data.Sistema.NIFProveedor,
NombreSistema: data.Sistema.NombreSistema,
Version: data.Sistema.Version,
NumeroInstalacion: data.Sistema.NumeroInstalacion,
TipoUsoVerifactu: data.Sistema.TipoUsoVerifactu,
},
Huella: currentHash,
PrevHash: prevHash,
PrevNumSerie: prevNumSerie,
PrevFecha: prevFecha,
FechaGen: data.FechaGen,
}
if s.verifactu != nil {
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
}
@ -163,6 +226,34 @@ saveLocal:
}, nil
}
func destNombre(d *Destinatario) string {
if d == nil {
return ""
}
return d.Nombre
}
func destNIF(d *Destinatario) string {
if d == nil {
return ""
}
return d.NIF
}
func toIVAData(list []IVARegularizacion) []verifactu.IVARegularizacionData {
out := make([]verifactu.IVARegularizacionData, len(list))
for i, v := range list {
out[i] = verifactu.IVARegularizacionData{
Base: v.Base,
Cuota: v.Cuota,
Tipo: v.Tipo,
ClaveRegimen: v.ClaveRegimen,
Calificacion: v.Calificacion,
}
}
return out
}
type AnulacionInput struct {
InvoiceInput
EmisorNombre string
@ -200,50 +291,3 @@ func (s *FacturaService) ProcessAnulacion(input AnulacionInput) (*AnulacionOutpu
Estado: "Anulada",
}, nil
}
func ToAltaData(in *InvoiceInput, data *InvoiceData, huella, prevHash, prevNumSerie string, prevFecha time.Time) 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.EmisorNombre,
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,
PrevNumSerie: prevNumSerie,
PrevFecha: prevFecha,
FechaGen: data.FechaGen,
}
}

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ import (
"time"
"VerifactuMidAPI/api"
_ "VerifactuMidAPI/internal/formats/dolibarr"
_ "VerifactuMidAPI/internal/formats/native"
"VerifactuMidAPI/internal"
"VerifactuMidAPI/internal/cert"
"VerifactuMidAPI/internal/config"