Generar los archivos del Libro IVA Digital de ARCA en Ruby
Generá en Ruby los TXT de ventas y alícuotas que acepta Portal IVA, con ancho fijo y codificación Windows-1252.
En esta guía vamos a generar desde Ruby los dos archivos de ventas que acepta la importación de Portal IVA:
LIBRO_IVA_DIGITAL_VENTAS_CBTE.txt, con una línea de 266 bytes por comprobante;LIBRO_IVA_DIGITAL_VENTAS_ALICUOTAS.txt, con una línea de 62 bytes por cada alícuota.
Este ejemplo es para responsables inscriptos que importan ventas en IVA Simple. Los sujetos exentos tienen un diseño específico, sin este archivo de alícuotas, y no deben usar el script sin adaptarlo.
Si primero querés entender qué es el Libro IVA, cómo se relaciona con IVA Simple y por qué el ZIP de duplicados electrónicos es diferente, leé la guía general:
Cómo generar los archivos del Libro IVA Digital de ARCA
Qué es el Libro IVA Digital, qué archivos acepta Portal IVA y cómo generar e importar el Libro IVA Ventas desde tu sistema.
1. Preparar los datos
El ejemplo usa una Factura B por $121: $100 de neto gravado y $21 de IVA. Los importes se guardan en centavos enteros, no como números decimales.
vouchers = [
{
date: "20260701",
voucher_type: 6, # 006 = Factura B
point_of_sale: "1",
number: "123",
number_to: "123",
buyer_document_type: 80, # 80 = CUIT
buyer_document_number: "30712345678",
buyer_name: "CLIENTE MUÑOZ S.A.",
total_cents: 12_100,
non_taxed_cents: 0,
uncategorized_perception_cents: 0,
exempt_cents: 0,
national_tax_perception_cents: 0,
gross_income_perception_cents: 0,
municipal_perception_cents: 0,
internal_tax_cents: 0,
currency: "PES",
exchange_rate_micros: 1_000_000, # 1,000000
operation_code: "0",
other_taxes_cents: 0,
due_date: "00000000",
vat_breakdown: [
{
net_cents: 10_000,
rate_code: 5, # 0005 = 21%
tax_cents: 2_100
}
]
}
]
Podés agregar más comprobantes al arreglo. El generador mantiene el mismo orden en ambos archivos.
2. Generar los registros de ancho fijo
El ejemplo no requiere gems externas. Creá un archivo generar_libro_iva.rb con el siguiente contenido completo:
def windows_1252(value, name)
String(value).encode(Encoding::WINDOWS_1252)
rescue Encoding::InvalidByteSequenceError,
Encoding::UndefinedConversionError
raise ArgumentError,
"#{name} contiene caracteres incompatibles con Windows-1252"
end
def numeric_field(value, width, name)
text = String(value)
unless text.match?(/\A\d+\z/)
raise ArgumentError, "#{name} debe contener solamente dígitos"
end
if text.length > width
raise ArgumentError, "#{name} supera las #{width} posiciones"
end
text.rjust(width, "0")
end
def zero_padded_alphanumeric_field(value, width, name)
text = String(value).strip.upcase
unless text.match?(/\A[0-9A-Z]+\z/)
raise ArgumentError, "#{name} debe ser alfanumérico"
end
byte_length = windows_1252(text, name).bytesize
if byte_length > width
raise ArgumentError, "#{name} supera las #{width} posiciones"
end
("0" * (width - byte_length)) + text
end
def text_field(value, width, name)
text = String(value)
.gsub(/[\r\n\t]/, " ")
.gsub(/\s+/, " ")
.strip
.upcase
byte_length = windows_1252(text, name).bytesize
if byte_length > width
raise ArgumentError, "#{name} supera las #{width} posiciones"
end
text + (" " * (width - byte_length))
end
def amount_field(cents, name)
unless cents.is_a?(Integer)
raise ArgumentError,
"#{name} debe expresarse en centavos enteros"
end
absolute = cents.abs.to_s
digit_width = cents.negative? ? 14 : 15
if absolute.length > digit_width
raise ArgumentError, "#{name} supera las 15 posiciones"
end
digits = absolute.rjust(digit_width, "0")
cents.negative? ? "-#{digits}" : digits
end
def build_record(parts, expected_length, name)
record = parts.join
byte_length = windows_1252(record, name).bytesize
if byte_length != expected_length
raise ArgumentError,
"#{name} mide #{byte_length}; " \
"ARCA exige #{expected_length}"
end
record
end
def validate_voucher(voucher)
vat_count = voucher.fetch(:vat_breakdown).length
unless vat_count.between?(1, 9)
raise ArgumentError,
"La cantidad de alícuotas debe estar entre 1 y 9"
end
vat_total = voucher.fetch(:vat_breakdown).sum do |vat|
vat.fetch(:net_cents) + vat.fetch(:tax_cents)
end
calculated_total =
voucher.fetch(:non_taxed_cents) +
voucher.fetch(:uncategorized_perception_cents) +
voucher.fetch(:exempt_cents) +
voucher.fetch(:national_tax_perception_cents) +
voucher.fetch(:gross_income_perception_cents) +
voucher.fetch(:municipal_perception_cents) +
voucher.fetch(:internal_tax_cents) +
voucher.fetch(:other_taxes_cents) +
vat_total
return if calculated_total == voucher.fetch(:total_cents)
raise ArgumentError,
"El total informado (#{voucher.fetch(:total_cents)}) " \
"no coincide con sus componentes (#{calculated_total})"
end
def sales_voucher_record(voucher)
validate_voucher(voucher)
build_record(
[
numeric_field(voucher.fetch(:date), 8, "Fecha"),
numeric_field(
voucher.fetch(:voucher_type),
3,
"Tipo de comprobante"
),
numeric_field(
voucher.fetch(:point_of_sale),
5,
"Punto de venta"
),
numeric_field(
voucher.fetch(:number),
20,
"Número de comprobante"
),
numeric_field(
voucher.fetch(:number_to),
20,
"Número de comprobante hasta"
),
numeric_field(
voucher.fetch(:buyer_document_type),
2,
"Tipo de documento"
),
zero_padded_alphanumeric_field(
voucher.fetch(:buyer_document_number),
20,
"Documento"
),
text_field(
voucher.fetch(:buyer_name),
30,
"Nombre del comprador"
),
amount_field(voucher.fetch(:total_cents), "Importe total"),
amount_field(
voucher.fetch(:non_taxed_cents),
"Conceptos no gravados"
),
amount_field(
voucher.fetch(:uncategorized_perception_cents),
"Percepción a no categorizados"
),
amount_field(
voucher.fetch(:exempt_cents),
"Operaciones exentas"
),
amount_field(
voucher.fetch(:national_tax_perception_cents),
"Percepciones nacionales"
),
amount_field(
voucher.fetch(:gross_income_perception_cents),
"Percepciones de Ingresos Brutos"
),
amount_field(
voucher.fetch(:municipal_perception_cents),
"Percepciones municipales"
),
amount_field(
voucher.fetch(:internal_tax_cents),
"Impuestos internos"
),
text_field(voucher.fetch(:currency), 3, "Moneda"),
numeric_field(
voucher.fetch(:exchange_rate_micros),
10,
"Tipo de cambio"
),
numeric_field(
voucher.fetch(:vat_breakdown).length,
1,
"Cantidad de alícuotas"
),
text_field(
voucher.fetch(:operation_code),
1,
"Código de operación"
),
amount_field(
voucher.fetch(:other_taxes_cents),
"Otros tributos"
),
numeric_field(
voucher.fetch(:due_date),
8,
"Fecha de vencimiento"
)
],
266,
"Registro de comprobante"
)
end
def sales_vat_record(voucher, vat)
build_record(
[
numeric_field(
voucher.fetch(:voucher_type),
3,
"Tipo de comprobante"
),
numeric_field(
voucher.fetch(:point_of_sale),
5,
"Punto de venta"
),
numeric_field(
voucher.fetch(:number),
20,
"Número de comprobante"
),
amount_field(vat.fetch(:net_cents), "Neto gravado"),
numeric_field(vat.fetch(:rate_code), 4, "Alícuota"),
amount_field(vat.fetch(:tax_cents), "Impuesto liquidado")
],
62,
"Registro de alícuota"
)
end
def write_windows_1252_file(path, records)
if records.empty?
raise ArgumentError, "#{path} no contiene registros"
end
content = records.join("\r\n") + "\r\n"
File.binwrite(path, windows_1252(content, path))
end
vouchers = [
{
date: "20260701",
voucher_type: 6,
point_of_sale: "1",
number: "123",
number_to: "123",
buyer_document_type: 80,
buyer_document_number: "30712345678",
buyer_name: "CLIENTE MUÑOZ S.A.",
total_cents: 12_100,
non_taxed_cents: 0,
uncategorized_perception_cents: 0,
exempt_cents: 0,
national_tax_perception_cents: 0,
gross_income_perception_cents: 0,
municipal_perception_cents: 0,
internal_tax_cents: 0,
currency: "PES",
exchange_rate_micros: 1_000_000,
operation_code: "0",
other_taxes_cents: 0,
due_date: "00000000",
vat_breakdown: [
{
net_cents: 10_000,
rate_code: 5,
tax_cents: 2_100
}
]
}
]
voucher_records = vouchers.map do |voucher|
sales_voucher_record(voucher)
end
vat_records = vouchers.flat_map do |voucher|
voucher.fetch(:vat_breakdown).map do |vat|
sales_vat_record(voucher, vat)
end
end
write_windows_1252_file(
"LIBRO_IVA_DIGITAL_VENTAS_CBTE.txt",
voucher_records
)
write_windows_1252_file(
"LIBRO_IVA_DIGITAL_VENTAS_ALICUOTAS.txt",
vat_records
)
puts "Generados #{voucher_records.length} comprobantes " \
"y #{vat_records.length} alícuotas"
Ejecutalo con:
ruby generar_libro_iva.rb
3. Validar antes de importar
El script corta la ejecución si:
- un campo numérico contiene caracteres no permitidos;
- el documento del comprador no es alfanumérico;
- un valor supera el ancho oficial;
- un texto contiene caracteres incompatibles con Windows-1252;
- una cabecera no mide exactamente 266 bytes;
- una alícuota no mide exactamente 62 bytes;
- el total no coincide con el neto, IVA y demás conceptos;
- un importe no fue entregado en centavos enteros;
- se intenta crear un archivo sin registros.
También escribe finales de línea CRLF y genera archivos Windows-1252 sin BOM.
Casos que requieren adaptar el ejemplo
- Varias alícuotas: agregá un hash por alícuota en
vat_breakdown. - Operaciones exentas o no gravadas: usá el código de operación y la alícuota que indique la tabla oficial.
- Notas de crédito: informá su tipo de comprobante y no inviertas los importes automáticamente.
- Moneda extranjera: mantené consistente la moneda, el tipo de cambio y la opción elegida al importar.
- TURIVA, importaciones y comprobantes anulados: usá sus diseños específicos.
Otras versiones
Consultá siempre el diseño de registros, las validaciones y las tablas del sistema vigentes antes de generar archivos productivos.