Pular para o conteúdo
PHP

PHP: faturando compras no Igest, em Portugal

Paloma Macetko
Ilustração de um unicórnio com varinha lançando um feixe sobre um pergaminho de fatura com selo, ao lado de uma balança pesando moedas e um castelo português com azulejos

Olá meus Unicórnios! 🦄✨

Sabe quando você olha para a tarefa e pensa "é só mandar um JSON com o valor da compra e pronto"? 😅 Pois é. Faturar em Portugal não é mandar um valor: é montar um documento fiscal, e documento fiscal tem regras que não perdoam um cêntimo de diferença.

Eu precisei integrar um sistema de vendas com o Igest, que é um dos serviços de faturação certificada usados por lá. E o caminho até a primeira fatura sair certa me ensinou quatro coisas que eu não sabia quando comecei: o preço que você cobra não é o valor que vai na linha, o IVA é um código de tabela e não uma percentagem, o NIF precisa chegar limpo, e — a mais assustadora — uma fatura enviada não se reenvia. 🤯

Neste artigo eu vou do zero até a fatura emitida, com um arquivo PHP único, linear, sem classe nem Composer. Só a extensão SOAP que já vem no PHP.

🧩 Por que SOAP, e por que sem WSDL

A primeira surpresa foi o protocolo. O webservice do Igest é SOAP — daqueles com envelope XML — e ele não publica um WSDL. Isso muda como você abre a conexão.

Normalmente você passaria a URL do WSDL para o SoapClient e ele descobriria sozinho os tipos e os métodos. Aqui não dá: o primeiro argumento vai null, e aí você tem de dizer à mão como o envelope deve ser montado.

// Sem WSDL o SoapClient recebe null no 1o argumento, e o envelope
// precisa ser descrito a mao aqui.
function abrirCliente($endpoint)
{
    return new SoapClient(null, array(
        'location'           => $endpoint,
        'uri'                => $endpoint,
        'connection_timeout' => 30,
        'trace'              => true,
        'exceptions'         => true,
        'style'              => SOAP_RPC,      // sem estes dois o servidor
        'use'                => SOAP_ENCODED,  // nao entende o envelope
    ));
}

function chamar($soap, $endpoint, $funcao, $parametros)
{
    // Sem soapaction/uri a resposta e "funcao desconhecida",
    // mesmo com o nome absolutamente correto.
    $retorno = $soap->__soapCall($funcao, $parametros, array(
        'soapaction' => $endpoint . '#' . $funcao,
        'uri'        => $endpoint,
    ));

    // A resposta volta como objeto aninhado; isto achata tudo em array.
    return json_decode(json_encode($retorno), true);
}

Repare nos dois pares que costumam ser esquecidos:

  • style e use — sem SOAP_RPC e SOAP_ENCODED, o PHP monta um envelope de outro formato e o servidor simplesmente não entende o que você mandou.
  • soapaction e uri na chamada — sem eles a resposta que volta é "função desconhecida", mesmo com o nome da função absolutamente correto. É um erro que engana, porque parece que você escreveu o nome errado. 😤

E o último detalhe: a resposta volta como objeto do PHP, aninhado. O json_decode(json_encode(...), true) ali no fim é o truque preguiçoso — e eficaz — para transformar tudo em array de uma vez.

🔐 A assinatura: duas datas que têm de ser a mesma

A autenticação é simples de descrever e fácil de errar. Você manda uma chave calculada assim:

// A data e gerada UMA vez: ela assina e viaja no envelope.
// Duas chamadas a date() podem cair em segundos diferentes.
$data = date('Y-m-d H:i:s');
$chave_assinada = sha1($CHAVE . $data);

Ou seja: sha1 da sua chave secreta concatenada com a data. Só que essa mesma data também viaja no envelope, num campo próprio. E é aí que mora a armadilha.

Se você chamar date('Y-m-d H:i:s') duas vezes — uma para assinar e outra para preencher o campo —, existe uma chance real de as duas caírem em segundos diferentes. A requisição sai, o servidor recalcula o sha1 com a data que recebeu, dá diferente da que você assinou, e a resposta é um educado "autenticação inválida". 😳

Foi exatamente o que aconteceu comigo, e eu perdi um tempo bom achando que a chave estava errada. A chave estava certa. Era o relógio.

E é a mesma variável que vai nos dois parâmetros do envelope:

new SoapParam(new SoapVar($chave_assinada, XSD_STRING), 'keyCliente'),
new SoapParam(new SoapVar($data, XSD_STRING), 'data'),

💶 O IVA: o valor da linha não é o preço que você cobrou

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

Em Portugal a taxa normal de IVA no continente é de 23%. Quando o cliente paga 20,00 €, esses 20,00 € já têm o IVA dentro. Mas o Igest, do jeito que a gente monta aqui, quer o valor da linha sem IVA — e ele mesmo calcula o imposto e o total por cima.

Quem decide isso é uma flag chamada bIvaInc:

function montarArtigo($endpoint, $descricao, $valor_com_iva, $quantidade, $percentagem_iva, $tipo_iva)
{
    $artigo = array(
        'codigo'         => '',
        'tipo'           => 'S',
        'nome'           => $descricao,
        'valor_unitario' => valorSemIva($valor_com_iva, $percentagem_iva),
        'bIvaInc'        => 0,   // 0 = o valor acima esta limpo, sem imposto
        'quantidade'     => (float) $quantidade,
        'iva'            => montarIva($endpoint, $percentagem_iva, $tipo_iva),
        'desconto'       => (float) 0,
    );

    return new SoapVar($artigo, SOAP_ENC_OBJECT, 'Artigo', $endpoint);
}

Com bIvaInc = 0 você está dizendo "o valor unitário que eu mandei está limpo, sem imposto". Então o valor tem de ser dividido antes de sair:

// O valor cobrado ja tem IVA dentro. A linha vai sem IVA, e com
// QUATRO casas: o servico multiplica pela quantidade e recalcula o total.
function valorSemIva($valor_com_iva, $percentagem_iva)
{
    return round($valor_com_iva / (1 + ($percentagem_iva / 100)), 4);
}

E agora o detalhe que parece paranoia e não é: quatro casas decimais, não duas.

🧮 Por que quatro casas decimais (e não duas)

Meu primeiro instinto foi arredondar para dois — é dinheiro, dinheiro tem dois. Mas o serviço não usa o seu valor como total: ele recalcula. Ele pega o seu valor unitário, multiplica pela quantidade, aplica o IVA e chega ao total dele. Se esse total não bater com o que o cliente pagou, você emitiu uma fatura errada — e fatura errada em documento fiscal é chatice de verdade.

E repare em como ele recalcula: o serviço arredonda a incidência e o IVA separadamente, e só então soma os dois. Não é o total que ele arredonda no fim — são as duas parcelas, cada uma por si. Essa diferença muda o resultado da conta, e é ela que a varredura abaixo reproduz.

Eu quis saber o tamanho do estrago antes de decidir, então varri 2.000 combinações de valor e quantidade comparando as duas precisões, sempre contra o total esperado:

$u2 = round($valor / 1.23, 2);
$i2 = round($u2 * $q, 2);
$t2 = round($i2 + round($i2 * 0.23, 2), 2);

$u4 = round($valor / 1.23, 4);
$i4 = round($u4 * $q, 2);
$t4 = round($i4 + round($i4 * 0.23, 2), 2);
casos testados: 2000
erram com 2 casas: 1529
erram com 4 casas: 375

4 casas salva (2 erra, 4 acerta):
  0.5 x2 esperado 1 / 2casas 1.01 / 4casas 1
  0.5 x3 esperado 1.5 / 2casas 1.51 / 4casas 1.5
  0.5 x4 esperado 2 / 2casas 2.02 / 4casas 2
  0.5 x5 esperado 2.5 / 2casas 2.52 / 4casas 2.5
  0.5 x6 esperado 3 / 2casas 3.03 / 4casas 3
  0.5 x8 esperado 4 / 2casas 4.03 / 4casas 4

2 casas salva (4 erra, 2 acerta):

Olhe a última lista: está vazia. Não existe um caso sequer em que duas casas acertem e quatro errem. Quatro casas é estritamente melhor — e a diferença não é pequena: 1.529 desvios contra 375.

Repare também que o erro aparece quando a quantidade é maior que 1. Com quantidade 1 as duas precisões dão no mesmo, e é por isso que esse bug consegue passar despercebido por meses: os seus testes com um item só passam lindamente, e a fatura torta só aparece quando alguém compra três. 😅

🏷️ O IVA é um código, não um número

Este me pegou de jeito. Dentro da linha vai um objeto de IVA com três campos, e o campo tipo não é a percentagem nem uma letra qualquer que você invente:

// O campo "tipo" e um codigo de tabela, nao a percentagem nem uma letra.
function montarIva($endpoint, $percentagem, $tipo)
{
    $iva = array(
        'percentagem' => (float) $percentagem,
        'tipo'        => $tipo,   // NOR (23%), INT (13%), RED (6%), ISE (isenta)
        'regiao'      => 'PT',
    );

    return new SoapVar($iva, SOAP_ENC_OBJECT, 'IVA', $endpoint);
}

Eu tinha escrito "I", de "IVA", achando que era só um rótulo. Não é: é um código de tabela. Os válidos são NOR (normal, 23%), INT (intermédia, 13%), RED (reduzida, 6%) e ISE (isenta). Qualquer outra coisa e o documento é recusado.

E repare que a percentagem vai junto com o código. Os dois têm de combinar — mandar NOR com percentagem 6 é pedir problema.

🇵🇹 O NIF precisa chegar limpo

O NIF é o número de contribuinte português: nove dígitos. Só que o que chega do formulário raramente é nove dígitos limpos — vem com espaço, com ponto, às vezes com o prefixo PT na frente.

// O NIF portugues tem 9 digitos. Tudo que nao for digito cai fora,
// inclusive o prefixo "PT" que muita gente digita na frente.
function limparNif($nif)
{
    $so_digitos = preg_replace('/\D/', '', (string) $nif);

    // Nove digitos ou nada: NIF pela metade viraria contribuinte inexistente.
    if (strlen($so_digitos) !== 9) {
        return '';
    }

    return $so_digitos;
}

Rodando isso com as entradas que eu realmente via chegar:

"123 456 789"  -> [123456789]
"123.456.789"  -> [123456789]
"PT123456789"  -> [123456789]
"12345678"     -> []
""             -> []

A decisão de projeto que importa está nas duas últimas linhas: um NIF com o número errado de dígitos vira string vazia, não um erro. Isso é de propósito — em Portugal a fatura sem contribuinte é perfeitamente válida (é a de consumidor final). O que não pode é ir um NIF pela metade, porque aí o documento sai com um contribuinte que não existe. Vazio é honesto; torto não é.

A morada segue a mesma lógica, e por isso os três campos vão juntos ou não vão:

function montarCliente($endpoint, $cliente)
{
    $dados = array(
        'codigo'    => (string) $cliente['codigo'],
        'nome'      => $cliente['nome'],
        'nif'       => limparNif($cliente['nif']),
        'pais_code' => 'PT',
        'email'     => $cliente['email'],
    );

    // Morada incompleta e pior que morada nenhuma: os tres campos
    // vao juntos ou nao vao.
    if ($cliente['cod_postal'] !== '' && $cliente['morada'] !== '' && $cliente['cidade'] !== '') {
        $dados['cod_postal'] = $cliente['cod_postal'];
        $dados['morada']     = $cliente['morada'];
        $dados['cidade']     = $cliente['cidade'];
    }

    return new SoapVar($dados, SOAP_ENC_OBJECT, 'Cliente', $endpoint);
}

📅 Duas datas, dois formatos

Um detalhe cruel e silencioso: o documento carrega duas datas, e elas não têm o mesmo formato.

  • data (a da autenticação, lá em cima) é data e hora: Y-m-d H:i:s.
  • data_emissao, dentro do documento, é só a data: Y-m-d.

Mandar a hora junto no data_emissao é rejeição na hora. E é o tipo de coisa que você copia de uma variável para a outra sem pensar, porque as duas "são a data".

🚫 A fatura que não se reenvia

Agora a parte que muda como você escreve o tratamento de erro — e a razão de eu ter tomado tanto cuidado com o catch.

Uma fatura é um documento fiscal. Ela não é um registro no seu banco que você apaga e recria: ela entra na contabilidade, ela é comunicada, ela tem número numa série. Emitir a mesma compra duas vezes não é um bug de dados — é um documento duplicado que alguém vai ter que anular formalmente depois. 😬

Só que a rede não colabora. Existe um cenário perfeitamente possível: a requisição chega ao Igest, a fatura é criada, e a resposta se perde no caminho de volta. Do lado do seu código, o que você vê é uma exceção — exatamente igual à de "não consegui conectar". Mas os dois casos são opostos: num não existe fatura nenhuma, no outro existe uma fatura que você não sabe que existe.

Por isso o tratamento separa as duas situações, e a diferença fica num campo explícito:

    try {
        $soap = abrirCliente($ENDPOINT);
        $resposta = chamar($soap, $ENDPOINT, 'novo_documento', $parametros);
    } catch (Exception $e) {
        // Nao sabemos o que aconteceu do outro lado: a fatura pode
        // ter sido criada e so a resposta ter se perdido.
        return array(
            'ok'        => false,
            'reenviar'  => false,   // conferir a mao, NUNCA repetir sozinho
            'mensagem'  => 'Falha de comunicacao: ' . $e->getMessage(),
        );
    }

    if (empty($resposta['bSucesso'])) {
        // Aqui ha resposta dizendo que nada foi criado: pode corrigir e reenviar.
        return array(
            'ok'       => false,
            'reenviar' => true,
            'mensagem' => 'O Igest recusou o documento: ' . $resposta['descricaoErro'],
        );
    }

Repare no 'reenviar':

  • Exceção (rede, timeout) → false. Você não sabe o que aconteceu do outro lado. Marque para conferir à mão e siga.
  • Recusa (bSucesso vazio) → true. Aqui você tem uma resposta dizendo que nada foi criado. Pode corrigir o que o serviço reclamou e mandar de novo, tranquila.

É uma distinção de uma linha que evita o pior estrago possível desta integração.

🛡️ E a rede de segurança: o número do documento

Mesmo com todo o cuidado acima, o que de fato impede a duplicata é uma decisão de modelagem: o número da compra vira o número do documento.

        'data_emissao'        => date('Y-m-d'),  // so a data, sem hora
        // O numero da compra vira o numero do documento: o proprio Igest
        // recusa um segundo documento com o mesmo numero na serie.
        'numero_seguranca'    => (string) $compra['numero'],
        'numero_fatura'       => (string) $compra['numero'],

Como o Igest não aceita dois documentos com o mesmo número na mesma série, uma segunda tentativa da mesma compra bate numa parede do lado dele — e não depende de você lembrar de checar. É a proteção que continua funcionando mesmo quando o seu código falha.

Foi o que eu quis ver acontecer antes de confiar. Mandando a mesma compra duas vezes seguidas:

Fatura emitida: FR AB/4021
Sem IVA: 16.26 EUR
IVA:     3.74 EUR
Total:   20 EUR
PDF:     index.php?mod=fact&op=viewPDF&id=1001

Erro: O Igest recusou o documento: Documento com este numero ja existe na serie
Pode reenviar: sim

A primeira passa, a segunda bate na parede. 🎉 E repare na aritmética da primeira: 16,26 + 3,74 = 20,00, exatamente o que o cliente pagou. É esse fechamento ao cêntimo que a divisão por 1,23 com quatro casas está protegendo.

🔗 O link do PDF vem em base64

Uma última pegadinha, pequena e simpática: o linkDocumento da resposta não é uma URL. É o caminho do documento, codificado em base64.

Então para chegar ao PDF você decodifica e junta com o domínio do serviço:

echo base64_decode($resultado['link']);
// index.php?mod=fact&op=viewPDF&id=1001

Guarde esse link junto do número do documento na sua base. É o comprovativo que o cliente vai pedir.

🔑 Onde ficam as credenciais

Nada de chave escrita dentro do arquivo. Tudo por variável de ambiente:

// Credenciais so por variavel de ambiente - nada escrito aqui dentro.

$ENDPOINT = getenv('IGEST_ENDPOINT');  // https://<servidor>/webservice.php
$ID_ENTIDADE = (int) getenv('IGEST_ID_ENTIDADE');
$CHAVE = getenv('IGEST_CHAVE');
$EMAIL = getenv('IGEST_EMAIL');
$SERIE = getenv('IGEST_SERIE');

São cinco: o endereço do webservice, o identificador da sua entidade, a chave secreta, o e-mail da conta e a série do documento. A série é aquele prefixo que aparece no número da fatura — no exemplo acima, AB/4021.

📄 O arquivo inteiro

É este, de cima a baixo. Uma função por assunto, a chamada no fim, e comentário curto em cada linha onde mora uma armadilha:

<?php
// Emissao de fatura no Igest via SOAP sem WSDL.
// Credenciais so por variavel de ambiente - nada escrito aqui dentro.

$ENDPOINT = getenv('IGEST_ENDPOINT');  // https://<servidor>/webservice.php
$ID_ENTIDADE = (int) getenv('IGEST_ID_ENTIDADE');
$CHAVE = getenv('IGEST_CHAVE');
$EMAIL = getenv('IGEST_EMAIL');
$SERIE = getenv('IGEST_SERIE');

// O NIF portugues tem 9 digitos. Tudo que nao for digito cai fora,
// inclusive o prefixo "PT" que muita gente digita na frente.
function limparNif($nif)
{
    $so_digitos = preg_replace('/\D/', '', (string) $nif);

    // Nove digitos ou nada: NIF pela metade viraria contribuinte inexistente.
    if (strlen($so_digitos) !== 9) {
        return '';
    }

    return $so_digitos;
}

// O valor cobrado ja tem IVA dentro. A linha vai sem IVA, e com
// QUATRO casas: o servico multiplica pela quantidade e recalcula o total.
function valorSemIva($valor_com_iva, $percentagem_iva)
{
    return round($valor_com_iva / (1 + ($percentagem_iva / 100)), 4);
}

// O campo "tipo" e um codigo de tabela, nao a percentagem nem uma letra.
function montarIva($endpoint, $percentagem, $tipo)
{
    $iva = array(
        'percentagem' => (float) $percentagem,
        'tipo'        => $tipo,   // NOR (23%), INT (13%), RED (6%), ISE (isenta)
        'regiao'      => 'PT',
    );

    return new SoapVar($iva, SOAP_ENC_OBJECT, 'IVA', $endpoint);
}

function montarArtigo($endpoint, $descricao, $valor_com_iva, $quantidade, $percentagem_iva, $tipo_iva)
{
    $artigo = array(
        'codigo'         => '',
        'tipo'           => 'S',
        'nome'           => $descricao,
        'valor_unitario' => valorSemIva($valor_com_iva, $percentagem_iva),
        'bIvaInc'        => 0,   // 0 = o valor acima esta limpo, sem imposto
        'quantidade'     => (float) $quantidade,
        'iva'            => montarIva($endpoint, $percentagem_iva, $tipo_iva),
        'desconto'       => (float) 0,
    );

    return new SoapVar($artigo, SOAP_ENC_OBJECT, 'Artigo', $endpoint);
}

function montarCliente($endpoint, $cliente)
{
    $dados = array(
        'codigo'    => (string) $cliente['codigo'],
        'nome'      => $cliente['nome'],
        'nif'       => limparNif($cliente['nif']),
        'pais_code' => 'PT',
        'email'     => $cliente['email'],
    );

    // Morada incompleta e pior que morada nenhuma: os tres campos
    // vao juntos ou nao vao.
    if ($cliente['cod_postal'] !== '' && $cliente['morada'] !== '' && $cliente['cidade'] !== '') {
        $dados['cod_postal'] = $cliente['cod_postal'];
        $dados['morada']     = $cliente['morada'];
        $dados['cidade']     = $cliente['cidade'];
    }

    return new SoapVar($dados, SOAP_ENC_OBJECT, 'Cliente', $endpoint);
}

// Sem WSDL o SoapClient recebe null no 1o argumento, e o envelope
// precisa ser descrito a mao aqui.
function abrirCliente($endpoint)
{
    return new SoapClient(null, array(
        'location'           => $endpoint,
        'uri'                => $endpoint,
        'connection_timeout' => 30,
        'trace'              => true,
        'exceptions'         => true,
        'style'              => SOAP_RPC,      // sem estes dois o servidor
        'use'                => SOAP_ENCODED,  // nao entende o envelope
    ));
}

function chamar($soap, $endpoint, $funcao, $parametros)
{
    // Sem soapaction/uri a resposta e "funcao desconhecida",
    // mesmo com o nome absolutamente correto.
    $retorno = $soap->__soapCall($funcao, $parametros, array(
        'soapaction' => $endpoint . '#' . $funcao,
        'uri'        => $endpoint,
    ));

    // A resposta volta como objeto aninhado; isto achata tudo em array.
    return json_decode(json_encode($retorno), true);
}

function emitirFatura($compra, $cliente)
{
    global $ENDPOINT, $ID_ENTIDADE, $CHAVE, $EMAIL, $SERIE;

    // A data e gerada UMA vez: ela assina e viaja no envelope.
    // Duas chamadas a date() podem cair em segundos diferentes.
    $data = date('Y-m-d H:i:s');
    $chave_assinada = sha1($CHAVE . $data);

    $artigo = montarArtigo(
        $ENDPOINT,
        $compra['descricao'],
        $compra['valor'],
        $compra['quantidade'],
        23,
        'NOR'
    );

    $documento = array(
        'serie'               => $SERIE,
        'tipo'                => 'FT',
        'data_emissao'        => date('Y-m-d'),  // so a data, sem hora
        // O numero da compra vira o numero do documento: o proprio Igest
        // recusa um segundo documento com o mesmo numero na serie.
        'numero_seguranca'    => (string) $compra['numero'],
        'numero_fatura'       => (string) $compra['numero'],
        'tipo_pagamento_code' => '0',
        'cliente'             => montarCliente($ENDPOINT, $cliente),
        'artigos'             => new SoapVar(array($artigo), SOAP_ENC_ARRAY, 'ArrayOfArtigo', $ENDPOINT),
        'email_send'          => $cliente['email'],
    );

    $parametros = array(
        new SoapParam(new SoapVar($ID_ENTIDADE, XSD_INT), 'idEntidade'),
        new SoapParam(new SoapVar($chave_assinada, XSD_STRING), 'keyCliente'),
        new SoapParam(new SoapVar($data, XSD_STRING), 'data'),
        new SoapParam(new SoapVar($EMAIL, XSD_STRING), 'email'),
        new SoapParam(new SoapVar($documento, SOAP_ENC_OBJECT, 'Documento', $ENDPOINT), 'documento'),
        new SoapParam(new SoapVar('1.0', XSD_STRING), 'versao'),
    );

    try {
        $soap = abrirCliente($ENDPOINT);
        $resposta = chamar($soap, $ENDPOINT, 'novo_documento', $parametros);
    } catch (Exception $e) {
        // Nao sabemos o que aconteceu do outro lado: a fatura pode
        // ter sido criada e so a resposta ter se perdido.
        return array(
            'ok'        => false,
            'reenviar'  => false,   // conferir a mao, NUNCA repetir sozinho
            'mensagem'  => 'Falha de comunicacao: ' . $e->getMessage(),
        );
    }

    if (empty($resposta['bSucesso'])) {
        // Aqui ha resposta dizendo que nada foi criado: pode corrigir e reenviar.
        return array(
            'ok'       => false,
            'reenviar' => true,
            'mensagem' => 'O Igest recusou o documento: ' . $resposta['descricaoErro'],
        );
    }

    return array(
        'ok'        => true,
        'documento' => $resposta['numeroDocumentoCompleto'],
        'sem_iva'   => $resposta['valorIncidencia'],
        'iva'       => $resposta['valorIva'],
        'total'     => $resposta['valorTotal'],
        'link'      => $resposta['linkDocumento'],   // caminho em base64
    );
}

// -------------------------------------------------------------------
// Uso

$compra = array(
    'numero'     => 4021,
    'descricao'  => 'COMPRA #4021 - PLANO MENSAL',
    'valor'      => 20.00,
    'quantidade' => 1,
);

$cliente = array(
    'codigo'     => 77,
    'nome'       => 'Maria Silva',
    'nif'        => 'PT123456789',
    'email'      => '[email protected]',
    'cod_postal' => '1000-001',
    'morada'     => 'Rua das Flores, 10',
    'cidade'     => 'Lisboa',
);

$resultado = emitirFatura($compra, $cliente);

if ($resultado['ok']) {
    echo "Fatura emitida: " . $resultado['documento'] . "\n";
    echo "Sem IVA: " . $resultado['sem_iva'] . " EUR\n";
    echo "IVA:     " . $resultado['iva'] . " EUR\n";
    echo "Total:   " . $resultado['total'] . " EUR\n";
    echo "PDF:     " . base64_decode($resultado['link']) . "\n";
} else {
    echo "Erro: " . $resultado['mensagem'] . "\n";
    echo "Pode reenviar: " . ($resultado['reenviar'] ? 'sim' : 'nao') . "\n";
}

🎁 O que eu levo desta integração

Faturação certificada é um daqueles temas em que o código é fácil e as regras é que são difíceis. O SOAP sem WSDL assusta na primeira meia hora e depois nunca mais; o que continua exigindo atenção é o resto: a data que precisa ser a mesma nos dois lugares, o valor que vai limpo com quatro casas, o código de IVA que é de tabela, o NIF que é nove dígitos ou nada.

E, acima de tudo, aquela distinção entre "foi recusado" e "não sei o que aconteceu". Num sistema comum, repetir a requisição é a coisa mais natural do mundo. Aqui, repetir é criar um documento fiscal a mais. 🦄

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

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

Leia também

Node.js

Node.js: enviando SMS pela ez4uteam

Como enviar SMS pela API da ez4uteam com Node.js puro, e as armadilhas do telefone, do limite de 160 caracteres e do HTTP 200 que mente.