
O Pix que não Pixava — String.fromCharCode, codepoints de controle e um BR Code invisível
⚡ O QR Code que todo mundo ignorava
Coloquei suporte a Pix no portfólio. Botão de doação, geração de BR Code, QR Code bonitinho na tela. Só tinha um problema: nenhum leitor de QR Code de banco reconhecia o código.
O mercadopago e o stripe funcionavam. Mas o Pix BR Code — que era pra ser o mais simples — simplesmente… não existia pros apps de banco. O QR Code aparecia, o escaneamento funcionava, mas o payload era rejeitado como “inválido” sem mensagem de erro.
Comecei a suspeitar que não era o QR Code em si, mas os bytes que estavam sendo gerados.
🧠 O formato EMV 2022
O BR Code (Pix) segue o padrão EMV® Merchant-Presented QR — uma especificação binária que define como codificar os dados de pagamento. Cada campo tem uma estrutura:
[Tag: 2 dígitos] + [Tamanho: 2 dígitos] + [Valor]
Exemplo pra chave Pix (email):
01 — tag "chave Pix"
12 — tamanho (12 caracteres)
[email protected] — o valor
O tamanho do campo é um número decimal com padding zero. Se o valor tem 12 caracteres, o campo de tamanho é "12", não o byte 0x0C.
E aí está o primeiro erro.
🔧 A luta — String.fromCharCode não é toString()
O código original usava:
const keyField = `01${String.fromCharCode(key.length)}${key}`;
String.fromCharCode(12) não retorna "12" — retorna o caractere Unicode cujo codepoint é 12, que é \x0C (Form Feed). Um caractere de controle invisível.
O EMV 2022 espera dígitos decimais. "12" (dois bytes: 0x31, 0x32), não "\x0C" (um byte: 0x0C).
O mesmo erro se repetia em 4 campos: chave Pix, Merchant Account Information, nome do merchant, e cidade. Todos usavam String.fromCharCode(length) pra gerar o campo de tamanho.
// Antes (ERRADO):
const keyField = `01${String.fromCharCode(key.length)}${key}`;
const maiLen = String.fromCharCode(mai.length);
const nameField = `59${String.fromCharCode(trimmedName.length)}${trimmedName}`;
const cityField = `60${String.fromCharCode(trimmedCity.length)}${trimmedCity}`;
// Depois (CORRETO):
const keyLen = String(key.length).padStart(2, "0");
const keyField = `01${keyLen}${key}`;
const maiLen = String(mai.length).padStart(2, "0");
const nameLen = String(trimmedName.length).padStart(2, "0");
const nameField = `59${nameLen}${trimmedName}`;
const cityLen = String(trimmedCity.length).padStart(2, "0");
const cityField = `60${cityLen}${trimmedCity}`;
A correção é trivial em retrospecto: String(n).padStart(2, "0") em vez de String.fromCharCode(n). Mas é o tipo de erro que o olho não pega em code review — o código parece certo, a lógica parece certa, mas o encoding está fundamentalmente quebrado.
O segundo erro: slice(0, -4)
Tinha também um segundo problema, mais sutil:
const crc = crc16(payload);
return payload.slice(0, -4) + crc; // ERRADO
O CRC16 do Pix é calculado incluindo o placeholder "6304" no payload. A spec diz que o CRC substitui o placeholder — mas isso significa que o placeholder fica no payload original, o CRC é calculado em cima dele, e o resultado substitui os 4 caracteres no final.
slice(0, -4) removia o placeholder antes de concatenar o CRC. O CRC calculado em cima do payload sem o placeholder não correspondia ao que os apps de banco esperavam.
const crc = crc16(payload);
return payload + crc; // CORRETO — placeholder "6304" já está no payload
💡 Resolução
Duas linhas que mudaram tudo:
String(key.length).padStart(2, "0")no lugar deString.fromCharCode(key.length)payload + crcno lugar depayload.slice(0, -4) + crc
Depois do fix, o BR Code foi reconhecido por 4 apps de banco diferentes na primeira tentativa.
📊 Métricas
| Métrica | Antes | Depois |
|---|---|---|
| App de banco que aceitam | 0/4 | 4/4 |
| Caracteres de controle no payload | 4 | 0 |
| Linhas do gerador Pix | 38 | 40 |
| Tempo de debug | ~2h | — |
🎯 Aprendizados
String.fromCharCodenão éNumber.toString— uma gera codepoints Unicode, a outra gera dígitos decimais. Parece óbvio, mas quando você tá codando um payload binário, a mente vai pro lugar errado.- Sempre teste com dados reais — o QR Code renderizava lindo no browser, mas só um app de banco de verdade revelou o problema.
- Leia a spec duas vezes — o erro do
slice(0, -4)veio de não entender completamente como o CRC do EMV 2022 funciona.