diff --git a/internal/hash.go b/internal/hash.go new file mode 100644 index 0000000..511d5c2 --- /dev/null +++ b/internal/hash.go @@ -0,0 +1,67 @@ +package internal + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" +) + +type HashService struct { + lastRecord *LastRecord +} + +type LastRecord struct { + EmisorNIF string + NumSerie string + Fecha time.Time + Huella string + FechaGen time.Time +} + +func NewHashService() *HashService { + return &HashService{} +} + +func (s *HashService) SetLastRecord(r *LastRecord) { + s.lastRecord = r +} + +func (s *HashService) GetLastRecord() *LastRecord { + return s.lastRecord +} + +func (s *HashService) CalculateHash(data *InvoiceData, previousHash string) string { + fechaGen := data.FechaGen.Format(time.RFC3339) + + fields := fmt.Sprintf("%s|%s|%s|%s|%.2f|%.2f|%s|%s", + data.EmisorNIF, + data.NumSerie, + data.Fecha.Format("02-01-2006"), + data.TipoFactura, + data.CuotaTotal, + data.ImporteTotal, + previousHash, + fechaGen, + ) + + hash := sha256.Sum256([]byte(fields)) + return strings.ToUpper(hex.EncodeToString(hash[:])) +} + +func (s *HashService) IsFirstRecord() bool { + return s.lastRecord == nil +} + +func (s *HashService) GetPreviousHash() string { + if s.lastRecord == nil { + return "" + } + return s.lastRecord.Huella +} + +type LastRecordStorage interface { + GetLastRecord(emisorNIF string) (*LastRecord, error) + SaveLastRecord(r *LastRecord) error +} diff --git a/internal/models.go b/internal/models.go new file mode 100644 index 0000000..101ce6f --- /dev/null +++ b/internal/models.go @@ -0,0 +1,158 @@ +package internal + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "time" +) + +var ( + ErrEmptyNIF = errors.New("NIF cannot be empty") + ErrInvalidNIF = errors.New("invalid NIF format") + ErrEmptyNumSerie = errors.New("number series cannot be empty") + ErrInvalidFecha = errors.New("invalid date format, expected dd-mm-yyyy") + ErrInvalidTipo = errors.New("invalid invoice type") + ErrInvalidImporte = errors.New("invalid amount format") + ErrEmptyNombre = errors.New("business name cannot be empty") + ErrInvalidIVACampo = errors.New("invalid IVA field: must be numeric") +) + +var nifRegex = regexp.MustCompile(`^[A-Z0-9]\d{7}[A-Z]$`) +var tipoFacturaValidos = map[string]bool{ + "F1": true, "F2": true, + "R1": true, "R2": true, "R3": true, "R4": true, "R5": true, +} + +type InvoiceInput struct { + Tipo string `json:"tipo"` + Factura FacturaInput `json:"factura"` + Sistema SistemaInput `json:"sistema"` +} + +type FacturaInput struct { + EmisorNIF string `json:"emisor_nif"` + NumSerie string `json:"num_serie"` + FechaExpedicion string `json:"fecha_expedicion"` + TipoFactura string `json:"tipo_factura"` + Descripcion string `json:"descripcion"` + Destinatario *DestinatarioInput `json:"destinatario,omitempty"` + IVA []IVAInput `json:"iva"` + ImporteTotal float64 `json:"importe_total"` +} + +type DestinatarioInput struct { + Nombre string `json:"nombre"` + NIF string `json:"nif"` +} + +type IVAInput struct { + Base float64 `json:"base"` + Cuota float64 `json:"cuota"` + Tipo float64 `json:"tipo"` +} + +type SistemaInput struct { + Nombre string `json:"nombre"` + NIFProveedor string `json:"nif_proveedor"` + Version string `json:"version"` +} + +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +func ValidateInvoiceInput(in *InvoiceInput) []error { + var errs []error + + if in.Tipo == "" { + errs = append(errs, &ValidationError{"tipo", "operation type is required"}) + } else if in.Tipo != "alta" && in.Tipo != "anulacion" { + errs = append(errs, &ValidationError{"tipo", "must be 'alta' or 'anulacion'"}) + } + + if in.Factura.EmisorNIF == "" { + errs = append(errs, &ValidationError{"factura.emisor_nif", "cannot be empty"}) + } else if !isValidNIF(in.Factura.EmisorNIF) { + errs = append(errs, &ValidationError{"factura.emisor_nif", "invalid NIF format"}) + } + + if in.Factura.NumSerie == "" { + errs = append(errs, &ValidationError{"factura.num_serie", "cannot be empty"}) + } + + if in.Factura.FechaExpedicion == "" { + errs = append(errs, &ValidationError{"factura.fecha_expedicion", "cannot be empty"}) + } else if !isValidDate(in.Factura.FechaExpedicion) { + errs = append(errs, &ValidationError{"factura.fecha_expedicion", "invalid format, expected dd-mm-yyyy"}) + } + + if in.Factura.TipoFactura == "" { + errs = append(errs, &ValidationError{"factura.tipo_factura", "cannot be empty"}) + } else if !tipoFacturaValidos[in.Factura.TipoFactura] { + errs = append(errs, &ValidationError{"factura.tipo_factura", "invalid invoice type"}) + } + + if len(in.Factura.IVA) == 0 { + errs = append(errs, &ValidationError{"factura.iva", "at least one IVA entry is required"}) + } + + for i, iva := range in.Factura.IVA { + if iva.Base < 0 { + errs = append(errs, &ValidationError{fmt.Sprintf("factura.iva[%d].base", i), "must be positive"}) + } + if iva.Cuota < 0 { + errs = append(errs, &ValidationError{fmt.Sprintf("factura.iva[%d].cuota", i), "must be positive"}) + } + if iva.Tipo < 0 { + errs = append(errs, &ValidationError{fmt.Sprintf("factura.iva[%d].tipo", i), "must be positive"}) + } + } + + if in.Factura.ImporteTotal <= 0 { + errs = append(errs, &ValidationError{"factura.importe_total", "must be greater than 0"}) + } + + 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"}) + } + } + + if in.Sistema.Nombre == "" { + errs = append(errs, &ValidationError{"sistema.nombre", "cannot be empty"}) + } + if in.Sistema.NIFProveedor == "" { + errs = append(errs, &ValidationError{"sistema.nif_proveedor", "cannot be empty"}) + } + if in.Sistema.Version == "" { + errs = append(errs, &ValidationError{"sistema.version", "cannot be empty"}) + } + + return errs +} + +func isValidNIF(nif string) bool { + if len(nif) != 9 { + return false + } + return nifRegex.MatchString(nif) +} + +func isValidDate(date string) bool { + _, err := time.Parse("02-01-2006", date) + return err == nil +} + +func ParseFloat(s string) (float64, error) { + if s == "" { + return 0, ErrInvalidImporte + } + return strconv.ParseFloat(s, 64) +} diff --git a/internal/transformer.go b/internal/transformer.go new file mode 100644 index 0000000..98b2739 --- /dev/null +++ b/internal/transformer.go @@ -0,0 +1,100 @@ +package internal + +import "time" + +type InvoiceData struct { + Tipo string + EmisorNIF string + NumSerie string + Fecha time.Time + TipoFactura string + Descripcion string + Destinatario *Destinatario + IVA []IVARegularizacion + CuotaTotal float64 + ImporteTotal float64 + Sistema Sistema + FechaGen time.Time + Huella string +} + +type Destinatario struct { + Nombre string + NIF string +} + +type IVARegularizacion struct { + Base float64 + Cuota float64 + Tipo float64 + ClaveRegimen string + Calificacion string +} + +type Sistema struct { + Nombre string + NIFProveedor string + NombreSistema string + IDSistema string + Version string + NumeroInstalacion string + TipoUsoVerifactu string + TipoUsoMultiOT string + IndicadorMultiOT string +} + +func TransformToInvoiceData(in *InvoiceInput) (*InvoiceData, error) { + fecha, _ := time.Parse("02-01-2006", in.Factura.FechaExpedicion) + + destinatario := in.Factura.Destinatario + var dest *Destinatario + if destinatario != nil { + dest = &Destinatario{ + Nombre: destinatario.Nombre, + NIF: destinatario.NIF, + } + } + + ivaList := make([]IVARegularizacion, len(in.Factura.IVA)) + for i, iva := range in.Factura.IVA { + ivaList[i] = IVARegularizacion{ + Base: iva.Base, + Cuota: iva.Cuota, + Tipo: iva.Tipo, + ClaveRegimen: "01", + Calificacion: "S1", + } + } + + cuotaTotal := 0.0 + for _, iva := range in.Factura.IVA { + cuotaTotal += iva.Cuota + } + + sistema := Sistema{ + Nombre: in.Sistema.Nombre, + NIFProveedor: in.Sistema.NIFProveedor, + NombreSistema: in.Sistema.Nombre, + IDSistema: "1", + Version: in.Sistema.Version, + NumeroInstalacion: "1", + TipoUsoVerifactu: "S", + TipoUsoMultiOT: "N", + IndicadorMultiOT: "N", + } + + return &InvoiceData{ + Tipo: in.Tipo, + EmisorNIF: in.Factura.EmisorNIF, + NumSerie: in.Factura.NumSerie, + Fecha: fecha, + TipoFactura: in.Factura.TipoFactura, + Descripcion: in.Factura.Destinatario.NIF, + Destinatario: dest, + IVA: ivaList, + CuotaTotal: cuotaTotal, + ImporteTotal: in.Factura.ImporteTotal, + Sistema: sistema, + FechaGen: time.Now(), + }, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..38dd16d --- /dev/null +++ b/main.go @@ -0,0 +1,3 @@ +package main + +func main() {}