Resolución N° 5.616/2024. Más información

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.


Generar los archivos del Libro IVA Digital de ARCA en Ruby

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

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.

Conéctate a ARCA hoy mismo

Evitá la complejidad de ARCA. Con Afip SDK integrás tu sistema en minutos y te enfocás en hacer crecer tu negocio.