Ir para o conteúdo

Pagamentos, fiscal & contato BR

Os quatro trilhos que todo produto brasileiro acaba precisando — Pix, boleto, chave de acesso da NFe e feriados / dias úteis — mais o canal por onde tudo isso chega ao cliente: WhatsApp. Tudo puro TypeScript, sem nenhuma dependência nova — a fatia inteira mede 9,22 KB brotli, só Pix (payload + CRC + componente de QR) mede 6,1 KB, e o deep link do WhatsApp mede 223 B.

Import pelo subpath tempest-react-sdk/br

Igual ao resto do módulo BR, estes helpers vivem em tempest-react-sdk/br — não na raiz. Quem não importa, não paga.

import { pixPayload, parseLinhaDigitavel, parseChaveNFe, isBusinessDay, whatsAppUrl } from "tempest-react-sdk/br";

O SDK não fala com banco nenhum

Nada aqui consulta o Bacen, a SEFAZ ou uma API de PSP. São codificadores e validadores locais: eles montam a string certa, conferem os dígitos verificadores e leem os campos. Se um boleto existe, está registrado ou já foi pago — só o banco responde. Se uma NF-e foi autorizada — só a SEFAZ.


Pagamentos BR (Pix · boleto · NFe) na gallery

Seção br-payments da gallery — rode localmente para interagir.

Parte 1 — Pix

O problema

Um QR de Pix não é um QR "de link". É um payload EMV MPM (o mesmo padrão da EMVCo que o Bacen adotou no "Manual de Padrões para Iniciação do Pix"): uma lista de triplas ID + tamanho de 2 dígitos + valor, terminada por um CRC-16.

Escrever isso na mão dá errado sempre no mesmo lugar — o checksum. Ele é CRC-16/CCITT-FALSE (polinômio 0x1021, valor inicial 0xFFFF) calculado sobre todo o payload anterior mais os literais 6304, ou seja, incluindo o cabeçalho da própria tag 63. Errar isso produz um QR que abre no app e falha na leitura.

O caminho curto: <PixQRCode>

import { PixQRCode } from "tempest-react-sdk/br";

export function CheckoutPix() {
  return (
    <PixQRCode
      pix={{
        key: "loja@tempest.dev",
        merchantName: "Loja Tempest",
        merchantCity: "São Paulo",
        amount: 25.5,
        txid: "PEDIDO123",
      }}
      amountLabel="R$ 25,50"
      payeeLabel="Loja Tempest"
    />
  );
}

Isso renderiza o símbolo e a linha copia-e-cola com botão de copiar. As duas coisas juntas não é enfeite:

Nunca mostre só o QR

Num checkout mobile, o QR aparece no mesmo aparelho que iria escaneá-lo. Sem a string copiável, o usuário fica travado. É o erro mais comum de tela de Pix.

O payload é montado no navegador e o QR é desenhado pelo encoder próprio do SDK (o mesmo do QRCode). Chave, valor e txid não saem da página — um serviço de imagem de QR receberia os três.

Quando o payload vem pronto do PSP (cobrança dinâmica assinada), passe direto:

<PixQRCode payload={charge.brcode} level="Q" size={220} />

pix e payload são mutuamente exclusivos

Passar os dois lança PixError. Prefira payload para cobrança dinâmica: aquela string foi o PSP que emitiu, e remontá-la aqui só cria um jeito de errar.

pixPayload — a string, sem UI

import { pixPayload } from "tempest-react-sdk/br";

const payload = pixPayload({
  key: "12345678909",
  merchantName: "Loja Tempest",
  merchantCity: "Sao Paulo",
  amount: 25.5,
  txid: "PEDIDO123",
});

// 00020101021126330014br.gov.bcb.pix0111123456789095204000053039865405
// 25.505802BR5912Loja Tempest6009Sao Paulo62130509PEDIDO1236304D68C

Campos aceitos:

Campo Tag Obrigatório Nota
key 26 / 01 ✅ CPF, CNPJ, e-mail, telefone ou EVP. Validada e normalizada.
merchantName 59 ✅ Máx. 25 caracteres. Acima disso, erro.
merchantCity 60 ✅ Máx. 15 caracteres.
amount 54 — Reais. Omita para o pagador digitar o valor.
txid 62 / 05 — [A-Za-z0-9]{1,25}. Default ***.
description 26 / 02 — Texto livre que alguns apps mostram.
postalCode 61 — CEP, só dígitos.
oneTime 01 — true → tag 12 (uso único) em vez de 11.

Estático × dinâmico

A distinção muda o que liquida, então não é cosmética:

A tag 26 carrega a chave. O QR é autocontido, pode ser impresso e reusado. Se amount for omitido, liquida com o valor que o pagador digitou.

pixPayload({
  key: "loja@tempest.dev",
  merchantName: "Tempest",
  merchantCity: "Belo Horizonte",
});

A tag 26 carrega uma URL (payloadLocation), e a carteira busca valor e recebedor no PSP. Liquida com o que o PSP serviu. Default: uso único.

pixPayload({
  kind: "dynamic",
  url: "pix.example.com/qr/v2/abc123",
  merchantName: "Tempest",
  merchantCity: "Recife",
});

Cobrança que precisa reconciliar centavo a centavo pede dinâmico

Um QR estático sem valor liquida o que o pagador digitou — inclusive R$ 1,00 numa fatura de R$ 100,00.

Chaves: validação e a armadilha dos 11 dígitos

import { normalizePixKey, pixKeyType } from "tempest-react-sdk/br";

pixKeyType("123.456.789-09"); // "cpf"
pixKeyType("11.222.333/0001-81"); // "cnpj"
pixKeyType("loja@tempest.dev"); // "email"
pixKeyType("+5511987654321"); // "phone"
pixKeyType("123e4567-e89b-12d3-a456-426614174000"); // "evp"
pixKeyType("não é chave"); // null

normalizePixKey("(11) 98765-4321"); // { type: "phone", value: "+5511987654321" }

CPF e CNPJ passam por validateCPF / validateCNPJ (os mesmos de Forms BR) — chave com dígito verificador errado é rejeitada com PixError.

CPF e celular nacional têm os dois 11 dígitos

"11987654321" é um celular válido e poderia ser um CPF. O desempate é o dígito verificador: 11 dígitos com DV válido viram "cpf", o resto vira "phone". Passe telefone como +5511987654321 e a ambiguidade desaparece.

Acentos

O conjunto de caracteres do BR Code não tem acento. O SDK remove os diacríticos e rejeita o que sobrar fora do ASCII imprimível:

pixPayload({ key: "…", merchantName: "Padaria Açúcar", merchantCity: "São Paulo" });
// → "5914Padaria Acucar" … "6009Sao Paulo"

pixPayload({ key: "…", merchantName: "Loja ✅", merchantCity: "Recife" });
// → PixError: merchantName has characters the BR Code cannot carry

Por que remover em vez de lançar

"São Paulo" é o nome real da cidade e o payload não pode carregá-lo. Um QR que não escaneia é pior do que um nome sem cedilha. Já um emoji é erro de quem chamou, e esse aparece.

Lendo um payload de volta

import { parsePixPayload } from "tempest-react-sdk/br";

const data = parsePixPayload(colado);
data.key; // "12345678909"
data.amount; // 25.5
data.txid; // "PEDIDO123"
data.crcValid; // true
data.fields; // toda TLV, na ordem, inclusive as desconhecidas

É tolerante com tag desconhecida — PSPs adicionam templates próprios, e um leitor que rejeita isso não serve em produção. O que não é tolerado: frame quebrado (tamanho que passa do fim, 6304 ausente) e CRC divergente, que lança. Para inspecionar um payload que você já sabe estar corrompido:

parsePixPayload(colado, { requireCrc: false }); // crcValid: false, sem lançar

CRC divergente não é aviso

Um BR Code com checksum errado foi corrompido no caminho, e a conta que ele aponta agora não é a conta que o recebedor publicou. Por isso o default lança.

O CRC detecta corrupção, não autoria: quem monta um payload recalcula o checksum, então um CRC válido não diz quem publicou a conta. Por isso o leitor também recusa payload que admite duas leituras — a prévia mostraria uma, e a carteira do banco pode pagar a outra:

Recusado (PixError) Por quê
tag repetida na raiz (54, 59, 26, …) o manual do BR Code do Bacen, citando o EMV® QRCPS-MPM: não há ID duplicado na raiz
subtag repetida dentro da 26 ou da 62 (duas chaves 01, dois txid 05) mesma ambiguidade um nível abaixo; nenhum emissor conforme repete
tag 63 antes do fim (um 6304 + CRC no meio, seguido de mais tags e de outro 6304 + CRC) o EMV® QRCPS-MPM, que o manual do Bacen segue, fixa a 63 como último objeto; cortado no primeiro CRC, o mesmo texto é outro payload válido, sem a 54 que vem depois
primeira tag diferente de 00 (Payload Format Indicator) o mesmo EMV fixa a 00 como primeiro objeto; as demais tags da raiz podem vir em qualquer ordem
tag 26 com chave (01) e URL (25) o BR Code é estático ou dinâmico, não os dois
tag 54 fora do formato EMV: 1e3, -1, ,5, 0x10, 1.005, mais de 13 caracteres só dígitos e um . decimal, até duas casas (BRL) — o que Number() leria como 1000, -1, NaN ou 16

"98" e "98." continuam valendo: o EMV permite o . sem casas decimais. Zero ("0", "0.00") é lido como amount: 0, porque o manual do BR Code do Bacen traz "0" como exemplo da tag 54 — o leitor é tolerante com o que a spec do Bacen aceita. O pixPayload continua recusando valor não positivo — conferido depois do arredondamento, então 0.004 lança em vez de emitir "0.00" — e recusa texto fora do conjunto de caracteres comum do EMV (ASCII imprimível), como uma chave joão@exemplo.com — nome, cidade e descrição perdem o acento antes da checagem ("São Paulo" → "Sao Paulo", caixa preservada) e só lançam se sobrar emoji ou CJK; a chave não é transliterada, porque outra grafia é outra chave: o tamanho de cada TLV conta caracteres, e só nesse conjunto caractere, unidade UTF-16 e byte coincidem. Na leitura, o tamanho é contado em caracteres, para um template de outro PSP com texto não ASCII não desalinhar as tags seguintes.

pixCrc16, se você precisa do checksum sozinho

import { pixCrc16 } from "tempest-react-sdk/br";

pixCrc16("123456789"); // "29B1" — o check value publicado do CRC-16/CCITT-FALSE
pixCrc16(payload.slice(0, -4)); // os 4 hex que fecham o payload

Parte 2 — Boleto

Dois layouts, o mesmo tamanho

O código de barras tem 44 dígitos nos dois casos, e é aí que mora o bug:

Primeiro dígito Linha digitável Uso
Cobrança ("banco") ≠ 8 47 dígitos Boleto de banco contra uma fatura
Arrecadação ("arrecadacao") 8 48 dígitos Concessionária, tributo, multa

Não são variantes de um formato: todo campo muda de posição e de significado. O SDK detecta pelo primeiro dígito e devolve uma união discriminada — estreite por kind antes de ler.

Ler o que o leitor de código de barras entregou

import { parseCodigoBarras, parseLinhaDigitavel } from "tempest-react-sdk/br";

const boleto = parseCodigoBarras("34191157000001234560000123456789012345678901");

if (boleto.kind === "banco") {
  boleto.banco; // "341"
  boleto.valor; // 1234.56
  boleto.vencimento; // Date — 2026-09-15
  boleto.linhaDigitavel; // 47 dígitos, já com os DVs
} else {
  boleto.segmentoLabel; // "Órgãos governamentais"
  boleto.valor; // reais, ou null quando o campo é referência
  boleto.empresa; // código FEBRABAN, ou prefixo do CNPJ no segmento 6
}

parseLinhaDigitavel faz o caminho inverso e aceita as duas linhas (47 ou 48). Os dois parsers conferem todos os dígitos verificadores que o layout permite conferir: os 3 (banco) ou 4 (arrecadação) DVs de bloco, mais o DV geral. Qualquer um errado lança BoletoError dizendo qual.

Conversão explícita, quando você só quer a outra representação:

import { codigoBarrasToLinhaDigitavel, linhaDigitavelToCodigoBarras } from "tempest-react-sdk/br";

linhaDigitavelToCodigoBarras("34190000172345678901723456789017115700000123456");
// "34191157000001234560000123456789012345678901"

Validar entrada digitada

import { formatLinhaDigitavel, validateBoleto } from "tempest-react-sdk/br";

validateBoleto(input); // true / false, sem lançar
formatLinhaDigitavel(input);
// "34190.00017 23456.789017 23456.789017 1 15700000123456"

formatLinhaDigitavel é helper de exibição: entrada que não tem 47 nem 48 dígitos volta intacta.

Só o layout, sem parsear: boletoKind

Para ramificar a UI antes de validar (mostrar o campo certo, escolher o ícone), boletoKind diz o layout a partir do tamanho e do primeiro dígito, e devolve null para entrada que não é boleto — sem lançar:

import { boletoKind } from "tempest-react-sdk/br";

boletoKind("34191157000001234560000123456789012345678901"); // "banco"
boletoKind("848900000017..."); // "arrecadacao"
boletoKind("123"); // null

E boletoDueDate(fator, options?) resolve o fator de vencimento isolado — útil quando o fator veio de outro sistema e você não tem o código de barras inteiro. Devolve { date, epoch } (dizendo qual base foi usada) ou null quando o fator é 0, que é o valor de "sem vencimento":

import { boletoDueDate } from "tempest-react-sdk/br";

boletoDueDate(1000, { epoch: "legacy" }); // { date: 2000-07-03, epoch: "legacy" }
boletoDueDate(1000, { epoch: "current" }); // { date: 2025-02-22, epoch: "current" }
boletoDueDate(0); // null

O epoch de volta importa: ele diz qual das duas leituras ambíguas (abaixo) saiu, o que é a diferença entre exibir a data e exibir a data certa.

A virada do fator de vencimento (fev/2025)

O vencimento não está no boleto como data — está como fator de vencimento, quatro dígitos contando dias desde uma data-base. E essa data-base mudou:

  • Base original 07/10/1997. O campo saturou em 9999 no dia 21/02/2025.
  • A partir de 22/02/2025 o contador reiniciou em 1000 sobre a nova base 29/05/2022 (comunicado FEBRABAN FB-009/2023).

As duas leituras são genuinamente ambíguas

Todo fator entre 1000 e 9999 tem uma leitura em cada base — a antiga cai em 2000-07-03 … 2025-02-21, a nova em 2025-02-22 … 2049-10-14. Nada no código de barras diz qual.

O default "auto" escolhe a que cai mais perto de reference (hoje, por default). Isso acerta o caso que importa — um boleto sendo pago agora — e erra numa varredura de arquivo histórico. Quando você sabe, diga:

parseCodigoBarras(barcode, { epoch: "current" }); // base 29/05/2022
parseCodigoBarras(barcode, { epoch: "legacy" }); // base 07/10/1997
parseCodigoBarras(barcode, { reference: new Date(2025, 5, 1) });

O campo resolvido vem acompanhado de qual base foi usada, então a UI pode avisar:

const boleto = parseCodigoBarras(barcode);
if (boleto.kind === "banco") {
  boleto.fatorVencimento; // 1570
  boleto.vencimentoEpoch; // "current"
  boleto.vencimento; // Date, ou null quando o fator é 0 (boleto sem vencimento)
}

Para emitir, o inverso:

import { fatorVencimento } from "tempest-react-sdk/br";

fatorVencimento(new Date(2025, 1, 22)); // 1000 — base atual
fatorVencimento(new Date(2025, 1, 21), "legacy"); // 9999
fatorVencimento(new Date(2020, 0, 1)); // BoletoError — nenhum fator representa isso

Arrecadação: o que é lido e o que não é

A posição 3 diz duas coisas ao mesmo tempo — se o valor é dinheiro e qual módulo calcula o DV geral:

Posição 3 Valor DV geral
6 Reais módulo 10
7 Quantidade de moeda / referência módulo 10
8 Reais módulo 11
9 Quantidade de moeda / referência módulo 11

Com 7 ou 9, valor vem null e o campo cru fica em valorRaw — o SDK não finge que uma referência é dinheiro. Posição 3 fora de 6-9 é rejeitada em vez de lida errado.

vencimentoCampoLivre é pista, não data de liquidação

O layout diz que uma data de vencimento, se existir, ocupa os 8 primeiros dígitos do campo livre como AAAAMMDD. Mas o campo é opcional e nada marca sua presença, então um campo livre que só parece data também cai ali. Use na UI; nunca para liquidar.

Os três dígitos verificadores, exportados

Aparecem em qualquer integração FEBRABAN, então estão no barrel:

import { mod10Dac, mod11DacArrecadacao, mod11DacCobranca } from "tempest-react-sdk/br";

mod10Dac("01230067896"); // 3 — o exemplo resolvido do layout FEBRABAN v7

Módulo 11 de cobrança ≠ módulo 11 de arrecadação

Mesmos pesos, mesma subtração — e regras diferentes para os restos degenerados. Cobrança resolve resto 0, 1 e 10 para 1; arrecadação resolve resto 0 e 1 para 0. Usar um no layout do outro dá dígito errado exatamente 3 vezes em 11, o que passa em teste feito com uma amostra pequena. São funções separadas por isso.


Parte 3 — Chave de acesso da NFe

Os 44 dígitos que identificam qualquer documento fiscal eletrônico:

35 2601 12345678000195 55 001 000000123 1 12345678 5
cUF AAMM CNPJ           mod série nNF    tp cNF     cDV
import { formatChaveNFe, parseChaveNFe, validateChaveNFe } from "tempest-react-sdk/br";

validateChaveNFe(input); // true / false

const chave = parseChaveNFe("35260112345678000195550010000001231123456785");
chave.uf; // "SP" — o tipo UF do próprio módulo br
chave.ano; // 2026
chave.mes; // 1
chave.cnpj; // "12345678000195"
chave.modeloLabel; // "NF-e"
chave.serie; // "001"
chave.numero; // "000000123"
chave.tipoEmissaoLabel; // "Normal"

chave.dv; // "5"

formatChaveNFe("35260112345678000195550010000001231123456785");
// "3526 0112 3456 7800 0195 5500 1000 0001 2311 2345 6785"

validateChaveNFe confere três coisas: 44 dígitos, cUF que é uma UF de verdade, e DV que recalcula (módulo 11, pesos 2–9 da direita para a esquerda, resto 0 ou 1 → dígito 0).

O layout é compartilhado — modelo é o que diz o que você tem na mão

NF-e (55), NFC-e (65), CT-e (57), MDF-e (58), BP-e (63), NF3e (66)… todos usam a mesma chave de 44 dígitos. Modelo fora da tabela vem com modeloLabel: null em vez de um chute.

O cUF vira o tipo UF

chave.uf é o mesmo union UF que citiesByUf, getState e o BrazilMap usam — dá para encadear direto com o resto do módulo BR.

Emitindo: calcular o DV e tratar o erro

Quem monta a chave (em vez de só ler uma pronta) precisa do dígito verificador dos 43 primeiros dígitos. chaveNFeCheckDigit faz esse cálculo isolado, e lança ChaveNFeError quando o corpo não tem exatamente 43 dígitos:

import { ChaveNFeError, chaveNFeCheckDigit } from "tempest-react-sdk/br";

const corpo = "3526011234567800019555001000000123112345678"; // 43 dígitos

try {
  const dv = chaveNFeCheckDigit(corpo); // 5
  const chave = `${corpo}${dv}`;
} catch (err) {
  if (err instanceof ChaveNFeError) console.error(err.message);
}

ChaveNFeError é a única exceção do grupo NFe — validateChaveNFe continua devolvendo false em vez de lançar, porque validar entrada de usuário não é caso excepcional.


Parte 4 — Feriados e dias úteis

O que está na tabela

import { holidaysFor } from "tempest-react-sdk/br";

holidaysFor(2026);
// 13 entradas: 9 feriados nacionais + 4 dias móveis do sistema financeiro

Cada entrada traz date (YYYY-MM-DD), name, movable e um kind:

  • "national" — feriado nacional em lei federal. 9 datas fixas (Lei 662/1949 com a redação da Lei 10.607/2002, Lei 6.802/1980 para 12 de outubro, Lei 14.759/2023 para 20 de novembro).
  • "banking" — não é feriado em lei, mas o sistema financeiro não opera: Carnaval (segunda e terça), Sexta-feira da Paixão e Corpus Christi. Agência fechada, compensação parada — um boleto ou uma TED datada aí liquida depois.

Por que os dois tipos existem

Carnaval e Corpus Christi não são feriados nacionais por lei — mas a Resolução CMN 4.880/2020 fecha os bancos nos quatro dias. Se você calcula prazo de pagamento, os dois contam; se você calcula obrigação trabalhista, só "national" conta. O default é o calendário bancário, porque é o que quebra dinheiro:

isBusinessDay("2026-04-03"); // false — Sexta-feira da Paixão
isBusinessDay("2026-04-03", { kinds: ["national"] }); // true

O que não está, e não vai estar

Feriado estadual e municipal não estão cobertos

A data magna varia por estado e cada um dos 5 570 municípios pode declarar os seus, incluindo até quatro dias religiosos. Nenhuma tabela fica completa e atualizada. Passe pelo extra:

const SAO_PAULO = ["2026-01-25", "2026-07-09", "2026-11-20"];
isBusinessDay("2026-07-09", { extra: SAO_PAULO }); // false

Também de fora, de propósito:

  • Ponto facultativo. Decreto que libera servidor público não é feriado e não muda prazo de ninguém.
  • História pré-2002. A tabela codifica a lei como está hoje. Pedir 1998 devolve o conjunto de hoje deslocado para 1998, não o que valia então. A única exceção modelada é 20 de novembro, que só aparece a partir de 2024.

Aritmética de dia útil

import { addBusinessDays, isBusinessDay, isHoliday, nextBusinessDay } from "tempest-react-sdk/br";

isHoliday("2026-11-20"); // true
isBusinessDay("2026-08-03"); // true — segunda-feira comum

nextBusinessDay("2026-12-24"); // 2026-12-28 — pula o Natal e o fim de semana
addBusinessDays("2026-04-01", 2); // 2026-04-06 — pula a Paixão e o fim de semana
addBusinessDays("2026-04-06", -2); // 2026-04-01 — negativo anda para trás

Aceitam Date ou "YYYY-MM-DD" e devolvem Date na meia-noite local.

Tudo é calendário local, nunca UTC

"Hoje é feriado?" é pergunta sobre o calendário de quem está olhando. Os helpers usam getFullYear/getMonth/getDate e constroem com new Date(y, m, d); passar por toISOString() deslocaria o dia para todo viewer a leste de Greenwich.

addBusinessDays(date, 0) devolve o dia intacto

Mesmo quando não é dia útil. Ajustar em silêncio esconderia justo o caso que você precisa ver.

Onde a Páscoa entra

Os quatro dias móveis são derivados do domingo de Páscoa, não listados:

import { easterSunday } from "tempest-react-sdk/br";

easterSunday(2026); // 2026-04-05

É o computus gregoriano anônimo (Meeus/Jones/Butcher): aritmética inteira sobre o ano, sem tabela e sem dependência — exato para qualquer ano gregoriano.


Parte 5 — WhatsApp

O Pix está montado, o boleto está lido, o horário está marcado. Falta a parte que nenhum encoder resolve: avisar a pessoa. No Brasil isso acontece no WhatsApp, e o pedaço que todo projeto reescreve não é o texto — é normalizar o número e montar a URL.

import { openWhatsApp, toWhatsAppNumber, whatsAppUrl } from "tempest-react-sdk/br";

toWhatsAppNumber("(11) 99999-8888"); // "5511999998888"
whatsAppUrl("(11) 99999-8888", "Seu horário é amanhã às 14h.");
// "https://wa.me/5511999998888?text=Seu%20hor%C3%A1rio%20%C3%A9%20amanh%C3%A3%20%C3%A0s%2014h."

openWhatsApp("(11) 99999-8888", "Seu horário é amanhã às 14h."); // abre a aba, devolve true

Abrir a conversa é uma vantagem, não uma limitação

O link abre o WhatsApp com o texto pronto e quem aperta enviar é a pessoa. Isso não exige API oficial, não exige template aprovado, não exige verificação de negócio — e, principalmente, não deixa a UI afirmar que a mensagem foi entregue quando ela só foi digitada. Envio automático é outro produto (Cloud API), com outro custo e outra responsabilidade.

Por que o 55 não é detalhe

Um telefone brasileiro é guardado quase sempre sem código de país: dez dígitos no fixo, onze no celular. O wa.me recusa qualquer coisa que não esteja totalmente qualificada. toWhatsAppNumber põe o 55 na frente dessas duas formas, e devolve intacto um número que já traz código de país (12 a 15 dígitos, a faixa E.164 que nenhum número nacional brasileiro alcança) — então o celular de um cliente estrangeiro também funciona.

toWhatsAppNumber("11999998888"); // "5511999998888"  — celular nacional
toWhatsAppNumber("1133334444"); // "551133334444"   — fixo nacional
toWhatsAppNumber("+55 11 99999-8888"); // "5511999998888"  — já qualificado
toWhatsAppNumber("+351 912 345 678"); // "351912345678"   — Portugal, intacto

Número que não dá pra normalizar devolve "" — de propósito

Devolver os dígitos como vieram é o que a versão ingênua faz, e o resultado é uma URL wa.me que abre o WhatsApp num número que ninguém tem. Aqui um typo vira botão desabilitado, não janela de conversa morta:

const url = whatsAppUrl(client.phone, greeting);

<Button disabled={!url} onClick={() => openWhatsApp(client.phone, greeting)}>
    Falar no WhatsApp
</Button>

Prefixo de operadora (0, 021) não é removido

Um zero na frente é ambíguo entre prefixo de longa distância e seleção de operadora, então um número que traz um lê como inválido em vez de ser reinterpretado no escuro. O zero é o que descarta 011999998888 mesmo tendo 12 dígitos: nenhum código de país E.164 começa com 0, logo aquilo é um número nacional vestindo prefixo, nunca um internacional.

O nono dígito é da sua base, não do link

Um celular gravado antes de 2016 tem dez dígitos e passa aqui como fixo — o link é montado e é o WhatsApp que decide se acha a conta. Se a sua base tem contato antigo, normalize o nono dígito na base; o deep link não tem como adivinhar se 1133334444 é fixo ou celular sem o 9.

Receita: cobrar o sinal por WhatsApp

O caso que junta esta parte com a Parte 1 — o texto da mensagem carrega o copia-e-cola do Pix:

import { openWhatsApp, pixPayload } from "tempest-react-sdk/br";
import { Button } from "tempest-react-sdk";

interface Booking {
    clientName: string;
    clientPhone: string;
    depositBRL: number;
}

export function DepositRequestButton({ booking }: { booking: Booking }) {
    function requestDeposit(): void {
        const payload = pixPayload({
            key: "financeiro@studio.com.br",
            merchantName: "Studio Aurora",
            merchantCity: "SAO PAULO",
            amount: booking.depositBRL,
        });

        openWhatsApp(
            booking.clientPhone,
            [
                `Oi, ${booking.clientName}! Para confirmar seu horário, o sinal é de R$ ${booking.depositBRL.toFixed(2)}.`,
                "",
                "Pix copia e cola:",
                payload,
            ].join("\n"),
        );
    }

    return <Button onClick={requestDeposit}>Pedir sinal no WhatsApp</Button>;
}

openWhatsApp devolve o pedido, não a aba

true significa "o número era usável e a abertura foi pedida". Abrir com noopener faz o window.open devolver null mesmo quando abre, então o retorno dele não distingue popup bloqueado de aba aberta — e prometer isso seria mentira. Fora do browser (runner de teste, service worker, plugin de build) devolve false em vez de estourar.


Recapitulando

  • Pix — pixPayload monta o BR Code EMV com o CRC-16/CCITT-FALSE certo (incluindo os literais 6304); parsePixPayload lê de volta, tolerante com tag desconhecida e intolerante com checksum errado, tag repetida e valor fora do formato EMV; <PixQRCode> desenha o símbolo e a copia-e-cola, porque num celular só o QR não serve.
  • Boleto — parseLinhaDigitavel / parseCodigoBarras convertem 47↔44 e 48↔44 conferindo todo DV; cobrança e arrecadação são layouts diferentes e o SDK nunca confunde os dois; o fator de vencimento tem duas bases desde fev/2025 e a escolha é explícita.
  • NFe — parseChaveNFe abre os 44 dígitos e resolve o cUF no tipo UF do módulo; validateChaveNFe confere tamanho, UF e DV.
  • Feriados — holidaysFor devolve os 9 feriados nacionais + os 4 dias bancários móveis, marcados; estado e município entram por extra; isBusinessDay / nextBusinessDay / addBusinessDays fazem a conta no calendário local.
  • WhatsApp — toWhatsAppNumber põe o 55 no número nacional e devolve "" no que não dá pra dialar; whatsAppUrl monta o wa.me com o texto codificado; openWhatsApp abre a aba. É deep link, nunca envio: quem aperta enviar é a pessoa.

Nada disso fala com banco. Para o lado servidor (registrar boleto, criar cobrança Pix, autorizar NF-e), veja Integração FastAPI.