Pular para o conteúdo
PHP

PHP: faturando compras no BOnline (Portugal)

Paloma Macetko
Um unicórnio atrás de um balcão emitindo uma fatura mágica, uma coruja carimbando um selo com o símbolo do euro e um castelo com azulejos portugueses ao fundo

Olá meus Unicórnios! 🦄✨

Sabe quando você acha que faturar é só mandar um POST com o valor da compra? 😅 Pois é. Faturar em Portugal tem uma particularidade que muda tudo: o que sai do outro lado não é um registro no seu banco, é um documento fiscal. E documento fiscal não se apaga.

Eu integrei um sistema de vendas com o BOnline — um serviço português de faturação — e o que me tomou tempo não foi a chamada HTTP. Foi descobrir, um a um, os detalhes que fazem a diferença entre uma fatura correta e um documento errado que já nasceu no arquivo da Autoridade Tributária. Este artigo é a lista desses detalhes, com o código que uso para cada um.

Vou montar um arquivo só, de cima para baixo, em PHP 7.4, usando apenas o curl que já vem no PHP. Sem Composer, sem classe, sem camada de abstração. Se você nunca falou com essa API, dá para copiar e entender inteiro.

🇵🇹 O que a fatura portuguesa exige

Antes do código, o mapa. A API do BOnline recebe um bloco de autenticação, um bloco de cliente (às vezes) e um bloco de fatura com as linhas dentro. Os campos vão em português mesmo — é uma API portuguesa, não há tradução para inglês:

tipo_documento    FT = fatura
serie             a serie do documento (A, B...)
data_documento    dia-mes-ano, com tracinho
moeda             EUR
iva_incluido      S = os precos das linhas JA tem IVA dentro
observacoes       a SUA referencia interna
detalhes[]        as linhas: artigo, quantidade, preco e IVA

Repare no iva_incluido. Ele decide como o serviço vai ler todos os preços que você mandar — e é a primeira armadilha do artigo.

🔐 A autenticação, que nunca mora no arquivo

A senha não viaja crua: o que vai no pedido é um HMAC-SHA256 dela com um hash privado da sua conta. São três segredos, e nenhum deles pode estar escrito no código. Quem lê o arquivo — ou o commit — passa a poder emitir faturas em nome da empresa.

function montarAutenticacao()
{
    $chave = getenv('BONLINE_API_KEY');
    $senha = getenv('BONLINE_API_PASS');
    $sal   = getenv('BONLINE_PRIVATE_HASH');

    if ($chave === false || $senha === false || $sal === false) {
        throw new Exception('Faltam as variaveis BONLINE_API_KEY, BONLINE_API_PASS e BONLINE_PRIVATE_HASH.');
    }

    return array(
        'api_key' => $chave,
        // A senha nao viaja crua: vai como HMAC-SHA256 dela com o hash privado.
        'api_pass' => hash_hmac('sha256', $senha, $sal),
    );
}

O if ali no meio parece burocracia, mas ele evita o pior tipo de bug: sem ele, uma variável de ambiente faltando vira uma string vazia, o HMAC é calculado em cima do nada, e você recebe um "credenciais inválidas" genérico sem saber qual das três está errada.

🧾 O NIF: nove dígitos e mais nada

O NIF é o número de contribuinte português. Ele tem 9 dígitos, e o cliente vai digitar do jeito dele: com espaço, com ponto, com tracinho. Se algum desses caracteres chegar no BOnline, o cliente inteiro é recusado.

function limparNif($nif)
{
    return preg_replace('/[^0-9]/', '', $nif);
}

Mas limpar não basta. O NIF tem dígito de controlo — dá para saber se o número é válido antes de gastar uma chamada de rede com ele. Os 8 primeiros dígitos são multiplicados por pesos de 9 a 2, e o resto da divisão por 11 diz qual tem de ser o último:

function nifValido($nif)
{
    $nif = limparNif($nif);
    if (strlen($nif) !== 9) {
        return false;
    }

    $soma = 0;
    for ($i = 0; $i < 8; $i++) {
        $soma += (int) $nif[$i] * (9 - $i);
    }

    $resto = $soma % 11;
    $controlo = 0;
    if ($resto >= 2) {
        $controlo = 11 - $resto;
    }

    return $controlo === (int) $nif[8];
}

Rodando isso com alguns números inventados, dá para ver a limpeza e a validação trabalhando juntas:

123456789      limpo=123456789  valido=sim
123 456 789    limpo=123456789  valido=sim
111111111      limpo=111111111  valido=nao

Repare na segunda linha: 123 456 789, digitado com espaços, vira o mesmo número da primeira. É exatamente esse caso que quebrava para mim antes de eu limpar a entrada.

💶 O IVA é por linha, não da fatura

Aqui está o detalhe que mais me pegou. O IVA não é um campo da fatura: ele é um campo de cada linha. Em Portugal a taxa normal é 23%, mas existem reduzidas de 6% e 13%, e uma mesma fatura pode misturar as três.

function montarLinha($numero, $codigoArtigo, $descricao, $quantidade, $precoComIva, $taxaIva)
{
    return array(
        'linha' => (string) $numero,
        'codigo_artigo' => $codigoArtigo,
        'descricao' => $descricao,
        'quantidade' => (string) $quantidade,
        'unidade_medida' => 'UN',
        'preco_unitario' => precoParaBOnline($precoComIva),
        'desconto' => '0',
        // A taxa de IVA e POR LINHA, nao da fatura inteira.
        // Em Portugal a normal e 23; ha reduzidas de 6 e 13.
        'iva' => (string) $taxaIva,
        'retencao' => '0',
    );
}

E o preço que vai nessa linha já inclui o IVA, porque a fatura é enviada com iva_incluido = "S". Essa é a combinação que engana: se você trocar o "S" por "N" e não mexer nos preços, o BOnline entende que aqueles valores são a base e soma 23% por cima. A fatura sai 23% mais cara, e está fiscalmente emitida.

Para conferir o que o cliente está pagando de imposto, eu separo as duas partes de um preço que já as tem juntas:

function separarIva($precoComIva, $taxaIva)
{
    $base = $precoComIva / (1 + ($taxaIva / 100));
    $base = round($base, 2);
    // O imposto sai por subtracao: assim base + imposto fecha sempre
    // exatamente no preco, sem depender de como o arredondamento caiu.
    $imposto = round($precoComIva, 2) - $base;

    return array(
        'base' => $base,
        'imposto' => round($imposto, 2),
        'total' => round($precoComIva, 2),
    );
}

O imposto sai por subtração, não por uma segunda multiplicação. Assim base + imposto fecha sempre exatamente no preço, sem depender de para que lado cada arredondamento caiu. Rodando com alguns valores:

 12.30 a 23%  ->  base  10.00 + IVA  2.30 =  12.30
 19.90 a 23%  ->  base  16.18 + IVA  3.72 =  19.90
 10.00 a 23%  ->  base   8.13 + IVA  1.87 =  10.00
  5.00 a  6%  ->  base   4.72 + IVA  0.28 =   5.00

Repare no 19.90: a base não é um número redondo (16,18 €), e é por isso que a conta tem de ser feita nesse sentido — do preço final para dentro, nunca ao contrário.

📅 A data e o preço, no formato que o serviço quer

Dois campos pequenos que estragam o documento inteiro. A data vai como 09-07-2026, com tracinho e dia na frente. Mandar no formato ISO (2026-07-09) faz o documento sair com a data errada — não dá erro, sai errado:

function dataParaBOnline($momento)
{
    $data = new DateTime($momento, new DateTimeZone('Europe/Lisbon'));
    return $data->format('d-m-Y');
}

function precoParaBOnline($valor)
{
    return number_format(round($valor, 2), 2, '.', '');
}

E o preço vai sempre com 2 casas e ponto decimal. Essa é traiçoeira em Portugal, onde se escreve "19,90" com vírgula: mandada assim, a vírgula corta o número e chega 19 do outro lado. O cliente é faturado em 19 € em vez de 19,90 €.

19.9     -> 19.90
19.999   -> 20.00
12.3     -> 12.30

🎯 O total tem de bater ao cêntimo

O BOnline recalcula o total a partir das linhas. Se a sua conta e a dele diferirem por um cêntimo, o documento é recusado — ou pior, sai com um valor que não é o que o cliente pagou.

Então eu confiro antes de enviar. E a soma é feita do jeito que o serviço soma: linha a linha, arredondando cada uma antes de somar.

function somarTotal($linhas)
{
    $total = 0;
    foreach ($linhas as $linha) {
        $valorLinha = (float) $linha['preco_unitario'] * (int) $linha['quantidade'];
        $total = $total + round($valorLinha, 2);
    }
    return round($total, 2);
}

Agora o detalhe que separa um guarda que funciona de um que rejeita compras boas. A comparação não pode ser ==. Ponto flutuante não é exato, e três linhas de 10 cêntimos provam isso:

0.10+0.10+0.10 = 0.30000000000000004441
isso e igual a 0.30? NAO
com round(...,2): sim

Isso mesmo — 0.10 + 0.10 + 0.10 não é 0.30 em PHP nenhum, nem em linguagem nenhuma que use ponto flutuante. Um guarda escrito com == recusaria essa compra perfeitamente válida. Por isso a comparação é por tolerância:

    $total = somarTotal($linhas);
    if (abs($total - round($compra['total'], 2)) >= 0.01) {
        throw new Exception(
            'O total das linhas (' . precoParaBOnline($total) . ') nao bate com o total da compra ('
            . precoParaBOnline($compra['total']) . '). Nada foi enviado.'
        );
    }

Repare na mensagem: ela diz os dois valores e termina com "Nada foi enviado". Quando isso aparecer no seu log às duas da manhã, essas três palavras são a diferença entre dormir tranquila e ir conferir se saiu uma fatura errada. 😅

📮 O POST que não é JSON

Esta me custou uma tarde. A API recebe JSON — mas não como corpo JSON. O JSON viaja dentro de um campo de formulário chamado jqvars, com Content-Type de formulário:

function chamarBOnline($caminho, $dados)
{
    $curl = curl_init();

    curl_setopt_array($curl, array(
        CURLOPT_URL => enderecoDoServico() . $caminho,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 90,
        CURLOPT_POST => true,
        // "jqvars=" na frente do JSON, e Content-Type de formulario.
        // Mandando application/json o servico responde vazio, sem erro.
        CURLOPT_POSTFIELDS => 'jqvars=' . json_encode($dados),
        CURLOPT_HTTPHEADER => array(
            'Expect:',
            'Content-Type: application/x-www-form-urlencoded',
        ),
    ));

O 'Expect:' vazio ali não é enfeite. Sem ele, o cURL manda um Expect: 100-continue em corpos maiores e fica esperando uma resposta intermediária que nem sempre vem — a chamada demora segundos a mais sem motivo.

🚨 HTTP 200 não quer dizer fatura emitida

Este é o erro que eu cometi, e vou assumir em primeira pessoa: eu tratei o código HTTP como se fosse o resultado. Não é. A API responde 200 quase sempre; se deu certo ou não está dentro do JSON, no status.code:

    $corpo = json_decode($resposta, true);
    if (!is_array($corpo)) {
        throw new Exception('O servico respondeu algo que nao e JSON (HTTP ' . $httpcode . ').');
    }

    // HTTP 200 nao quer dizer fatura emitida: o erro vem dentro do JSON.
    // Quem so olhar o codigo HTTP vai achar que deu certo.
    if (!isset($corpo['status']['code']) || $corpo['status']['code'] !== '1') {
        $mensagem = 'O servico recusou o documento.';
        if (isset($corpo['status']['message'])) {
            $mensagem = $corpo['status']['message'];
        }
        throw new Exception($mensagem);
    }

    return $corpo['result'];

status.code igual a "1" — string, não número — é o único sinal de que a fatura existe. Qualquer outra coisa é recusa, mesmo com HTTP 200 e resposta bonita.

👤 Com NIF e sem NIF são dois caminhos

Se o cliente informou NIF, o BOnline cria o cliente e a fatura no mesmo pedido, e o documento sai identificado. Se não informou, a venda é a consumidor final — o que a lei portuguesa permite para particular — e o campo codigo_cliente vai com "CF":

    if (isset($cliente['nif']) && $cliente['nif'] !== '') {
        return $fatura;
    }

    $fatura['codigo_cliente'] = 'CF';
    return $fatura;

E o endereço da chamada muda junto:

function caminhoDaChamada($cliente)
{
    if (isset($cliente['nif']) && $cliente['nif'] !== '') {
        return 'apps/api/facturas/_criar_tudo_num_pedido.php';
    }
    return 'apps/api/facturas/_criar.php';
}

Junto isso na preparação do cliente: NIF inválido não faz a compra falhar, ela só desce para consumidor final. Faturar como CF é melhor que não faturar. 🙏

function prepararCliente($nome, $email, $nif)
{
    $cliente = array(
        'nome' => $nome,
        'email' => $email,
        'pais' => 'PT',
    );

    $nif = limparNif($nif);
    if ($nif !== '' && nifValido($nif)) {
        $cliente['nif'] = $nif;
    }

    return $cliente;
}

⛔ A fatura que não pode ser reenviada

Se você só for ler um pedaço deste artigo, leia este. 🙏

Imagine: você manda o pedido, o BOnline emite a fatura, e a resposta se perde na rede. Do seu lado, parece que falhou. Você reenvia. O cliente recebe dois documentos fiscais do mesmo valor — e documento fiscal não se apaga, só se corrige com nota de crédito, com contabilista no meio.

A proteção é anotar que a compra foi faturada, e conferir isso antes de qualquer envio. Num sistema de verdade é uma coluna na sua tabela de compras; aqui é um arquivo, para o exemplo rodar sozinho:

function faturarCompra($compra)
{
    // Fatura emitida nao se apaga - so se corrige com uma nota de
    // credito. Se a resposta se perder na rede e voce reenviar, o
    // cliente recebe DOIS documentos fiscais do mesmo valor.
    if (jaFaturada($compra['referencia'])) {
        throw new Exception('A compra ' . $compra['referencia'] . ' ja foi faturada. Nada foi enviado.');
    }

E, do outro lado da chamada, a anotação vem antes de qualquer outra coisa:

    $resultado = chamarBOnline(caminhoDaChamada($cliente), $envio);

    // Anote ANTES de qualquer outra coisa. Se o programa morrer aqui e
    // voce rodar de novo, o guarda la em cima impede o documento duplo.
    marcarFaturada($compra['referencia'], $resultado['document_number']);

É por isso que a referencia — a sua identificação da compra, que também vai no campo observacoes da fatura — importa tanto. Ela é o que liga o documento lá do BOnline à compra aqui do seu lado. Sem ela, descobrir se uma compra já foi faturada vira trabalho manual.

Rodando o script duas vezes seguidas com a mesma compra, num BOnline de mentira que eu subi para não tocar no serviço real:

=== 1a execucao ===
Fatura emitida: FT A/19224
Codigo interno: 30224

=== 2a execucao (mesma compra) ===
Nao foi possivel faturar: A compra Compra_10042 ja foi faturada. Nada foi enviado.

=== registo ===
Compra_10042 => FT A/19224

Uma fatura, um registo. É esse o comportamento que você quer quando o processo cair no meio — e é o único jeito de dormir tranquila com um integrador de faturação rodando sozinho.

🧩 Juntando tudo

A função principal, que amarra as peças na ordem: confere duplicata, prepara o cliente, monta as linhas, confere o total e só então envia.

    $cliente = prepararCliente($compra['nome'], $compra['email'], $compra['nif']);

    $linhas = array();
    $numero = 1;
    foreach ($compra['itens'] as $item) {
        $linhas[] = montarLinha(
            $numero,
            $item['codigo'],
            $item['descricao'],
            $item['quantidade'],
            $item['preco'],
            $item['iva']
        );
        $numero = $numero + 1;
    }

E a chamada, com os dados fictícios do exemplo:

$compra = array(
    'nome' => 'Maria Silva',
    'email' => '[email protected]',
    'nif' => '123 456 789',
    'referencia' => 'Compra_10042',
    'momento' => '2026-07-09 14:30:00',
    'total' => 24.60,
    'itens' => array(
        array(
            'codigo' => 'SERV1',
            'descricao' => 'Consulta online',
            'quantidade' => 1,
            'preco' => 12.30,
            'iva' => 23,
        ),
    ),
);

try {
    $documento = faturarCompra($compra);
    echo "Fatura emitida: " . $documento['numero'] . "\n";
} catch (Exception $e) {
    echo "Nao foi possivel faturar: " . $e->getMessage() . "\n";
}

Repare que o NIF entra com espaços de propósito, do jeito que o cliente digitaria. A limparNif() resolve antes de sair da sua máquina.

💥 Quando dá errado

O try/catch cobre os três jeitos de falhar, e cada um diz o que aconteceu em português claro:

Nao foi possivel faturar: Credenciais invalidas
Nao foi possivel faturar: Faltam as variaveis BONLINE_API_KEY, BONLINE_API_PASS e BONLINE_PRIVATE_HASH.
Nao foi possivel faturar: O total das linhas (12.30) nao bate com o total da compra (25.00). Nada foi enviado.

Os dois últimos são os meus preferidos, porque acontecem antes de qualquer byte sair da máquina. Erro que acontece antes do envio custa zero: nenhum documento fiscal foi criado, nada precisa ser corrigido, é só arrumar e rodar de novo.

📋 O resumo das armadilhas

iva_incluido = S      o preco da linha JA tem IVA dentro
iva por linha         23 normal, 6 e 13 reduzidas
data                  09-07-2026, nunca 2026-07-09
preco                 ponto decimal, nunca virgula
NIF                   9 digitos limpos, com digito de controlo
sem NIF               codigo_cliente = CF, e outro endereco
corpo do POST         jqvars=<json>, formulario e nao JSON
status.code = "1"     o unico sinal de sucesso, nao o HTTP 200
referencia            anote antes: fatura nao se reenvia

Nenhuma delas está no lugar onde eu procurei primeiro. Todas moram no detalhe do campo, não na estrutura da chamada — que é justamente o que faz uma integração de faturação parecer simples até o dia em que um cliente liga perguntando por que recebeu duas faturas. 😳

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

PHP

Leia também