Uma caixa de recibos não precisa virar uma noite de digitação. Neste guia, vamos construir um fluxo mínimo que recebe a foto de um recibo, envia a imagem para o HiVision e devolve um objeto estruturado com estabelecimento, data, itens, total, confiança e avisos. Depois, vamos processar um lote e gerar um CSV aberto na planilha.

A ideia importante não é apenas extrair texto. É preservar a dúvida. Se uma sombra esconde o valor total, o sistema não deve inventar um número plausível. Deve marcar o campo como incerto e mandar a foto para revisão.

Primeiro, faça um recibo funcionar

Crie uma pasta vazia, salve o arquivo abaixo como read-receipt.mjs e defina a chave no ambiente:

export HINOW_API_KEY="sua-chave"
node read-receipt.mjs "https://seu-endereco/recibo.jpg"

O exemplo usa uma URL pública para a imagem porque a rota de leitura recebe a foto dentro de uma parte image_url. Em um produto real, você pode hospedar temporariamente o arquivo ou usar o armazenamento que já faz parte do seu sistema.

const imageUrl = process.argv[2];

if (!imageUrl) {
  throw new Error("Usage: node read-receipt.mjs IMAGE_URL");
}

const instruction = `
Leia este recibo e responda em JSON neste formato:
{
  "estabelecimento": "string",
  "data": "AAAA-MM-DD",
  "total": 0.0,
  "itens": [{ "descricao": "string", "valor": 0.0 }],
  "confianca": "alta|media|baixa",
  "avisos": ["string"]
}

Não complete informações que não estejam legíveis na imagem.
Se um campo estiver borrado, cortado ou ambíguo, use o melhor valor seguro apenas quando houver evidência clara;
caso contrário, registre a dúvida em avisos e reduza a confiança.
Responda em português do Brasil.
`;

const response = await fetch("https://api.hinow.ai/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.HINOW_API_KEY,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    model: "hinow/hivision",
    messages: [{
      role: "user",
      content: [
        { type: "text", text: instruction },
        { type: "image_url", image_url: { url: imageUrl } }
      ]
    }],
    response_format: { type: "json_object" },
    temperature: 0
  })
});

if (!response.ok) {
  throw new Error(await response.text());
}

const data = await response.json();
const receipt = JSON.parse(data.choices[0].message.content);

console.log(JSON.stringify(receipt, null, 2));

A instrução pede uma estrutura previsível, enquanto JSON.parse valida o conteúdo retornado antes de importar. response_format solicita uma resposta em JSON; ainda assim, valide o conteúdo antes de usar. Se a resposta não puder ser interpretada, o script falha em vez de inserir uma linha silenciosamente incompleta.

O esquema também faz uma distinção essencial. total é o dado que você quer usar; confianca e avisos dizem se é seguro usá-lo. Um recibo torto pode ser lido corretamente. Um número borrado pode parecer correto e estar errado. Esse segundo caso é mais perigoso porque passa pela planilha sem chamar atenção.

Depois, transforme várias fotos em CSV

Neste exemplo, o script recebe URLs na linha de comando, faz uma chamada por imagem e imprime um CSV com uma linha por item. O exemplo inclui no resultado recibos marcados como alta ou média e envia os de baixa para revisão. Isso é uma regra implementada pelo código, não uma garantia de que as linhas incluídas estejam corretas.

Salve como receipts-to-csv.mjs:

function escapeCsv(value) {
  const text = String(value ?? "");
  return `"${text.replaceAll('"', '""')}"`;
}

async function readReceipt(imageUrl) {
  const instruction = `
Leia este recibo e responda em JSON neste formato:
{
  "estabelecimento": "string",
  "data": "AAAA-MM-DD",
  "total": 0.0,
  "itens": [{ "descricao": "string", "valor": 0.0 }],
  "confianca": "alta|media|baixa",
  "avisos": ["string"]
}
Não invente texto ou números ilegíveis. Coloque dúvidas em avisos e responda em português do Brasil.
`;

  const response = await fetch("https://api.hinow.ai/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.HINOW_API_KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      model: "hinow/hivision",
      messages: [{
        role: "user",
        content: [
          { type: "text", text: instruction },
          { type: "image_url", image_url: { url: imageUrl } }
        ]
      }],
      response_format: { type: "json_object" },
      temperature: 0
    })
  });

  if (!response.ok) {
    throw new Error(await response.text());
  }

  const data = await response.json();
  const content = data.choices[0].message.content;
  return JSON.parse(content);
}

const imageUrls = process.argv.slice(2);
if (imageUrls.length === 0) {
  throw new Error("Usage: node receipts-to-csv.mjs IMAGE_URL...");
}

const rows = [];
const review = [];

for (const imageUrl of imageUrls) {
  const receipt = await readReceipt(imageUrl);

  if (receipt.confianca === "baixa") {
    review.push({ imageUrl, avisos: receipt.avisos });
    continue;
  }

  for (const item of receipt.itens ?? []) {
    rows.push([
      receipt.estabelecimento,
      receipt.data,
      item.descricao,
      item.valor,
      receipt.total,
      receipt.confianca,
      imageUrl
    ]);
  }
}

console.log([
  ["estabelecimento", "data", "descricao", "valor_item", "total_recibo", "confianca", "imagem"],
  ...rows
].map(row => row.map(escapeCsv).join(",")).join("\n"));

if (review.length > 0) {
  console.error("\nRevisão humana necessária:");
  console.error(JSON.stringify(review, null, 2));
}

O for sequencial prioriza simplicidade; execuções paralelas podem reduzir o tempo, mas exigem controle de concorrência. Para um protótipo, acompanhar uma imagem por vez facilita descobrir se o problema está na URL, na leitura ou na conversão para CSV.

A conta que decide se vale automatizar

Considere o cenário hipotético usado aqui: digitar um recibo leva 2 minutos. 300 recibos somam 600 minutos, ou 10 horas. Se o fluxo mandar 15% das fotos para revisão e cada revisão levar 2 minutos, a parte humana cai para cerca de 1 hora e meia.

Esses valores são premissas de uma simulação, não uma medição universal. O tempo cresce com uma chamada por recibo e depende da latência do serviço, do tamanho das imagens e da forma como o lote é processado.

Valide antes de confiar no total

Não comece por 300 recibos. Separe um lote pequeno com fotos nítidas, tortas, amassadas, sombreadas e parcialmente ilegíveis. Some manualmente os totais e compare com a soma dos registros extraídos.

A validação precisa olhar especialmente para números. Um nome de estabelecimento errado costuma ser fácil de encontrar. Um total errado que parece normal pode contaminar relatórios, reembolsos e decisões financeiras. Confira também se a soma dos itens faz sentido diante do total do recibo, lembrando que impostos, descontos e taxas podem impedir uma igualdade simples.

Neste fluxo, o campo de confiança serve para direcionar a conferência; ele não substitui a validação. A amostragem manual continua necessária mesmo entre os registros marcados como alta ou média.

O próximo passo é enriquecer, não apagar a trilha

Depois que o CSV estiver correto, você pode adicionar uma segunda etapa para categorizar despesas, por exemplo separando alimentação, transporte e materiais. Faça isso sobre os dados já extraídos, não sobre a foto novamente. O modelo de linguagem recebe uma tarefa menor e o sistema mantém a imagem original ao lado de cada linha.

Também vale guardar o JSON bruto. O CSV é ótimo para abrir na planilha, mas perde parte da estrutura, principalmente a lista de avisos. Manter os dois formatos permite corrigir uma classificação depois sem fotografar tudo de novo.

O objetivo do fluxo é tornar os erros visíveis antes de incorporá-los aos dados oficiais. A caixa de recibos deixa de ser apenas um problema de transcrição quando cada linha carrega o valor lido, a indicação de confiança e o caminho para a imagem original.