Skip to main content

cfdi_cli/
formatter.rs

1use std::io::{self, Write};
2
3use crate::models::Comprobante;
4
5#[derive(Debug, Clone, Copy)]
6pub enum OutputFormat {
7    Json,
8    Csv,
9    Table,
10}
11
12impl std::str::FromStr for OutputFormat {
13    type Err = String;
14
15    fn from_str(s: &str) -> Result<Self, Self::Err> {
16        match s.to_lowercase().as_str() {
17            "json" => Ok(OutputFormat::Json),
18            "csv" => Ok(OutputFormat::Csv),
19            "table" => Ok(OutputFormat::Table),
20            other => Err(format!("Unknown format: {other}. Use json, csv, or table")),
21        }
22    }
23}
24
25pub fn format_json(cfdi: &Comprobante, writer: &mut dyn Write) -> io::Result<()> {
26    let json = serde_json::to_string_pretty(cfdi)
27        .map_err(io::Error::other)?;
28    writeln!(writer, "{json}")
29}
30
31pub fn format_csv_header(writer: &mut csv::Writer<impl Write>) -> Result<(), csv::Error> {
32    writer.write_record([
33        "UUID",
34        "Fecha",
35        "Tipo",
36        "Serie",
37        "Folio",
38        "EmisorRFC",
39        "EmisorNombre",
40        "RegimenFiscal",
41        "ReceptorRFC",
42        "ReceptorNombre",
43        "UsoCFDI",
44        "SubTotal",
45        "Descuento",
46        "Total",
47        "Moneda",
48        "TipoCambio",
49        "FormaPago",
50        "MetodoPago",
51        "IVATrasladado",
52        "IVARetenido",
53        "ISRRetenido",
54        "LugarExpedicion",
55    ])
56}
57
58pub fn format_csv_row(
59    cfdi: &Comprobante,
60    writer: &mut csv::Writer<impl Write>,
61) -> Result<(), csv::Error> {
62    let uuid = cfdi.uuid().unwrap_or("").to_string();
63    let iva_trasladado = get_impuesto_trasladado(cfdi, "002");
64    let iva_retenido = get_impuesto_retenido(cfdi, "002");
65    let isr_retenido = get_impuesto_retenido(cfdi, "001");
66
67    writer.write_record([
68        &uuid,
69        &cfdi.fecha,
70        &cfdi.tipo_de_comprobante,
71        cfdi.serie.as_deref().unwrap_or(""),
72        cfdi.folio.as_deref().unwrap_or(""),
73        &cfdi.emisor.rfc,
74        cfdi.emisor.nombre.as_deref().unwrap_or(""),
75        &cfdi.emisor.regimen_fiscal,
76        &cfdi.receptor.rfc,
77        cfdi.receptor.nombre.as_deref().unwrap_or(""),
78        &cfdi.receptor.uso_cfdi,
79        &cfdi.sub_total,
80        cfdi.descuento.as_deref().unwrap_or("0"),
81        &cfdi.total,
82        &cfdi.moneda,
83        cfdi.tipo_cambio.as_deref().unwrap_or(""),
84        cfdi.forma_pago.as_deref().unwrap_or(""),
85        cfdi.metodo_pago.as_deref().unwrap_or(""),
86        &iva_trasladado,
87        &iva_retenido,
88        &isr_retenido,
89        &cfdi.lugar_expedicion,
90    ])
91}
92
93pub fn format_table(cfdi: &Comprobante, writer: &mut dyn Write) -> io::Result<()> {
94    use colored::Colorize;
95
96    let uuid = cfdi.uuid().unwrap_or("N/A");
97    let tipo = cfdi.tipo_comprobante_str();
98
99    writeln!(writer, "{}", "=".repeat(60).dimmed())?;
100    writeln!(writer, "  {} {}", "UUID:".bold(), uuid.cyan())?;
101    writeln!(
102        writer,
103        "  {} {}  {} {}  {} {}",
104        "Fecha:".bold(),
105        cfdi.fecha,
106        "Tipo:".bold(),
107        tipo,
108        "Moneda:".bold(),
109        cfdi.moneda
110    )?;
111    if let Some(ref serie) = cfdi.serie {
112        write!(writer, "  {} {}", "Serie:".bold(), serie)?;
113        if let Some(ref folio) = cfdi.folio {
114            write!(writer, "  {} {}", "Folio:".bold(), folio)?;
115        }
116        writeln!(writer)?;
117    }
118    writeln!(writer)?;
119
120    writeln!(writer, "  {}", "EMISOR".bold().green())?;
121    writeln!(writer, "    RFC:    {}", cfdi.emisor.rfc)?;
122    if let Some(ref nombre) = cfdi.emisor.nombre {
123        writeln!(writer, "    Nombre: {nombre}")?;
124    }
125    writeln!(writer, "    Regimen: {}", cfdi.emisor.regimen_fiscal)?;
126    writeln!(writer)?;
127
128    writeln!(writer, "  {}", "RECEPTOR".bold().blue())?;
129    writeln!(writer, "    RFC:    {}", cfdi.receptor.rfc)?;
130    if let Some(ref nombre) = cfdi.receptor.nombre {
131        writeln!(writer, "    Nombre: {nombre}")?;
132    }
133    writeln!(writer, "    UsoCFDI: {}", cfdi.receptor.uso_cfdi)?;
134    writeln!(writer)?;
135
136    writeln!(writer, "  {}", "CONCEPTOS".bold().yellow())?;
137    for (i, c) in cfdi.conceptos.iter().enumerate() {
138        writeln!(
139            writer,
140            "    {}. {} (x{}) ${} = ${}",
141            i + 1,
142            c.descripcion,
143            c.cantidad,
144            c.valor_unitario,
145            c.importe
146        )?;
147    }
148    writeln!(writer)?;
149
150    writeln!(writer, "  {}", "TOTALES".bold().magenta())?;
151    writeln!(writer, "    SubTotal: ${}", cfdi.sub_total)?;
152    if let Some(ref desc) = cfdi.descuento {
153        writeln!(writer, "    Descuento: ${desc}")?;
154    }
155    if let Some(ref imp) = cfdi.impuestos {
156        if let Some(ref t) = imp.total_impuestos_trasladados {
157            writeln!(writer, "    IVA Trasladado: ${t}")?;
158        }
159        if let Some(ref t) = imp.total_impuestos_retenidos {
160            writeln!(writer, "    Impuestos Retenidos: ${t}")?;
161        }
162    }
163    writeln!(writer, "    {} ${}", "Total:".bold(), cfdi.total.bold())?;
164    writeln!(writer, "{}", "=".repeat(60).dimmed())?;
165
166    Ok(())
167}
168
169fn get_impuesto_trasladado(cfdi: &Comprobante, codigo: &str) -> String {
170    cfdi.impuestos
171        .as_ref()
172        .map(|imp| {
173            imp.traslados
174                .iter()
175                .filter(|t| t.impuesto == codigo)
176                .filter_map(|t| t.importe.as_deref())
177                .next()
178                .unwrap_or("0")
179                .to_string()
180        })
181        .unwrap_or_else(|| "0".to_string())
182}
183
184fn get_impuesto_retenido(cfdi: &Comprobante, codigo: &str) -> String {
185    cfdi.impuestos
186        .as_ref()
187        .map(|imp| {
188            imp.retenciones
189                .iter()
190                .filter(|r| r.impuesto == codigo)
191                .map(|r| r.importe.as_str())
192                .next()
193                .unwrap_or("0")
194                .to_string()
195        })
196        .unwrap_or_else(|| "0".to_string())
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::parser::parse_str;
203
204    fn sample_cfdi_xml() -> &'static str {
205        r#"<?xml version="1.0" encoding="utf-8"?>
206<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4" xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital" Version="4.0" Fecha="2024-01-15T10:30:00" Sello="abc" NoCertificado="001" Certificado="CERT" SubTotal="1000.00" Total="1160.00" TipoDeComprobante="I" Moneda="MXN" LugarExpedicion="44100">
207  <cfdi:Emisor Rfc="AAA010101AAA" Nombre="Test" RegimenFiscal="601"/>
208  <cfdi:Receptor Rfc="BBB020202BBB" Nombre="Cliente" UsoCFDI="G03"/>
209  <cfdi:Conceptos>
210    <cfdi:Concepto ClaveProdServ="84111506" Cantidad="1" ClaveUnidad="E48" Descripcion="Servicio" ValorUnitario="1000.00" Importe="1000.00" ObjetoImp="02"/>
211  </cfdi:Conceptos>
212</cfdi:Comprobante>"#
213    }
214
215    #[test]
216    fn test_format_json() {
217        let cfdi = parse_str(sample_cfdi_xml()).unwrap();
218        let mut buf = Vec::new();
219        format_json(&cfdi, &mut buf).unwrap();
220        let output = String::from_utf8(buf).unwrap();
221        assert!(output.contains("AAA010101AAA"));
222        assert!(output.contains("1160.00"));
223    }
224
225    #[test]
226    fn test_format_csv() {
227        let cfdi = parse_str(sample_cfdi_xml()).unwrap();
228        let mut wtr = csv::Writer::from_writer(Vec::new());
229        format_csv_header(&mut wtr).unwrap();
230        format_csv_row(&cfdi, &mut wtr).unwrap();
231        let data = String::from_utf8(wtr.into_inner().unwrap()).unwrap();
232        assert!(data.contains("AAA010101AAA"));
233        assert!(data.contains("1160.00"));
234    }
235}