Correios Automático

Integração com NF-e na pré-postagem

Com poucos ganchos, o seu plugin de nota fiscal ou ERP entrega a NF-e à pré-postagem do Correios Automático: o objeto sai com a nota da loja no lugar da DC-e, a pré-postagem automática do PRO espera a nota, a etiqueta imprime a DANFE simplificada, a lista de pedidos mostra o status da nota, a devolução reaproveita o documento da ida e o armazém Correios LOG+ recebe o XML. Integrações como SquidCore e Bling já funcionam assim.

Correios Automático 1.14.0PRO 1.6.4REST infixs-correios-automatico/v1WP 6.0+ · PHP 7.4+ · HPOSGuia de instalaçãoPlugin no WordPress.orgCorreios Automático PRO
Integração com NF-e na pré-postagem
#O que o seu plugin fazGancho ou metadadoPlano · desde
1Detecta o Correios Automático e registra os ganchosplugins_loaded + constante INFIXS_CORREIOS_AUTOMATICO_PLUGIN_VERSIONGrátis
2Entrega a NF-e do pedidofiltro infixs_correios_automatico_order_invoice e/ou os metas _infixs_correios_automatico_invoice_number + _infixs_correios_automatico_invoice_keyGrátis 1.11.0
3Faz a pré-postagem automática esperar a notafiltro infixs_correios_automatico_auto_prepost_wait_invoicePRO 1.3.0
4Avisa que a nota ficou pronta ou falhouação infixs_correios_automatico_order_invoice_ready / infixs_correios_automatico_order_invoice_failed (o seu plugin dispara)PRO 1.3.0 ouve
5No cancelamento, tira a nota dos metasapagar os 2 metas se ainda guardam aquela chaveGrátis
6Entrega os dados da DANFE simplificadafiltro infixs_correios_automatico_order_danfeGrátis 1.12.0
7Mostra o status da nota na lista de pedidosfiltro infixs_correios_automatico_order_invoice_statusGrátis 1.12.0
8Ajusta o documento da logística reversafiltro infixs_correios_automatico_reverse_prepost_fiscal_documentGrátis 1.14.0
9Entrega o XML ao armazém Correios LOG+ação infixs_correios_automatico_pro_logplus_order_created + filtro infixs_correios_automatico_pro_logplus_invoice_xmlPRO 1.6.0
10Lê o objeto e o rastreioação infixs_correios_automatico_prepost_created e infixs_correios_automatico_after_add_tracking_codeGrátis

Visão do fluxo

  1. O pedido é pago e entra em Processando. Com o PRO, a pré-postagem automática roda nesse momento (no woocommerce_order_status_processing, prioridade 100) e procura a NF-e de cada envio.
  2. O seu plugin responde pelo filtro infixs_correios_automatico_order_invoice ou pelos metas da nota. Com a nota autorizada, a pré-postagem sai com ela na hora, sem DC-e.
  3. Se a nota ainda vai ser emitida, o seu plugin devolve true em infixs_correios_automatico_auto_prepost_wait_invoice PRO. O envio fica aguardando e o pedido aparece em Pendências → NF-e no painel.
  4. A SEFAZ autoriza: o seu plugin grava a nota, salva o pedido e dispara infixs_correios_automatico_order_invoice_ready. O PRO relê o pedido e cria a pré-postagem com a NF-e.
  5. Se a emissão falhar, o seu plugin dispara infixs_correios_automatico_order_invoice_failed com o motivo. O lojista vê a mensagem no pedido e o envio segue aguardando a nota corrigida.
  6. Na impressão, infixs_correios_automatico_order_danfe entrega os dados da DANFE simplificada, que sai junto com a etiqueta.
  7. Na lista de pedidos, infixs_correios_automatico_order_invoice_status mostra o estado da nota com links para o seu plugin.
  8. Na devolução, infixs_correios_automatico_reverse_prepost_fiscal_document ajusta o documento da reversa, por exemplo com uma NF-e reemitida.
  9. No armazém Correios LOG+ PRO, o seu plugin recebe o id do pedido nos Correios, emite a NF-e com ele em xPed e entrega o XML por infixs_correios_automatico_pro_logplus_invoice_xml.
  10. Por fim, infixs_correios_automatico_prepost_created e infixs_correios_automatico_after_add_tracking_code devolvem ao seu plugin o objeto e o código de rastreio.

No plugin grátis, quem pré-posta é o lojista (botões do pedido, formulário do painel ou pré-postagem em massa), e essas pré-postagens já usam a NF-e do seu plugin. A pré-postagem automática no pagamento é do PRO.

NF-e ou DC-e: como a pré-postagem decide

Em toda pré-postagem de ida, o Correios Automático escolhe o documento fiscal nesta ordem:

  1. Documento informado por quem pediu a pré-postagem. Se a chamada já traz invoice_key, invoice_number ou emiteDCe, ele vale como veio e nenhum filtro roda.
  2. Filtro infixs_correios_automatico_prepost_use_order_invoice. Devolver false faz a pré-postagem seguir com DC-e.
  3. NF-e do pedido: o filtro infixs_correios_automatico_order_invoice e, sem resposta válida dele, os metas _infixs_correios_automatico_invoice_number e _infixs_correios_automatico_invoice_key.
  4. Resultado: com chave de NF-e (modelo 55), a pré-postagem vai aos Correios com chaveNFe e numeroNotaFiscal, sem DC-e. Sem NF-e, os Correios emitem a DC-e (emiteDCe: "S").
Onde a pré-postagem nasceO que o Correios Automático recebeDocumento
Pedido, botão “Criar Pré-Postagem c/ Nota Fiscal”número e chave dos campos, já preenchidos com a NF-e resolvidaNF-e
Pedido, botão “Criar Pré-Postagem c/ Declaração Eletrônica (DCe)”withDce: trueDC-e
Painel, formulário da pré-postagemnúmero e chave da nota, ou a opção DC-eo escolhido
Painel, pré-postagem em massanenhum documentoo filtro da nota roda: NF-e se houver, senão DC-e
PRO Pré-postagem automáticaa NF-e encontrada, ou nada se ninguém espera a notaNF-e, ou DC-e sem espera
PRO PUT /wp-json/wc/v3/orders/{id} com os dois metasnúmero e chave do corpo da requisiçãoNF-e na hora
POST /wp-json/infixs-correios-automatico/v1/prepostso que vier no corpo (detalhes)conforme o corpo

Bom saber

Depois de cada pré-postagem, o Correios Automático grava a nota usada nos metas _infixs_correios_automatico_invoice_number e _infixs_correios_automatico_invoice_key, seja qual for a origem. Assim o número e a chave ficam visíveis no pedido e na caixa de pré-postagem. Por isso o cancelamento da nota passa pelos metas (veja como).

1. Informar a NF-e do pedido

Dois caminhos levam a nota à pré-postagem, e eles se completam:

  • Filtro infixs_correios_automatico_order_invoice: responde com a nota a partir dos dados do seu plugin. Uma resposta válida tem prioridade sobre os metas.
  • Metas do pedido _infixs_correios_automatico_invoice_number e _infixs_correios_automatico_invoice_key: gravados quando a nota é autorizada. Valem como reserva e aparecem para o lojista na caixa de pré-postagem.

O padrão recomendado usa os dois: grave os metas na autorização, sem sobrescrever uma nota que o lojista digitou, e responda o filtro enquanto os metas estiverem vazios ou com a sua chave.

// Como o Correios Automático pergunta a nota:
/**
 * @param array|null $invoice Resposta dos emissores anteriores; começa em null.
 * @param WC_Order   $order   Pedido.
 * @param array      $context for ('prepost' | 'display'), shipping_item_id (0 = pedido inteiro), vendor_id (0 = a loja).
 * @return array|null
 */
$invoice = apply_filters( 'infixs_correios_automatico_order_invoice', null, $order, $context );
ContextoValor
for'prepost' quando a nota vai aos Correios (pré-postagem em massa, automática do PRO, reversa); 'display' quando só é exibida (lista de pedidos, etiqueta, caixa do pedido).
shipping_item_idlinha de frete (volume) do envio; 0 = pedido inteiro.
vendor_idvendedor do marketplace que posta; 0 = a loja.
Chave da respostaObrigatóriaRegra
invoice_keysim44 dígitos com modelo 55 nas posições 21 e 22. Na leitura, tudo o que não é dígito sai (espaços, pontos, o prefixo NFe).
invoice_numbernãoSem ele, o número é lido da chave (posições 26 a 34, sem zeros à esquerda).
sourcenãoIdentificador da sua integração; passa por sanitize_key(). Vazio vira unknown.
source_labelnãoNome mostrado ao lojista. Vazio vira o source.

Devolva null, ou o valor recebido, quando o pedido não for seu: o próximo emissor responde e, por fim, os metas. Quando a nota vem dos metas, o plugin mostra a origem como “informada no pedido” (source = order_meta).

add_filter( 'infixs_correios_automatico_order_invoice', 'meu_plugin_nfe_do_pedido', 10, 3 );

function meu_plugin_nfe_do_pedido( $invoice, $order, $context ) {
	if ( null !== $invoice || ! $order instanceof WC_Order ) {
		return $invoice; // Outro emissor já respondeu.
	}

	if ( ! empty( $context['vendor_id'] ) ) {
		return $invoice; // No marketplace, o vendedor emite a nota dele.
	}

	$nfe = meu_plugin_nfe_autorizada( $order ); // Leitura local (meta ou tabela), sem chamar API.

	if ( ! $nfe || 'production' !== $nfe['ambiente'] ) {
		return $invoice; // Só nota autorizada de produção viaja com o objeto.
	}

	$informada = preg_replace( '/\D+/', '', (string) $order->get_meta( '_infixs_correios_automatico_invoice_key', true ) );

	if ( '' !== $informada && $informada !== $nfe['chave'] ) {
		return $invoice; // O lojista informou outra NF-e: os metas respondem por ela.
	}

	return [
		'invoice_key'    => $nfe['chave'],  // '35261112345678000195550010000012341123456781'
		'invoice_number' => $nfe['numero'], // '1234' (opcional: sai da chave)
		'source'         => 'meu-plugin-nfe',
		'source_label'   => 'Meu Plugin de NF-e',
	];
}

E a publicação nos metas, com o aviso de nota pronta logo depois:

// Quando a SEFAZ autorizar a NF-e do pedido:
function meu_plugin_publicar_nfe( WC_Order $order, string $numero, string $chave ) {
	$atual = preg_replace( '/\D+/', '', (string) $order->get_meta( '_infixs_correios_automatico_invoice_key', true ) );

	if ( '' !== $atual && $atual !== $chave ) {
		return; // O lojista (ou outro emissor) já informou outra nota: ela prevalece.
	}

	$order->update_meta_data( '_infixs_correios_automatico_invoice_number', $numero );
	$order->update_meta_data( '_infixs_correios_automatico_invoice_key', $chave ); // string de 44 dígitos
	$order->save();

	do_action( 'infixs_correios_automatico_order_invoice_ready', $order->get_id() );
}

Dicas para a sua integração

  • O filtro roda em cada linha da lista de pedidos do painel, em cada etiqueta e na caixa do pedido (sempre com for = display). Responda do que já está salvo no pedido ou na sua tabela, sem chamar API.
  • Na pré-postagem, o Correios Automático confere o tamanho e o modelo da chave. O dígito verificador, o ambiente (produção ou homologação) e o emitente ficam com o seu plugin: entregue só nota autorizada de produção e valide a chave antes (\Infixs\CorreiosAutomatico\Utils\AccessKey::hasValidCheckDigit()).
  • Uma resposta fora do formato é ignorada, e a leitura segue para os metas.
  • Encadeamento: devolva o $invoice recebido quando ele não for null, e também quando $context['vendor_id'] não for zero (o vendedor emite a nota dele).

2. Avisar que a nota ficou pronta

Assim que a nota estiver legível (metas salvos ou filtro pronto para responder), dispare a ação:

// Depois de salvar a nota (metas gravados ou filtro pronto para responder):
do_action( 'infixs_correios_automatico_order_invoice_ready', (int) $order_id );
  • Quem dispara é o seu plugin. Quem ouve é o PRO: ele relê o pedido, busca a nota de novo e cria a pré-postagem dos envios que aguardavam. No Correios LOG+ (PRO 1.6.2+), o mesmo aviso adianta o envio do XML ao armazém.
  • Pode disparar sempre que autorizar uma nota. Sem envio aguardando, a ação não faz nada; com o grátis sozinho, a nota já aparece na próxima pré-postagem do lojista.
  • O PRO trava o pedido enquanto retoma os envios (a trava sai no fim e, se sobrar, expira sozinha em 60 segundos) e tira cada envio da espera depois da tentativa. Assim, avisos simultâneos ou seguidos (webhook e cron, por exemplo) geram uma pré-postagem só.
  • Um pedido em Aguardando (on-hold) não é postado no aviso. Ele continua aguardando e sai quando o pedido voltar a Processando.
  • O PRO dispara as mesmas ações para as notas da integração nativa com o Bling; um ouvinte seu também as recebe.

3. Fazer a pré-postagem automática esperar a nota

PRO Sem nota no momento do pagamento, a pré-postagem automática sai na hora com DC-e. Para ela esperar a NF-e do seu plugin, responda true neste filtro:

// Como o PRO pergunta se deve esperar:
/**
 * @param bool     $wait             false por padrão.
 * @param WC_Order $order            Pedido que vai ser pré-postado.
 * @param int      $shipping_item_id Linha de frete do envio, 0 para o pedido inteiro.
 * @return bool
 */
$wait = apply_filters( 'infixs_correios_automatico_auto_prepost_wait_invoice', false, $order, $shipping_item_id );
  • É perguntado só quando o envio ainda não tem NF-e, e só se infixs_correios_automatico_prepost_use_order_invoice não devolveu false.
  • A pergunta chega quando o pedido entra em Processando, antes de woocommerce_payment_complete. Responda pelo que o plugin já sabe (configuração, regra de emissão) e não por um agendamento criado no pagamento.
  • Ela se repete a cada aviso de nota pronta e de hora em hora, na varredura dos pedidos que aguardam. Continue respondendo true enquanto a nota estiver a caminho, inclusive depois de uma rejeição que o lojista vai corrigir.
  • Quando a nota não vai sair (emissão desligada, pedido dispensado, ambiente de homologação), responda false: o envio sai com DC-e no próximo aviso ou na varredura.
  • $shipping_item_id é 0 para o pedido inteiro ou o id da linha de frete de cada volume. Uma NF-e cobre todos os volumes do pedido.
  • Se outro emissor já pediu a espera ($wait igual a true), devolva true.
add_filter( 'infixs_correios_automatico_auto_prepost_wait_invoice', 'meu_plugin_esperar_nfe', 10, 3 );

function meu_plugin_esperar_nfe( $wait, $order, $shipping_item_id ) {
	if ( $wait || ! $order instanceof WC_Order ) {
		return (bool) $wait; // Outro emissor já pediu a espera.
	}

	if ( 'yes' !== get_option( 'meu_plugin_emitir_automatico', 'yes' ) ) {
		return false; // O seu plugin não vai emitir: a pré-postagem segue com DC-e.
	}

	// Decida pelo que já se sabe do pedido: configuração, regra de emissão, situação da nota.
	// Uma nota rejeitada continua esperando: o lojista corrige e emite de novo.
	return ! in_array( meu_plugin_situacao_nfe( $order ), [ 'autorizada', 'cancelada', 'dispensada' ], true );
}
EventoO que acontece com o envio que aguarda
Aviso de nota pronta e a nota resolvePré-postagem com a NF-e, sem DC-e.
Aviso de nota pronta, sem nota, e o filtro ainda pede esperaContinua aguardando.
Aviso de nota pronta, sem nota, e o filtro passou a dizer falsePré-postagem com DC-e.
Varredura de hora em horaO mesmo que o aviso, para cada pedido que aguarda.
Pedido cancelado, reembolsado ou com falhaA espera é encerrada.
Pedido em Aguardando (on-hold)Não posta agora; posta quando o pedido volta a Processando.
Lojista pré-posta à mão (com NF-e ou DC-e)A espera daquele envio é encerrada.
Pedido enviado ao armazém Correios LOG+A espera é encerrada: o armazém posta.
Método com atraso de pré-postagem configuradoO atraso conta desde o pagamento; a pré-postagem sai quando ele termina.

Atalho

Se o seu plugin autoriza a nota de forma síncrona no próprio woocommerce_order_status_processing, com prioridade menor que 100, e salva o pedido, a pré-postagem automática já encontra a nota e sai com ela, sem passar pela espera.

Para acompanhar a espera: o meta _infixs_correios_automatico_auto_prepost_waiting_invoice (somente leitura) guarda shipping_item_id => timestamp de cada envio que aguarda; no painel, o pedido mostra “Pré-postagem automática aguardando a NF-e” e aparece em Pendências → NF-e; na primeira espera, o pedido ganha a nota privada “Pré-postagem automática aguardando a nota fiscal (NF-e).”

4. Quando a emissão falha

// A SEFAZ rejeitou a nota:
do_action( 'infixs_correios_automatico_order_invoice_failed', (int) $order_id, 'Rejeição 778: NCM inexistente' );
  • O PRO registra, em cada envio que aguarda, o erro de pré-postagem invoice_unavailable: “A pré-postagem automática aguarda a nota fiscal (NF-e), que não pôde ser emitida: {mensagem}”. O lojista vê o erro no pedido e no painel.
  • O envio continua aguardando. A nota reemitida, seguida do aviso de nota pronta, cria a pré-postagem e limpa o erro. Nesse meio-tempo o lojista pode pré-postar à mão com DC-e.
  • A mensagem passa por sanitize_text_field(). Use o motivo da SEFAZ, que é o que o lojista precisa para corrigir.
  • O aviso vale para envios que já aguardam. Para uma rejeição anterior ao pagamento, mostre o motivo também no status da nota.

Cancelamento da NF-e

Ao cancelar uma NF-e, apague os dois metas se eles ainda guardam aquela chave, e preserve uma nota que o lojista tenha digitado. Assim a próxima pré-postagem, a etiqueta e a DANFE passam a usar a nota certa.

// Ao cancelar a NF-e no seu plugin:
function meu_plugin_nfe_cancelada( WC_Order $order, string $chave_cancelada ) {
	$atual = preg_replace( '/\D+/', '', (string) $order->get_meta( '_infixs_correios_automatico_invoice_key', true ) );

	if ( $atual !== $chave_cancelada ) {
		return; // Os metas guardam outra nota (digitada pelo lojista ou de outro emissor).
	}

	$order->delete_meta_data( '_infixs_correios_automatico_invoice_number' );
	$order->delete_meta_data( '_infixs_correios_automatico_invoice_key' );
	$order->save();
}

Uma pré-postagem criada com a nota cancelada continua valendo nos Correios. Avise o lojista, por exemplo com uma nota no pedido, para cancelar e refazer a pré-postagem (o cancelamento de pré-postagem é um recurso do PRO). A página de impressão também avisa quando a chave da pré-postagem é diferente da chave da DANFE.

DANFE simplificada na etiqueta

O filtro infixs_correios_automatico_order_danfe entrega os dados da DANFE simplificada. A página de impressão, o PDF e a impressão automática do PRO colocam a DANFE junto com a etiqueta, separada ou embutida conforme o perfil de impressão (danfe.enabled e danfe.mode separate ou embedded).

// Como o Correios Automático pede a DANFE:
/**
 * @param array|null $danfe   Resposta dos emissores anteriores; começa em null.
 * @param WC_Order   $order   Pedido.
 * @param array      $context for ('label' | 'display'), shipping_item_id, vendor_id e invoice:
 *                            [ 'invoice_number' => '1234', 'invoice_key' => '3526...', 'model' => '55', 'source' => '...', 'source_label' => '...' ].
 * @return array|null
 */
$danfe = apply_filters( 'infixs_correios_automatico_order_danfe', null, $order, $context );
  • Roda quando o pedido tem NF-e resolvida, e $context['invoice'] traz essa nota. Responda só por ela.
  • Respostas: null (sem dados), [ 'schema' => 1, 'pending' => true ] (dados em preparação) ou o bloco completo, com os valores crus do XML autorizado.
  • Registrar o filtro liga as opções da DANFE no painel; registre-o quando o seu plugin tiver esses dados.
  • for é 'label' na impressão e 'display' no painel.
CampoOrigem no XMLRegra de aceite
schema—Exatamente 1 (OrderDanfeResolver::SCHEMA).
source / source_label—Identificador e nome do seu plugin.
environmentide/tpAmbproduction ou homologation.
keychNFe44 dígitos, modelo 55, dígito verificador válido e igual a $context['invoice']['invoice_key'].
numberide/nNF1 a 9 dígitos, igual ao número da chave.
serieside/serie1 a 3 dígitos, igual à série da chave.
operationide/tpNFout (1, saída) ou in (0, entrada).
issued_atide/dhEmiData e hora ISO-8601 (AAAA-MM-DDTHH:MM:SS, com o fuso do XML).
protocol.numberprotNFe/infProt/nProt15 dígitos.
protocol.authorized_atprotNFe/infProt/dhRecbtoData e hora ISO-8601.
emitter.name / .document / .ie / .ufemitNome obrigatório (a DANFE usa até 60 caracteres); documento igual ao CNPJ/CPF da chave; UF com 2 letras; IE opcional.
recipient.name / .document / .ie / .ufdestNome e UF obrigatórios (EX no exterior); documento e IE opcionais.
totaltotal/ICMSTot/vNFEm centavos (inteiro). Opcional: um valor fora do formato só fica fora da DANFE.
add_filter( 'infixs_correios_automatico_order_danfe', 'meu_plugin_danfe', 10, 3 );

function meu_plugin_danfe( $danfe, $order, $context ) {
	if ( null !== $danfe || ! $order instanceof WC_Order || ! empty( $context['vendor_id'] ) ) {
		return $danfe;
	}

	$nfe = meu_plugin_nfe_autorizada( $order );

	if ( ! $nfe || $nfe['chave'] !== $context['invoice']['invoice_key'] ) {
		return $danfe; // Responda só pela NF-e que o Correios Automático resolveu para o pedido.
	}

	if ( empty( $nfe['protocolo'] ) ) {
		return [ 'schema' => 1, 'pending' => true ]; // Dados ainda em preparação.
	}

	return [
		'schema'       => 1,
		'source'       => 'meu-plugin-nfe',
		'source_label' => 'Meu Plugin de NF-e',
		'environment'  => 'production',
		'key'          => '35261112345678000195550010000012341123456781',
		'number'       => '1234',
		'series'       => '1',
		'operation'    => 'out',
		'issued_at'    => '2026-11-04T10:15:00-03:00',
		'protocol'     => [ 'number' => '135260000000001', 'authorized_at' => '2026-11-04T10:15:30-03:00' ],
		'emitter'      => [ 'name' => 'Loja Exemplo LTDA', 'document' => '12345678000195', 'ie' => '123456789', 'uf' => 'SP' ],
		'recipient'    => [ 'name' => 'Cliente Exemplo', 'document' => '000.000.000-00', 'ie' => '', 'uf' => 'RJ' ],
		'total'        => 15990, // R$ 159,90 em centavos
	];
}

Um bloco recusado é registrado no log do Correios Automático com o motivo (reason: schema, environment, key_invalid, key_mismatch, number, operation, issued_at, protocol, emitter ou recipient), e a etiqueta sai sem a DANFE. Para imprimir só a DANFE de pedidos, \Infixs\CorreiosAutomatico\Core\Admin\PrintUrl::orders( [ $order_id ], PrintUrl::VIEW_DANFE ) devolve a URL da página de impressão no admin (exige manage_woocommerce).

Status da nota na lista de pedidos

Cada pedido do painel mostra o estado da NF-e. O padrão vem pronto (“NF-e nº 1234”, “Pré-postagem automática aguardando a NF-e” ou “Sem NF-e”), e o filtro deixa o seu plugin contar mais: nota em emissão, rejeitada com o motivo, cancelada e links para ela.

// Como o Correios Automático monta o status:
/**
 * @param array    $status  Estado padrão (ou o de emissores anteriores).
 * @param WC_Order $order   Pedido.
 * @param array    $context vendor_id (int), invoice (array|null, a NF-e resolvida) e waiting (bool).
 * @return array
 */
$status = apply_filters( 'infixs_correios_automatico_order_invoice_status', $status, $order, $context );
ChaveValores aceitos
statenone, pending, authorized, error ou cancelled.
tonemuted, warning, success ou danger. Sem ele, vale o tom do estado.
labelTexto curto, ex.: NF-e nº 1234.
detailTexto longo, ex.: o motivo da rejeição.
source / source_labelIdentificador e nome do seu plugin.
danfeready, pending, unavailable ou none. A prontidão calculada pelo plugin chega em $status['danfe'].
actionsAté 4 links { id, label, url, target }. A url precisa estar no mesmo host do home_url() ou do admin_url(); target aceita _blank.

Os textos passam por sanitize_text_field(), e um item fora do formato é descartado. Se $context['invoice'] traz a chave de outra nota, devolva $status como veio.

add_filter( 'infixs_correios_automatico_order_invoice_status', 'meu_plugin_status_nfe', 10, 3 );

function meu_plugin_status_nfe( $status, $order, $context ) {
	if ( ! is_array( $status ) || ! $order instanceof WC_Order || ! empty( $context['vendor_id'] ) ) {
		return $status;
	}

	$nfe = meu_plugin_nfe( $order ); // Qualquer situação: em emissão, autorizada, rejeitada...

	if ( ! $nfe ) {
		return $status; // Mantém o padrão do Correios Automático.
	}

	$resolvida = $context['invoice']['invoice_key'] ?? '';

	if ( '' !== $resolvida && $resolvida !== $nfe['chave'] ) {
		return $status; // O pedido viaja com a nota de outro emissor ou digitada à mão.
	}

	if ( 'rejeitada' === $nfe['situacao'] ) {
		return [
			'state'        => 'error',
			'tone'         => 'danger',
			'label'        => 'NF-e rejeitada',
			'detail'       => $nfe['motivo'],
			'source'       => 'meu-plugin-nfe',
			'source_label' => 'Meu Plugin de NF-e',
			'danfe'        => 'none',
			'actions'      => [
				[
					'id'     => 'abrir',
					'label'  => 'Abrir no Meu Plugin de NF-e',
					'url'    => admin_url( 'admin.php?page=meu-plugin-nfe&order=' . $order->get_id() ),
					'target' => '_blank',
				],
			],
		];
	}

	return $status;
}

Pendências próprias na lista de pedidos

Dois filtros acrescentam uma pendência sua ao filtro de pendências da rota GET /orders do painel, ao lado das nativas tracking, prepost e invoice. A chave precisa casar com [a-z_]+, e a condição é um trecho SQL já preparado que usa o marcador {order_id} no lugar da coluna do pedido (o plugin troca pelo nome certo, com posts ou HPOS).

add_filter( 'infixs_correios_automatico_order_pending_kinds', function ( $kinds ) {
	$kinds   = is_array( $kinds ) ? $kinds : [];
	$kinds[] = 'meu_plugin_nfe';

	return array_values( array_unique( $kinds ) );
} );

add_filter( 'infixs_correios_automatico_order_pending_condition', function ( $condition, $kind ) {
	if ( null !== $condition || 'meu_plugin_nfe' !== $kind ) {
		return $condition;
	}

	global $wpdb;

	$token = \Infixs\CorreiosAutomatico\Utils\OrderQuery::ORDER_ID_TOKEN; // '{order_id}'

	if ( \Infixs\CorreiosAutomatico\Utils\OrderQuery::usesOrdersTable() ) {
		$table  = \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore::get_meta_table_name();
		$column = 'order_id';
	} else {
		$table  = $wpdb->postmeta;
		$column = 'post_id';
	}

	return $wpdb->prepare(
		"EXISTS ( SELECT 1 FROM {$table} AS meu_nfe WHERE meu_nfe.{$column} = {$token} AND meu_nfe.meta_key = %s AND meu_nfe.meta_value = %s )",
		'_meu_plugin_nfe_situacao',
		'rejeitada'
	);
}, 10, 2 );

A pendência própria fica disponível em GET /orders?pending=meu_plugin_nfe e na preferência salva do operador. O menu Pendências do painel mostra as pendências nativas e as do Correios LOG+.

Logística reversa

Na devolução, o Correios Automático já reaproveita o documento fiscal da ida. O filtro permite ajustar essa escolha, por exemplo trocando uma NF-e cancelada e reemitida pela nova.

// Como o Correios Automático decide o documento da devolução:
/**
 * @param array        $document         emiteDCe, source e, com nota, invoice_number e invoice_key.
 * @param WC_Order     $order            Pedido devolvido.
 * @param int          $shipping_item_id Linha de frete devolvida, 0 para o pedido inteiro.
 * @param Prepost|null $outbound         Pré-postagem da ida (\Infixs\CorreiosAutomatico\Models\Prepost), ou null.
 * @return array
 */
$document = apply_filters( 'infixs_correios_automatico_reverse_prepost_fiscal_document', $document, $order, $shipping_item_id, $outbound );
source recebidoQuandoDocumento
outbound_invoiceA ida saiu com NF-e emitida pela loja (mesma raiz de CNPJ do remetente).A mesma NF-e, com emiteDCe: 'N'.
outbound_dceA ida saiu com DC-e.A mesma DC-e (chave modelo 99). Se os Correios não acharem a chave, pede uma DC-e nova (emiteDCe: 'S').
order_invoiceA ida não saiu com NF-e da loja nem com DC-e: o plugin não pré-postou o envio (ou a pré-postagem foi cancelada ou expirou), a pré-postagem é anterior à DC-e ou foi com a NF-e de outro emitente.A NF-e do pedido, se emitida pela loja.
declarationNenhum dos casos acima.Só a declaração de conteúdo.
  • O filtro roda na criação da reversa e na prévia (GET /preposts/reverse/document). Quando quem pediu a reversa já mandou o documento (source = caller), ele vai como veio.
  • Na resposta, emiteDCe só pede DC-e com a string 'S'; a chave precisa ser de modelo 55 ou 99; invoice_number fica só com dígitos e, vazio, sai da chave.
  • Se os Correios recusarem o documento (400 ou 422), o plugin refaz a reversa sem documento e deixa uma nota no pedido. Essa proteção vale para os source acima: mantenha o source recebido para conservá-la.
  • A reversa é do Contrato Próprio; no Contrato pré-pago, a rota responde 422 prepaid_reverse_not_supported.
add_filter( 'infixs_correios_automatico_reverse_prepost_fiscal_document', 'meu_plugin_documento_da_devolucao', 10, 4 );

function meu_plugin_documento_da_devolucao( $document, $order, $shipping_item_id, $outbound ) {
	if ( ! is_array( $document ) || ! $order instanceof WC_Order || empty( $document['invoice_key'] ) ) {
		return $document; // Mantém a decisão do Correios Automático.
	}

	$nova = meu_plugin_nfe_que_substituiu( $order, $document['invoice_key'] ); // null se a nota da ida segue valendo

	if ( ! $nova ) {
		return $document;
	}

	return [
		'invoice_key'    => $nova['chave'],
		'invoice_number' => $nova['numero'],
		'emiteDCe'       => 'N',
		'source'         => $document['source'], // Um source conhecido mantém a nova tentativa sem documento.
	];
}

Correios LOG+ e o xPed

PRO No Correios LOG+, o armazém dos Correios separa, embala e posta. O pedido vai ao armazém, a NF-e é emitida com o id do pedido nos Correios em <compra><xPed> e o XML é entregue ao armazém. Os pedidos do LOG+ não passam pela pré-postagem automática.

  1. Ouça infixs_correios_automatico_pro_logplus_order_created( $order_id, $correios_id, $order ). O $correios_id, também gravado no meta _infixs_logplus_id, é o valor do xPed e é diferente do número do pedido da loja.
  2. Emita a NF-e depois desse evento, com <compra><xPed> igual a $correios_id. A nota do pedido orienta também a transportadora: Correios, CNPJ 34.028.316/0001-03.
  3. Entregue o XML autorizado (o nfeProc completo) pelo filtro infixs_correios_automatico_pro_logplus_invoice_xml.
  4. Dispare infixs_correios_automatico_order_invoice_ready: o envio do XML é adiantado para agora (PRO 1.6.2+; na 1.6.0 e na 1.6.1, o XML espera o envio agendado ou a rodada de 30 minutos).
// 1) O pedido entrou no armazém: guarde o id dos Correios, que a NF-e leva em <compra><xPed>.
add_action( 'infixs_correios_automatico_pro_logplus_order_created', function ( $order_id, $correios_id, $order ) {
	meu_plugin_agendar_emissao( (int) $order_id, [ 'xPed' => (string) $correios_id ] );
}, 10, 3 );

// 2) Entregue o XML autorizado (nfeProc completo).
add_filter( 'infixs_correios_automatico_pro_logplus_invoice_xml', function ( $xml, $order, $correios_id ) {
	if ( ( is_string( $xml ) && '' !== trim( $xml ) ) || ! $order instanceof WC_Order ) {
		return $xml; // Outro plugin já entregou.
	}

	$autorizado = meu_plugin_xml_autorizado( $order ); // emitido com xPed = $correios_id

	return $autorizado ? $autorizado : $xml;
}, 10, 3 );

// 3) Explique ao lojista onde corrigir quando o XML sai sem o xPed.
add_filter( 'infixs_correios_automatico_pro_logplus_invoice_xml_error', function ( $message, $error, $xml, $order, $correios_id ) {
	$data = $error instanceof WP_Error ? $error->get_error_data() : null;

	if ( is_array( $data ) && 'xped' === ( $data['reason'] ?? '' ) && meu_plugin_e_meu_xml( $order, $xml ) ) {
		return sprintf( 'Emita a NF-e de novo no Meu Plugin de NF-e com o campo "Pedido de compra" = %s.', $correios_id );
	}

	return $message;
}, 10, 5 );

// 4) Assim que a NF-e autorizar, adiante o envio do XML (PRO 1.6.2+; sem o aviso, o envio sai na rodada seguinte).
do_action( 'infixs_correios_automatico_order_invoice_ready', $order_id );
EventoO que acontece
Pedido criado no armazémO primeiro envio do XML é agendado para 30 minutos depois.
Aviso de nota pronta (PRO 1.6.2+)O envio do XML é adiantado para agora (e um XML recusado na conferência é reaberto, se ainda restam tentativas).
Rodada a cada 30 minutosProcura o XML dos pedidos que ainda aguardam; sem XML, apenas espera, sem contar tentativa.
XML reprovado na conferência localO pedido ganha uma nota com o motivo, uma vez por motivo. Um novo aviso de nota pronta tenta de novo.
Armazém recusa o XMLAs 7 primeiras tentativas seguem o manual dos Correios (30 minutos e depois de 10 em 10). Se o armazém responde que o pedido ainda não está liberado para faturamento, ou os Correios não respondem, o XML segue indo sozinho: de hora em hora até 3 dias do pedido e a cada 3 horas até 10 dias (filtro infixs_correios_automatico_pro_logplus_invoice_wait_days), com uma nota no pedido. Outras recusas param na 7ª, e o lojista reenvia pelo quadro Correios LOG+ do pedido. O botão Atualizar do pedido também envia na hora o XML que ele espera. Desde PRO 1.6.4.
Armazém aceitaNota no pedido: “XML da NF-e entregue ao armazém Correios LOG+.”
  • O XML pode ter até 1 MB. A conferência procura compra/xPed sem depender do namespace e remove BOM e espaços das pontas.
  • O XML que o lojista envia à mão (no quadro Correios LOG+ do pedido ou pela REST) tem prioridade, e enquanto ele existir o filtro não é consultado (desde a 1.6.2).
  • Registre o filtro na prioridade padrão (10) e devolva o $xml recebido quando ele não for seu: assim o seu plugin convive com outras integrações de nota.
  • O filtro de mensagem infixs_correios_automatico_pro_logplus_invoice_xml_error vale para a conferência local; quando falta o xPed ou ele está diferente, $error->get_error_data()['reason'] é xped.
  • O Correios LOG+ usa o Contrato Próprio, a licença PRO ativa e a integração ligada nas configurações.

Ler a pré-postagem e o rastreio

  • infixs_correios_automatico_prepost_created( $order_id, $prepost ) dispara depois de toda pré-postagem criada, de ida e de reversa, por qualquer caminho. Ela chega antes de o código de rastreio ser gravado.
  • O $prepost traz id, order_id, shipping_item_id, vendor_id, object_code (o código do objeto, que é o rastreio), service_code, status, status_label, expire_at, invoice_key e invoice_number como os Correios responderam, e dce. Leia os campos com isset().
  • Para saber se é devolução, compare object_code com o meta _infixs_correios_automatico_reverse_prepost_code (já gravado nesse momento), use DeliveryServiceCode::isReverseService( $prepost->service_code ) ou ouça infixs_correios_automatico_reverse_prepost_created.
  • infixs_correios_automatico_after_add_tracking_code( $code, $order_id ) recebe o código primeiro e o pedido depois. Ele dispara para todo código gravado, inclusive de outras transportadoras e da devolução; Container::trackingService()->getShipmentCodes( $order_id ) separa os objetos de ida.
  • Na REST do WooCommerce, GET /wp-json/wc/v3/orders/{id} traz tracking_codes e correios_tracking_code.
// Pré-postagem criada (ida e reversa, por qualquer caminho).
add_action( 'infixs_correios_automatico_prepost_created', function ( $order_id, $prepost ) {
	$order = wc_get_order( $order_id );

	if ( ! $order || ! is_object( $prepost ) || empty( $prepost->object_code ) ) {
		return;
	}

	if ( $prepost->object_code === $order->get_meta( '_infixs_correios_automatico_reverse_prepost_code', true ) ) {
		return; // Devolução: volta para a loja.
	}

	$com_dce = ! empty( $prepost->dce );
	$chave   = $com_dce ? '' : (string) $order->get_meta( '_infixs_correios_automatico_invoice_key', true );

	meu_plugin_registrar_objeto( (int) $order_id, (string) $prepost->object_code, $chave );
}, 10, 2 );

// Código de rastreio gravado. Atenção à ordem: código primeiro, pedido depois.
add_action( 'infixs_correios_automatico_after_add_tracking_code', function ( $code, $order_id ) {
	$ida = wp_list_pluck(
		\Infixs\CorreiosAutomatico\Container::trackingService()->getShipmentCodes( (int) $order_id ),
		'code'
	);

	if ( in_array( (string) $code, array_map( 'strval', $ida ), true ) ) {
		meu_plugin_enviar_rastreio( (int) $order_id, (string) $code );
	}
}, 10, 2 );

ERP fora do WordPress

Um ERP que emite a nota fora da loja integra pela REST, com uma senha de aplicação de um usuário com manage_woocommerce:

  • Pré-postar com a NF-e: POST /wp-json/infixs-correios-automatico/v1/preposts com order_id, shipping_item_id, invoice_number e invoice_key. A rota pré-posta, grava a nota no pedido e adiciona o código de rastreio. Veja o exemplo.
  • PRO Gravar a nota pelo WooCommerce: PUT /wp-json/wc/v3/orders/{id} com os dois metas em meta_data pré-posta na hora cada volume ainda não pré-postado, com a NF-e e o código de rastreio. Veja REST do WooCommerce.
  • PRO XML do Correios LOG+: POST /wp-json/infixs-correios-automatico/v1/logplus/order/{id}/xml com { "xml": "…" }.
  • Acompanhar: GET /wp-json/infixs-correios-automatico/v1/orders/{id} (nota, pré-postagens, erros e rastreios) e GET /wp-json/infixs-correios-automatico/v1/orders?pending=invoice&status= (pedidos aguardando a nota).

Pré-postar pelo seu código

Para a maioria das integrações, entregar a nota e disparar o aviso de nota pronta basta: o PRO e o lojista continuam no controle. Se o seu plugin precisar criar a pré-postagem ele mesmo, Container::prepostService()->createPrepost( $order_id, $data ) aceita invoice_key, invoice_number, emiteDCe (S ou N), shipping_item_id, vendor_id, receiptNotice, receiptNoticeElectronic, ownHands e dangerousProduct. Complete o que a rota REST faz por você: confira o contrato, valide a chave, evite duplicar o envio e grave o código de rastreio. No marketplace, deixe de fora as remessas dos vendedores, que saem com a nota deles.

function meu_plugin_prepostar_com_nfe( int $order_id, string $chave, string $numero ) {
	$c = '\Infixs\CorreiosAutomatico\Container';

	if ( ! class_exists( $c ) || ! method_exists( $c, 'contractMode' ) ) {
		return new WP_Error( 'ca_missing', 'Correios Automático 1.14.0 ou superior não está ativo.' );
	}

	$erro = $c::contractMode()->prepostError();

	if ( $erro ) {
		return $erro; // Contrato Próprio desligado, ou Contrato pré-pago desconectado ou com a conexão expirada.
	}

	$key_class = '\Infixs\CorreiosAutomatico\Utils\AccessKey';
	$chave     = (string) $key_class::normalize( $chave );

	if ( ! $key_class::isInvoice( $chave ) || ! $key_class::hasValidCheckDigit( $chave ) ) {
		return new WP_Error( 'invalid_key', 'Chave de NF-e inválida.' );
	}

	$order = wc_get_order( $order_id );

	if ( ! $order ) {
		return new WP_Error( 'invalid_order', 'Pedido não encontrado.' );
	}

	$entidade = '\Infixs\CorreiosAutomatico\Entities\Order';

	foreach ( $c::shipmentContextResolver()->resolveShipmentContexts( $order_id, [] ) as $ctx ) {
		// O vendedor da remessa, resolvido como a pré-postagem o resolve.
		$escopo = $entidade::resolveScope( new $entidade( $order ), [ 'for' => 'prepost' ] + $ctx );

		if ( ! empty( $escopo['vendor_id'] ) ) {
			continue; // No marketplace, o vendedor emite a nota dele.
		}

		$item_id = isset( $ctx['shipping_item_id'] ) ? (int) $ctx['shipping_item_id'] : 0;
		$item    = $item_id ? $order->get_item( $item_id ) : null;
		$feito   = $item
			? $item->get_meta( '_infixs_correios_automatico_prepost_id' )
			: $order->get_meta( '_infixs_correios_automatico_prepost_created' );

		if ( $feito ) {
			continue; // Já pré-postado.
		}

		$prepost = $c::prepostService()->createPrepost( $order_id, $ctx + [
			'invoice_key'    => $chave,
			'invoice_number' => $numero,
			'emiteDCe'       => 'N',
		] );

		if ( is_wp_error( $prepost ) ) {
			continue; // O erro já ficou registrado no pedido.
		}

		$c::trackingService()->add( $order_id, $prepost->object_code, false, [
			'shipping_item_id' => (int) $prepost->shipping_item_id,
		] );
	}

	return true;
}

Exemplo completo

O mini-plugin “Meu Plugin de NF-e” (PHP 7.4) faz a integração inteira só com ganchos públicos e metas do pedido: entrega a nota, espera no PRO, mostra o status, imprime a DANFE, cuida da reversa, do Correios LOG+, do cancelamento e da leitura do rastreio. Troque a classe My_NFe_Record pela sua tabela ou serviço de notas.

ParteO que fazGanchos
my_nfe_boot_correios_integration()No plugins_loaded 20, registra a integração quando o grátis 1.11.0+ e o WooCommerce estão ativos.—
My_NFe_Access_KeyConfere 44 dígitos, modelo 55 e dígito verificador (módulo 11).—
orderInvoice()Responde só nota autorizada de produção; respeita outro emissor, o vendedor do marketplace e a nota digitada à mão.order_invoice (10, 3)
waitInvoice()Espera enquanto a nota vai sair (rejeitada continua esperando); pedido marcado sem nota não espera.auto_prepost_wait_invoice (10, 3)
invoiceStatus()Estado próprio (em emissão, autorizada, rejeitada, cancelada, sem nota) e 2 links do admin.order_invoice_status (10, 3)
orderDanfe()Bloco cru do XML; pending enquanto faltam dados.order_danfe (10, 3)
reverseDocument()Troca a NF-e cancelada da ida pela nova, mantendo o source.reverse_prepost_fiscal_document (10, 4)
onLogPlusOrderCreated(), logPlusInvoiceXml(), logPlusRefusal()Guarda o xPed, entrega o XML e explica a recusa por xPed.LOG+ (10, 3 · 10, 3 · 10, 5)
onPrepostCreated(), onTrackingCodeAdded()Lembra a chave usada em cada objeto de ida e guarda só o rastreio de ida.prepost_created e after_add_tracking_code (10, 2)
onAuthorized(), onFailed(), onSkipped(), onCancelled()Publica os metas e dispara o aviso; repassa a rejeição; avisa que o pedido não vai ter nota (o envio sai com a DC-e); no cancelamento, limpa os metas e lista os objetos que saíram com a nota cancelada.order_invoice_ready, order_invoice_failed

No seu plugin, ligue os momentos da emissão aos eventos do exemplo. Marque também o pedido que não vai ter nota: assim o envio que esperava por ela sai com a DC-e.

$records = new My_NFe_Record();

// Opcional, ao enviar a nota à SEFAZ (o painel mostra "NF-e em emissão"):
$records->saveProcessing( $order );

// NF-e autorizada pela SEFAZ:
$records->saveAuthorized( $order, [
	'key'           => $chave,          // 44 dígitos
	'number'        => $nNF,
	'series'        => $serie,
	'environment'   => 'production',    // ou 'homologation'
	'operation'     => 'out',
	'issued_at'     => $dhEmi,          // ISO-8601, como no XML
	'protocol'      => $nProt,          // 15 dígitos
	'authorized_at' => $dhRecbto,
	'emitter'       => [ 'name' => $emit_nome, 'document' => $emit_cnpj, 'ie' => $emit_ie, 'uf' => $emit_uf ],
	'recipient'     => [ 'name' => $dest_nome, 'document' => $dest_doc, 'ie' => $dest_ie, 'uf' => $dest_uf ],
	'total_cents'   => $vNF_em_centavos,
	'xml'           => $xml_nfeProc,
] );
do_action( 'my_nfe_authorized', $order->get_id() );

// NF-e rejeitada:
$records->saveRejected( $order, $xMotivo );
do_action( 'my_nfe_failed', $order->get_id(), $xMotivo );

// NF-e cancelada:
$records->saveCancelled( $order );
do_action( 'my_nfe_cancelled', $order->get_id(), $chave );

// Pedido que não vai ter NF-e (o envio que esperava a nota sai com a DC-e):
$records->saveSkipped( $order );
do_action( 'my_nfe_skipped', $order->get_id() );
Ver o código completo do exemplo (meu-plugin-nfe.php)
<?php
/**
 * Plugin Name:       Meu Plugin de NF-e (integração com o Correios Automático)
 * Description:       Exemplo de como um plugin de NF-e entrega a nota à pré-postagem do Correios Automático (grátis 1.11.0+ e PRO 1.3.0+): NF-e no lugar da DC-e, espera da pré-postagem automática, DANFE simplificada, status no painel, logística reversa, XML do armazém Correios LOG+ e leitura do rastreio.
 * Version:           1.0.0
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Author:            Meu Plugin
 * Text Domain:       meu-plugin-nfe
 *
 * O Correios Automático é opcional: sem ele, nada deste arquivo é registrado.
 * O exemplo fala com ele só por ganchos públicos e metas do pedido.
 *
 * Onde ligar no SEU plugin (o resto é a integração pronta):
 *   1. Ao autorizar a NF-e: grave a nota com My_NFe_Record::saveAuthorized() e dispare do_action( 'my_nfe_authorized', $order_id ).
 *   2. Ao ser rejeitada: My_NFe_Record::saveRejected() e do_action( 'my_nfe_failed', $order_id, $mensagem ).
 *   3. Ao cancelar: My_NFe_Record::saveCancelled() e do_action( 'my_nfe_cancelled', $order_id, $chave ).
 *   4. Pedido que não vai ter NF-e: My_NFe_Record::saveSkipped() e do_action( 'my_nfe_skipped', $order_id ).
 *   Opcional: ao enviar a nota à SEFAZ, My_NFe_Record::saveProcessing() mostra "NF-e em emissão" no painel.
 * Troque My_NFe_Record pela sua tabela/serviço de notas: a integração só usa os métodos públicos dela.
 */

defined( 'ABSPATH' ) || exit;

/**
 * Regras da chave de acesso de 44 dígitos que o Correios Automático confere.
 *
 * UF(2) AAMM(4) CNPJ(14) modelo(2) série(3) número(9) tpEmis(1) código(8) DV(1).
 */
final class My_NFe_Access_Key {

	/**
	 * Só os dígitos, quando são 44; vazio em qualquer outro caso.
	 *
	 * @param mixed $key Chave como foi lida ou guardada.
	 */
	public static function normalize( $key ): string {
		if ( ! is_string( $key ) && ! is_int( $key ) ) {
			return '';
		}

		$digits = (string) preg_replace( '/\D+/', '', (string) $key );

		return 44 === strlen( $digits ) ? $digits : '';
	}

	/**
	 * Chave inteira de NF-e (modelo 55) com o dígito verificador certo.
	 *
	 * @param mixed $key Chave.
	 */
	public static function isValidInvoice( $key ): bool {
		$digits = self::normalize( $key );

		if ( '' === $digits || '55' !== substr( $digits, 20, 2 ) ) {
			return false;
		}

		return self::checkDigit( substr( $digits, 0, 43 ) ) === (int) $digits[43];
	}

	/**
	 * Número da nota gravado na chave (posições 26 a 34), sem os zeros à esquerda.
	 *
	 * @param string $key Chave normalizada.
	 */
	public static function number( string $key ): string {
		$number = ltrim( substr( $key, 25, 9 ), '0' );

		return '' === $number ? '0' : $number;
	}

	/**
	 * Módulo 11 com pesos de 2 a 9 da direita para a esquerda; resto 0 ou 1 dá 0.
	 *
	 * @param string $body Os 43 primeiros dígitos.
	 */
	public static function checkDigit( string $body ): int {
		$sum = 0;
		$weight = 2;

		for ( $index = strlen( $body ) - 1; $index >= 0; $index-- ) {
			$sum += (int) $body[ $index ] * $weight;
			$weight = 9 === $weight ? 2 : $weight + 1;
		}

		$rest = $sum % 11;

		return $rest < 2 ? 0 : 11 - $rest;
	}
}

/**
 * Configurações do seu plugin que a integração consulta.
 */
final class My_NFe_Settings {

	/**
	 * Se o plugin emite a NF-e sozinho quando o pedido é pago.
	 */
	public function issuesAutomatically(): bool {
		return 'yes' === get_option( 'my_nfe_auto_issue', 'yes' );
	}

	/**
	 * Se o lojista quer que a pré-postagem automática do PRO espere a NF-e.
	 */
	public function waitsForCorreios(): bool {
		return 'yes' === get_option( 'my_nfe_correios_wait', 'yes' );
	}

	/**
	 * Ambiente em que o plugin emite: `production` ou `homologation`.
	 */
	public function environment(): string {
		return 'homologation' === get_option( 'my_nfe_environment', 'production' ) ? 'homologation' : 'production';
	}
}

/**
 * Onde o seu plugin guarda a NF-e do pedido. Aqui é um meta do próprio pedido;
 * no seu plugin, troque pela sua tabela ou serviço.
 */
final class My_NFe_Record {

	const META = '_my_nfe';

	const STATUS_PROCESSING = 'processing';

	const STATUS_AUTHORIZED = 'authorized';

	const STATUS_REJECTED = 'rejected';

	const STATUS_CANCELLED = 'cancelled';

	const STATUS_SKIPPED = 'skipped';

	/** Campos do pedido que sobrevivem à troca da nota. */
	const HISTORY_FIELDS = [ 'previous_keys', 'prepost_keys', 'xped', 'tracking_codes' ];

	/**
	 * A nota do pedido, vazia quando o plugin ainda não tem nenhuma.
	 */
	public function get( WC_Order $order ): array {
		$record = $order->get_meta( self::META, true );

		return is_array( $record ) ? $record : [];
	}

	public function status( WC_Order $order ): string {
		$record = $this->get( $order );

		return isset( $record['status'] ) ? (string) $record['status'] : '';
	}

	/**
	 * A NF-e autorizada do pedido, em qualquer ambiente, ou null.
	 */
	public function authorizedInvoice( WC_Order $order ): ?array {
		$record = $this->get( $order );

		if ( self::STATUS_AUTHORIZED !== ( $record['status'] ?? '' ) || ! My_NFe_Access_Key::isValidInvoice( $record['key'] ?? '' ) ) {
			return null;
		}

		$record['key'] = My_NFe_Access_Key::normalize( $record['key'] );

		return $record;
	}

	/**
	 * A NF-e que pode viajar com o objeto: autorizada e de produção.
	 *
	 * A chave não diz o ambiente, e o Correios Automático não confere isso:
	 * uma nota de homologação iria aos Correios como se valesse.
	 */
	public function usableInvoice( WC_Order $order ): ?array {
		$record = $this->authorizedInvoice( $order );

		return $record && 'production' === ( $record['environment'] ?? '' ) ? $record : null;
	}

	/**
	 * Chaves que o plugin já pôs no pedido e que foram canceladas.
	 *
	 * @return string[]
	 */
	public function previousKeys( WC_Order $order ): array {
		$record = $this->get( $order );

		return isset( $record['previous_keys'] ) && is_array( $record['previous_keys'] ) ? array_map( 'strval', $record['previous_keys'] ) : [];
	}

	/**
	 * Grava a NF-e autorizada. O seu fluxo de emissão chama isto e depois
	 * dispara `my_nfe_authorized`.
	 *
	 * @param array $nfe key, number, series, environment, operation (`out`/`in`), issued_at, protocol,
	 *                   authorized_at, emitter e recipient (name, document, ie, uf), total_cents e xml.
	 */
	public function saveAuthorized( WC_Order $order, array $nfe ): void {
		$this->replace( $order, array_merge( $nfe, [
			'status' => self::STATUS_AUTHORIZED,
			'key' => My_NFe_Access_Key::normalize( $nfe['key'] ?? '' ),
			'message' => '',
		] ) );
	}

	public function saveRejected( WC_Order $order, string $message ): void {
		$this->replace( $order, [ 'status' => self::STATUS_REJECTED, 'message' => $message ] );
	}

	/**
	 * A nota foi enviada à SEFAZ e aguarda a resposta. Opcional: o painel passa a
	 * mostrar "NF-e em emissão", e a pré-postagem automática continua esperando.
	 */
	public function saveProcessing( WC_Order $order ): void {
		$this->replace( $order, [ 'status' => self::STATUS_PROCESSING, 'message' => '' ] );
	}

	/**
	 * O pedido não vai ter NF-e (por exemplo, a loja não emite para ele). O seu
	 * fluxo chama isto e depois dispara `my_nfe_skipped`: o envio que esperava a
	 * nota deixa de esperar e sai com a DC-e.
	 */
	public function saveSkipped( WC_Order $order ): void {
		$this->replace( $order, [ 'status' => self::STATUS_SKIPPED, 'message' => '' ] );
	}

	public function saveCancelled( WC_Order $order ): void {
		$record = $this->get( $order );
		$previous = $this->previousKeys( $order );

		if ( ! empty( $record['key'] ) ) {
			$previous[] = (string) $record['key'];
		}

		$this->put( $order, [ 'status' => self::STATUS_CANCELLED, 'previous_keys' => array_values( array_unique( $previous ) ) ] );
	}

	/**
	 * Lembra com que NF-e (chave vazia: DC-e) cada objeto de ida foi pré-postado.
	 * Um pedido em volumes tem um objeto por volume.
	 */
	public function rememberPrepost( WC_Order $order, string $object_code, string $key ): void {
		$keys = $this->prepostKeys( $order );
		$keys[ $object_code ] = $key;

		$this->put( $order, [ 'prepost_keys' => $keys ] );
	}

	/**
	 * Objetos de ida do pedido e a chave com que cada um foi pré-postado.
	 *
	 * @return array<string, string> código do objeto => chave (vazia: DC-e).
	 */
	public function prepostKeys( WC_Order $order ): array {
		$record = $this->get( $order );

		return isset( $record['prepost_keys'] ) && is_array( $record['prepost_keys'] ) ? array_map( 'strval', $record['prepost_keys'] ) : [];
	}

	/**
	 * Guarda o id do pedido no armazém Correios LOG+, que a NF-e leva em `<compra><xPed>`.
	 */
	public function rememberPurchaseOrder( WC_Order $order, string $correios_id ): void {
		$this->put( $order, [ 'xped' => $correios_id ] );
	}

	public function addTrackingCode( WC_Order $order, string $code ): void {
		$record = $this->get( $order );
		$codes = isset( $record['tracking_codes'] ) && is_array( $record['tracking_codes'] ) ? $record['tracking_codes'] : [];

		if ( in_array( $code, $codes, true ) ) {
			return;
		}

		$codes[] = $code;
		$this->put( $order, [ 'tracking_codes' => $codes ] );
	}

	/**
	 * Troca a nota inteira, mantendo só o histórico do pedido: os campos de uma
	 * NF-e anterior nunca se misturam com os da nova.
	 */
	private function replace( WC_Order $order, array $fields ): void {
		$history = array_intersect_key( $this->get( $order ), array_flip( self::HISTORY_FIELDS ) );

		$this->write( $order, array_merge( $history, $fields ) );
	}

	/**
	 * Mescla os campos na nota.
	 */
	private function put( WC_Order $order, array $fields ): void {
		$this->write( $order, array_merge( $this->get( $order ), $fields ) );
	}

	/**
	 * Salva só os metas: estes métodos também rodam dentro de ações do Correios
	 * Automático, que salvam o pedido logo depois.
	 */
	private function write( WC_Order $order, array $record ): void {
		$order->update_meta_data( self::META, $record );
		$order->save_meta_data();
	}
}

/**
 * Toda a conversa com o Correios Automático (grátis e PRO).
 */
final class My_NFe_Correios_Bridge {

	const SOURCE = 'meu-plugin-nfe';

	const SOURCE_LABEL = 'Meu Plugin de NF-e';

	/** Metas do pedido que o Correios Automático lê. */
	const NUMBER_META = '_infixs_correios_automatico_invoice_number';

	const KEY_META = '_infixs_correios_automatico_invoice_key';

	/** Último objeto de logística reversa do pedido, gravado antes de a ação de criação disparar. */
	const REVERSE_CODE_META = '_infixs_correios_automatico_reverse_prepost_code';

	/** Versão do bloco da DANFE simplificada (OrderDanfeResolver::SCHEMA). */
	const DANFE_SCHEMA = 1;

	const DANFE_RESOLVER_CLASS = '\Infixs\CorreiosAutomatico\Services\OrderDanfeResolver';

	const CONTAINER_CLASS = '\Infixs\CorreiosAutomatico\Container';

	/** @var My_NFe_Record */
	private $records;

	/** @var My_NFe_Settings */
	private $settings;

	public function __construct( My_NFe_Record $records, My_NFe_Settings $settings ) {
		$this->records = $records;
		$this->settings = $settings;
	}

	public function register(): void {
		// 1. Entregar a NF-e à pré-postagem (grátis 1.11.0+ e PRO).
		add_filter( 'infixs_correios_automatico_order_invoice', [ $this, 'orderInvoice' ], 10, 3 );

		// 2. Fazer a pré-postagem automática do PRO esperar a nota (PRO 1.3.0+; sem o PRO, ninguém pergunta).
		add_filter( 'infixs_correios_automatico_auto_prepost_wait_invoice', [ $this, 'waitInvoice' ], 10, 3 );

		// 3. Status da nota na lista de Pedidos do Correios Automático (grátis 1.12.0+).
		add_filter( 'infixs_correios_automatico_order_invoice_status', [ $this, 'invoiceStatus' ], 10, 3 );

		// 4. DANFE simplificada na etiqueta. Registrar este filtro liga as opções da DANFE no painel,
		//    então registre só quando o Correios Automático a imprime e o seu plugin tem os dados.
		if ( method_exists( self::DANFE_RESOLVER_CLASS, 'resolve' ) ) {
			add_filter( 'infixs_correios_automatico_order_danfe', [ $this, 'orderDanfe' ], 10, 3 );
		}

		// 5. Logística reversa (grátis 1.14.0+).
		if ( version_compare( INFIXS_CORREIOS_AUTOMATICO_PLUGIN_VERSION, '1.14.0', '>=' ) ) {
			add_filter( 'infixs_correios_automatico_reverse_prepost_fiscal_document', [ $this, 'reverseDocument' ], 10, 4 );
		}

		// 6. Armazém Correios LOG+ (PRO 1.6.0+; a mensagem de recusa é da 1.6.2+).
		add_action( 'infixs_correios_automatico_pro_logplus_order_created', [ $this, 'onLogPlusOrderCreated' ], 10, 3 );
		add_filter( 'infixs_correios_automatico_pro_logplus_invoice_xml', [ $this, 'logPlusInvoiceXml' ], 10, 3 );
		add_filter( 'infixs_correios_automatico_pro_logplus_invoice_xml_error', [ $this, 'logPlusRefusal' ], 10, 5 );

		// 7. Ler o resultado: pré-postagem criada e código de rastreio.
		add_action( 'infixs_correios_automatico_prepost_created', [ $this, 'onPrepostCreated' ], 10, 2 );
		add_action( 'infixs_correios_automatico_after_add_tracking_code', [ $this, 'onTrackingCodeAdded' ], 10, 2 );

		// 8. Eventos do SEU plugin, que viram os avisos do contrato.
		add_action( 'my_nfe_authorized', [ $this, 'onAuthorized' ] );
		add_action( 'my_nfe_failed', [ $this, 'onFailed' ], 10, 2 );
		add_action( 'my_nfe_skipped', [ $this, 'onSkipped' ] );
		add_action( 'my_nfe_cancelled', [ $this, 'onCancelled' ], 10, 2 );
	}

	/**
	 * NF-e do pedido para a pré-postagem, a etiqueta e o painel.
	 *
	 * Roda para cada pedido da lista do painel e para cada etiqueta: responda
	 * do que já está salvo, sem chamar API.
	 *
	 * @param array|null $invoice Resposta dos emissores anteriores.
	 * @param mixed      $order   Pedido.
	 * @param mixed      $context for (`prepost`/`display`), shipping_item_id, vendor_id.
	 *
	 * @return array|null
	 */
	public function orderInvoice( $invoice, $order, $context = [] ) {
		if ( null !== $invoice || ! $order instanceof WC_Order ) {
			return $invoice; // Outro emissor já respondeu.
		}

		if ( is_array( $context ) && ! empty( $context['vendor_id'] ) ) {
			return $invoice; // No marketplace, o vendedor emite a nota dele.
		}

		$nfe = $this->records->usableInvoice( $order );

		if ( ! $nfe ) {
			return $invoice;
		}

		$informed = My_NFe_Access_Key::normalize( $order->get_meta( self::KEY_META, true ) );

		if ( '' !== $informed && $informed !== $nfe['key'] && ! in_array( $informed, $this->records->previousKeys( $order ), true ) ) {
			return $invoice; // O lojista informou outra NF-e no pedido: os metas respondem por ela.
		}

		return [
			'invoice_key' => $nfe['key'],
			'invoice_number' => (string) ( $nfe['number'] ?? '' ),
			'source' => self::SOURCE,
			'source_label' => self::SOURCE_LABEL,
		];
	}

	/**
	 * Se a pré-postagem automática do PRO espera a NF-e deste envio.
	 *
	 * Só é perguntado quando o pedido ainda não tem nota. Roda quando o pedido
	 * entra em "Processando", antes de `woocommerce_payment_complete`: responda
	 * pelo que o plugin sabe do pedido e da configuração, não por um agendamento
	 * feito no pagamento. Também roda a cada hora enquanto o envio espera.
	 *
	 * @param mixed $wait             Resposta dos emissores anteriores.
	 * @param mixed $order            Pedido.
	 * @param int   $shipping_item_id Linha de frete do envio, zero para o pedido inteiro.
	 */
	public function waitInvoice( $wait, $order, $shipping_item_id = 0 ): bool {
		if ( $wait || ! $order instanceof WC_Order ) {
			return (bool) $wait; // Outro emissor já pediu a espera.
		}

		if ( ! $this->settings->issuesAutomatically() || ! $this->settings->waitsForCorreios() || 'production' !== $this->settings->environment() ) {
			return false; // Nota de homologação nunca vai aos Correios: esperar por ela travaria o envio.
		}

		$status = $this->records->status( $order );

		// Rejeitada continua esperando: o lojista corrige e emite de novo. Responder false
		// aqui faria a varredura de hora em hora do PRO postar o envio com DC-e.
		return ! in_array( $status, [ My_NFe_Record::STATUS_SKIPPED, My_NFe_Record::STATUS_CANCELLED, My_NFe_Record::STATUS_AUTHORIZED ], true );
	}

	/**
	 * Estado da nota na lista de Pedidos do Correios Automático.
	 *
	 * @param mixed $status  Estado padrão (ou dos emissores anteriores).
	 * @param mixed $order   Pedido.
	 * @param mixed $context vendor_id, invoice (a NF-e que o Correios Automático resolveu) e waiting.
	 *
	 * @return mixed
	 */
	public function invoiceStatus( $status, $order, $context = [] ) {
		if ( ! is_array( $status ) || ! $order instanceof WC_Order ) {
			return $status;
		}

		$context = is_array( $context ) ? $context : [];

		if ( ! empty( $context['vendor_id'] ) ) {
			return $status;
		}

		$record = $this->records->get( $order );

		if ( empty( $record['status'] ) ) {
			return $status;
		}

		$resolved = My_NFe_Access_Key::normalize( $context['invoice']['invoice_key'] ?? '' );

		if ( '' !== $resolved && $resolved !== My_NFe_Access_Key::normalize( $record['key'] ?? '' ) ) {
			return $status; // O pedido viaja com a NF-e de outro emissor ou informada à mão.
		}

		list( $state, $tone, $label, $detail ) = $this->panelState( $record );

		return [
			'state' => $state,
			'tone' => $tone,
			'label' => $label,
			'detail' => $detail,
			'source' => self::SOURCE,
			'source_label' => self::SOURCE_LABEL,
			'danfe' => isset( $status['danfe'] ) ? $status['danfe'] : 'none', // A prontidão da DANFE só vem aqui.
			'actions' => $this->panelActions( $order, $record ),
		];
	}

	/**
	 * Dados crus do XML autorizado para a DANFE simplificada.
	 *
	 * @param array|null $danfe   Resposta dos emissores anteriores.
	 * @param mixed      $order   Pedido.
	 * @param mixed      $context for (`label`/`display`), shipping_item_id, vendor_id e invoice.
	 *
	 * @return array|null
	 */
	public function orderDanfe( $danfe, $order, $context = [] ) {
		if ( null !== $danfe || ! $order instanceof WC_Order || ! is_array( $context ) || ! empty( $context['vendor_id'] ) ) {
			return $danfe;
		}

		$nfe = $this->records->authorizedInvoice( $order );
		$resolved = My_NFe_Access_Key::normalize( $context['invoice']['invoice_key'] ?? '' );

		if ( ! $nfe || $resolved !== $nfe['key'] ) {
			return $danfe; // Só responda pela NF-e que o Correios Automático resolveu para o pedido.
		}

		if ( ! $this->hasDanfeData( $nfe ) ) {
			return [ 'schema' => self::DANFE_SCHEMA, 'pending' => true ];
		}

		return [
			'schema' => self::DANFE_SCHEMA,
			'source' => self::SOURCE,
			'source_label' => self::SOURCE_LABEL,
			'environment' => $nfe['environment'],
			'key' => $nfe['key'],
			'number' => (string) $nfe['number'],
			'series' => (string) $nfe['series'],
			'operation' => 'in' === ( $nfe['operation'] ?? 'out' ) ? 'in' : 'out',
			'issued_at' => (string) $nfe['issued_at'],
			'protocol' => [
				'number' => (string) $nfe['protocol'],
				'authorized_at' => (string) $nfe['authorized_at'],
			],
			'emitter' => $this->party( $nfe['emitter'] ),
			'recipient' => $this->party( $nfe['recipient'] ),
			'total' => isset( $nfe['total_cents'] ) ? (int) $nfe['total_cents'] : null,
		];
	}

	/**
	 * Documento com que a devolução viaja.
	 *
	 * O padrão do Correios Automático já é o documento da ida. Aqui só se troca
	 * uma NF-e da ida que o seu plugin cancelou pela nota que a substituiu.
	 *
	 * @param mixed $document         emiteDCe, invoice_number, invoice_key e source.
	 * @param mixed $order            Pedido devolvido.
	 * @param int   $shipping_item_id Linha de frete devolvida, zero para o pedido inteiro.
	 * @param mixed $outbound         Pré-postagem da ida, ou null.
	 *
	 * @return mixed
	 */
	public function reverseDocument( $document, $order, $shipping_item_id = 0, $outbound = null ) {
		if ( ! is_array( $document ) || ! $order instanceof WC_Order || empty( $document['invoice_key'] ) ) {
			return $document;
		}

		$sent = My_NFe_Access_Key::normalize( $document['invoice_key'] );
		$current = $this->records->usableInvoice( $order );

		if ( ! $current || $sent === $current['key'] || ! in_array( $sent, $this->records->previousKeys( $order ), true ) ) {
			return $document;
		}

		return [
			'invoice_key' => $current['key'],
			'invoice_number' => (string) ( $current['number'] ?? '' ),
			'emiteDCe' => 'N',
			'source' => isset( $document['source'] ) ? $document['source'] : 'outbound_invoice', // Mantém a nova tentativa sem documento.
		];
	}

	/**
	 * O pedido entrou no armazém Correios LOG+: a NF-e dele precisa levar este id em `<compra><xPed>`.
	 *
	 * @param int    $order_id    Pedido da loja.
	 * @param string $correios_id Id do pedido nos Correios.
	 * @param mixed  $order       Pedido da loja.
	 */
	public function onLogPlusOrderCreated( $order_id, $correios_id, $order = null ): void {
		$order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );

		if ( ! $order instanceof WC_Order || ! is_scalar( $correios_id ) || '' === (string) $correios_id ) {
			return;
		}

		$this->records->rememberPurchaseOrder( $order, (string) $correios_id );
		// No seu plugin: emita a NF-e deste pedido a partir de agora, com <compra><xPed> igual a $correios_id.
	}

	/**
	 * XML autorizado da NF-e para o armazém LOG+. Só é perguntado enquanto o lojista não enviou um XML à mão.
	 *
	 * @param mixed  $xml         XML de outro emissor, vazio quando não há.
	 * @param mixed  $order       Pedido.
	 * @param string $correios_id Id do pedido nos Correios.
	 *
	 * @return mixed
	 */
	public function logPlusInvoiceXml( $xml, $order, $correios_id = '' ) {
		if ( ( is_string( $xml ) && '' !== trim( $xml ) ) || ! $order instanceof WC_Order ) {
			return $xml;
		}

		$nfe = $this->records->usableInvoice( $order );

		return $nfe && ! empty( $nfe['xml'] ) ? (string) $nfe['xml'] : $xml;
	}

	/**
	 * Explica ao lojista onde corrigir quando o armazém recusa a sua NF-e por faltar o xPed.
	 *
	 * @param mixed  $message     Mensagem do Correios Automático.
	 * @param mixed  $error       WP_Error da conferência; `reason` = `xped` quando falta o id.
	 * @param mixed  $xml         XML recusado.
	 * @param mixed  $order       Pedido.
	 * @param string $correios_id Id do pedido nos Correios.
	 *
	 * @return mixed
	 */
	public function logPlusRefusal( $message, $error, $xml, $order, $correios_id = '' ) {
		if ( ! $error instanceof WP_Error || ! $order instanceof WC_Order ) {
			return $message;
		}

		$data = $error->get_error_data();
		$nfe = $this->records->usableInvoice( $order );

		if ( ! is_array( $data ) || 'xped' !== ( $data['reason'] ?? '' ) || ! $nfe || ! is_string( $xml ) || false === strpos( $xml, $nfe['key'] ) ) {
			return $message; // Só a recusa do XML da NF-e deste plugin (o XML traz a chave em chNFe).
		}

		return sprintf(
			/* translators: %s: id do pedido nos Correios */
			__( 'A NF-e do Meu Plugin de NF-e saiu sem o pedido do armazém (xPed = %s). Cancele e emita de novo pelo Meu Plugin de NF-e com esse número.', 'meu-plugin-nfe' ),
			(string) $correios_id
		);
	}

	/**
	 * Lembra com que nota cada objeto de ida foi pré-postado e avisa quando foi com uma NF-e já cancelada.
	 *
	 * @param int   $order_id Pedido.
	 * @param mixed $prepost  Pré-postagem salva (Infixs\CorreiosAutomatico\Models\Prepost).
	 */
	public function onPrepostCreated( $order_id, $prepost = null ): void {
		$order = wc_get_order( $order_id );

		if ( ! $order instanceof WC_Order || ! is_object( $prepost ) ) {
			return;
		}

		$object_code = (string) self::prop( $prepost, 'object_code' );

		if ( '' === $object_code || $object_code === (string) $order->get_meta( self::REVERSE_CODE_META, true ) ) {
			return; // A devolução volta para a loja: não diz nada da nota da ida.
		}

		$key = $this->prepostKey( $order, $prepost );
		$this->records->rememberPrepost( $order, $object_code, $key );

		if ( '' !== $key && in_array( $key, $this->records->previousKeys( $order ), true ) ) {
			$order->add_order_note( sprintf(
				/* translators: %s: código do objeto */
				__( 'Meu Plugin de NF-e: a pré-postagem %s saiu com uma NF-e já cancelada. Cancele a pré-postagem e refaça com a nota atual.', 'meu-plugin-nfe' ),
				$object_code
			) );
		}
	}

	/**
	 * Guarda o rastreio dos objetos que vão ao cliente (para o seu plugin levar à nota ou ao ERP).
	 *
	 * Atenção à ordem dos argumentos: código primeiro, pedido depois.
	 *
	 * @param mixed $code     Código de rastreio.
	 * @param mixed $order_id Pedido.
	 */
	public function onTrackingCodeAdded( $code, $order_id ): void {
		$order = wc_get_order( $order_id );

		if ( ! is_string( $code ) || '' === $code || ! $order instanceof WC_Order || ! $this->isOutboundCode( $order, $code ) ) {
			return;
		}

		$this->records->addTrackingCode( $order, $code );
	}

	/**
	 * A NF-e foi autorizada: põe a nota nos metas do pedido e avisa quem espera.
	 *
	 * @param int $order_id Pedido.
	 */
	public function onAuthorized( $order_id ): void {
		$order = wc_get_order( $order_id );

		if ( ! $order instanceof WC_Order ) {
			return;
		}

		$nfe = $this->records->usableInvoice( $order );

		if ( $nfe ) {
			$this->publishMetas( $order, $nfe );
		}

		// Avise depois de salvar: o PRO relê o pedido, pergunta o filtro de NF-e e posta.
		// Sem nota utilizável (homologação), o filtro de espera diz não e o PRO posta com DC-e.
		do_action( 'infixs_correios_automatico_order_invoice_ready', (int) $order_id );
	}

	/**
	 * A NF-e foi rejeitada: o PRO mostra a mensagem como erro de pré-postagem e o envio segue esperando.
	 *
	 * @param int   $order_id Pedido.
	 * @param mixed $message  Motivo, para o lojista.
	 */
	public function onFailed( $order_id, $message = '' ): void {
		do_action( 'infixs_correios_automatico_order_invoice_failed', (int) $order_id, is_scalar( $message ) ? (string) $message : '' );
	}

	/**
	 * O pedido não vai ter NF-e: waitInvoice() já responde false, e o aviso faz o PRO
	 * retomar agora o envio que esperava, que sai com a DC-e. Sem o aviso, a retomada
	 * vem na varredura de hora em hora do PRO.
	 *
	 * @param int $order_id Pedido.
	 */
	public function onSkipped( $order_id ): void {
		do_action( 'infixs_correios_automatico_order_invoice_ready', (int) $order_id );
	}

	/**
	 * A NF-e foi cancelada: tira a nota dos metas, se ainda for ela, para não viajar na próxima pré-postagem,
	 * e avisa quais objetos de ida já saíram com ela.
	 *
	 * @param int   $order_id Pedido.
	 * @param mixed $key      Chave cancelada.
	 */
	public function onCancelled( $order_id, $key = '' ): void {
		$order = wc_get_order( $order_id );
		$key = My_NFe_Access_Key::normalize( $key );

		if ( ! $order instanceof WC_Order || '' === $key ) {
			return;
		}

		if ( My_NFe_Access_Key::normalize( $order->get_meta( self::KEY_META, true ) ) === $key ) {
			$order->delete_meta_data( self::NUMBER_META );
			$order->delete_meta_data( self::KEY_META );
			$order->save();
		}

		$objects = array_keys( $this->records->prepostKeys( $order ), $key, true );

		if ( $objects ) {
			$order->add_order_note( sprintf(
				/* translators: %s: códigos dos objetos */
				_n(
					'Meu Plugin de NF-e: a NF-e cancelada viaja na pré-postagem %s. Cancele a pré-postagem e refaça com a nova nota.',
					'Meu Plugin de NF-e: a NF-e cancelada viaja nas pré-postagens %s. Cancele as pré-postagens e refaça com a nova nota.',
					count( $objects ),
					'meu-plugin-nfe'
				),
				implode( ', ', $objects )
			) );
		}
	}

	/**
	 * Escreve a nota nos dois metas sem tomar o lugar de uma NF-e que o lojista
	 * (ou outro emissor) informou: só um meta vazio, a mesma chave ou uma chave
	 * antiga deste plugin cedem lugar.
	 */
	private function publishMetas( WC_Order $order, array $nfe ): void {
		$current = My_NFe_Access_Key::normalize( $order->get_meta( self::KEY_META, true ) );

		if ( '' !== $current && $current !== $nfe['key'] && ! in_array( $current, $this->records->previousKeys( $order ), true ) ) {
			return;
		}

		$order->update_meta_data( self::NUMBER_META, (string) ( $nfe['number'] ?? '' ) );
		$order->update_meta_data( self::KEY_META, $nfe['key'] ); // Sempre string: 44 dígitos não cabem num inteiro.
		$order->save();
	}

	/**
	 * Chave da NF-e com que a pré-postagem saiu; vazia quando saiu com DC-e.
	 */
	private function prepostKey( WC_Order $order, $prepost ): string {
		if ( ! empty( self::prop( $prepost, 'dce' ) ) ) {
			return '';
		}

		$answered = My_NFe_Access_Key::normalize( self::prop( $prepost, 'invoice_key' ) );

		if ( '' !== $answered ) {
			return '55' === substr( $answered, 20, 2 ) ? $answered : '';
		}

		// O Correios Automático grava nos metas a nota usada antes de disparar a ação.
		return My_NFe_Access_Key::normalize( $order->get_meta( self::KEY_META, true ) );
	}

	/**
	 * Se o código é de um objeto que vai ao cliente (não de uma devolução).
	 */
	private function isOutboundCode( WC_Order $order, string $code ): bool {
		if ( $code === (string) $order->get_meta( self::REVERSE_CODE_META, true ) ) {
			return false;
		}

		if ( ! method_exists( self::CONTAINER_CLASS, 'trackingService' ) ) {
			return true;
		}

		$service = call_user_func( [ self::CONTAINER_CLASS, 'trackingService' ] );

		if ( ! is_object( $service ) || ! method_exists( $service, 'getShipmentCodes' ) ) {
			return true; // Antes da 1.11.0 não há como separar ida e volta.
		}

		return in_array( $code, array_map( 'strval', wp_list_pluck( (array) $service->getShipmentCodes( $order->get_id() ), 'code' ) ), true );
	}

	/**
	 * @return array{0: string, 1: string, 2: string, 3: string} state, tone, label, detail.
	 */
	private function panelState( array $record ): array {
		$number = (string) ( $record['number'] ?? '' );
		$message = (string) ( $record['message'] ?? '' );

		/* translators: %s: número da NF-e */
		$numbered = sprintf( __( 'NF-e nº %s', 'meu-plugin-nfe' ), $number );

		switch ( $record['status'] ) {
			case My_NFe_Record::STATUS_AUTHORIZED:
				return 'production' === ( $record['environment'] ?? '' )
					? [ 'authorized', 'success', $numbered, '' ]
					: [ 'authorized', 'muted', $numbered, __( 'Homologação, sem valor fiscal', 'meu-plugin-nfe' ) ];
			case My_NFe_Record::STATUS_REJECTED:
				return [ 'error', 'danger', __( 'NF-e rejeitada', 'meu-plugin-nfe' ), $message ];
			case My_NFe_Record::STATUS_CANCELLED:
				return [ 'cancelled', 'muted', $numbered, __( 'Cancelada', 'meu-plugin-nfe' ) ];
			case My_NFe_Record::STATUS_SKIPPED:
				return [ 'none', 'muted', __( 'NF-e não será emitida', 'meu-plugin-nfe' ), '' ];
		}

		return [ 'pending', 'warning', __( 'NF-e em emissão', 'meu-plugin-nfe' ), '' ];
	}

	/**
	 * Links do painel: só endereços deste site (o Correios Automático descarta os outros), no máximo quatro.
	 */
	private function panelActions( WC_Order $order, array $record ): array {
		if ( My_NFe_Record::STATUS_AUTHORIZED !== $record['status'] || ! current_user_can( 'edit_shop_orders' ) ) {
			return [];
		}

		$url = add_query_arg( [ 'page' => 'meu-plugin-nfe', 'order' => $order->get_id() ], admin_url( 'admin.php' ) );

		return [
			[ 'id' => 'danfe_pdf', 'label' => __( 'DANFE completa (PDF)', 'meu-plugin-nfe' ), 'url' => add_query_arg( 'file', 'danfe', $url ), 'target' => '_blank' ],
			[ 'id' => 'xml', 'label' => __( 'XML', 'meu-plugin-nfe' ), 'url' => add_query_arg( 'file', 'xml', $url ) ],
		];
	}

	private function hasDanfeData( array $nfe ): bool {
		return preg_match( '/^\d{15}$/', (string) ( $nfe['protocol'] ?? '' ) )
			&& ! empty( $nfe['issued_at'] )
			&& ! empty( $nfe['authorized_at'] )
			&& ! empty( $nfe['number'] )
			&& isset( $nfe['series'] ) && '' !== (string) $nfe['series']
			&& ! empty( $nfe['emitter']['name'] ) && ! empty( $nfe['emitter']['uf'] )
			&& ! empty( $nfe['recipient']['name'] ) && ! empty( $nfe['recipient']['uf'] );
	}

	/**
	 * @param mixed $party name, document, ie e uf, como saem do XML.
	 */
	private function party( $party ): array {
		$party = is_array( $party ) ? $party : [];

		return [
			'name' => (string) ( $party['name'] ?? '' ),
			'document' => (string) ( $party['document'] ?? '' ),
			'ie' => (string) ( $party['ie'] ?? '' ),
			'uf' => (string) ( $party['uf'] ?? '' ),
		];
	}

	/**
	 * Lê um campo do modelo da pré-postagem sem aviso de propriedade inexistente.
	 *
	 * @param object $model Modelo.
	 *
	 * @return mixed
	 */
	private static function prop( $model, string $name ) {
		return isset( $model->{$name} ) ? $model->{$name} : null;
	}
}

/**
 * Liga a integração depois que o Correios Automático (grátis no include, PRO
 * no `plugins_loaded` 11) carregou.
 */
function my_nfe_boot_correios_integration(): void {
	if ( ! defined( 'INFIXS_CORREIOS_AUTOMATICO_PLUGIN_VERSION' ) || ! function_exists( 'wc_get_order' ) ) {
		return; // Correios Automático ou WooCommerce inativo.
	}

	if ( version_compare( INFIXS_CORREIOS_AUTOMATICO_PLUGIN_VERSION, '1.11.0', '<' ) ) {
		return; // O filtro da NF-e do pedido chegou na 1.11.0.
	}

	( new My_NFe_Correios_Bridge( new My_NFe_Record(), new My_NFe_Settings() ) )->register();
}

add_action( 'plugins_loaded', 'my_nfe_boot_correios_integration', 20 );

Lista de verificação de testes

Prepare o grátis 1.14.0 com o Contrato Próprio ativo e um método do Correios Automático na zona de entrega; para os itens do PRO, o PRO 1.6.2 com licença ativa e a pré-postagem automática ligada no método. Ligue o log em Correios Automático → Configurações → Depuração (nível Debug Log) e acompanhe em WooCommerce → Status → Logs.

Plugin grátis

  • Sem o Correios Automático ativo, o seu plugin carrega normalmente e não registra os ganchos.
  • Pedido com nota autorizada: a caixa “Pré-Postagem” do pedido mostra a NF-e com o nome do seu plugin e os campos vêm preenchidos.
  • Pré-postagem em massa com um pedido com nota e outro sem: o log mostra chaveNFe e numeroNotaFiscal no primeiro e emiteDCe: "S" no segundo.
  • Depois da pré-postagem, o pedido traz os metas _infixs_correios_automatico_invoice_number e _invoice_key com a nota usada.
  • Nota de homologação: o seu filtro não responde e a pré-postagem sai com DC-e.
  • Chave com 43 dígitos ou modelo 99: o Correios Automático ignora a resposta. Chave com dígito verificador errado: o seu plugin não a entrega.
  • Nota digitada pelo lojista no pedido: a próxima resolução usa a nota digitada (source = order_meta).
  • Botão “c/ Declaração Eletrônica (DCe)”: sai com DC-e mesmo com a nota pronta.
  • Lista de pedidos com 50 pedidos: o tempo de resposta continua o mesmo com o seu plugin ativo.
  • Status no painel: “em emissão”, “rejeitada” com o motivo e “NF-e nº …”; os links abrem no admin do mesmo site.
  • DANFE: ligada no perfil de impressão, sai com a etiqueta; com protocolo faltando, aparece “em preparação”; com o CNPJ do emitente diferente do da chave, o log traz reason = emitter.
  • Cancelar a nota: os dois metas somem (se eram dela) e a etiqueta deixa de sair com a DANFE.
  • Reversa de um pedido com a NF-e da ida reemitida: o log “Documento fiscal da pré-postagem reversa definido.” mostra nfe: true e o envio leva a chave nova com emiteDCe: "N".
  • Rastreio: o ouvinte recebe ($code, $order_id) nessa ordem; o código da devolução não entra como rastreio de ida; num pedido em volumes, cada volume grava o seu código.
  • Marketplace: numa remessa de vendedor, vendor_id é diferente de zero e o seu plugin não responde.

PRO

  • Pagamento sem nota: o pedido ganha a nota “Pré-postagem automática aguardando a nota fiscal (NF-e).”, o meta de espera aparece e o pedido entra em Pendências → NF-e.
  • Nota autorizada + aviso de nota pronta: a pré-postagem sai com NF-e, a espera some e o código de rastreio é gravado.
  • Nota autorizada de forma síncrona antes da prioridade 100 do woocommerce_order_status_processing: pré-postagem direta, sem espera.
  • Rejeição + order_invoice_failed: o painel mostra a mensagem e o envio continua aguardando; reemitir e avisar cria a pré-postagem e limpa o erro.
  • Espera desligada no seu plugin: na varredura de hora em hora (ou no próximo aviso), o envio sai com DC-e.
  • Pedido marcado sem nota (saveSkipped() + my_nfe_skipped no exemplo): o envio que aguardava sai com DC-e, sem esperar a varredura.
  • Pedido em Aguardando (on-hold) com aviso de nota pronta: não posta; ao voltar para Processando, posta.
  • Pedido cancelado: a espera some.
  • Pedido em 2 volumes: cada linha espera e posta separada, com a mesma NF-e.
  • Dois avisos seguidos (webhook + cron): uma pré-postagem por envio.
  • Correios LOG+: o pedido criado no armazém dispara infixs_correios_automatico_pro_logplus_order_created; a nota emitida com xPed, seguida do aviso, gera “XML da NF-e entregue ao armazém Correios LOG+.”; uma nota sem xPed mostra a mensagem do seu filtro de erro.
  • REST do WooCommerce: PUT /wp-json/wc/v3/orders/{id} com os dois metas em meta_data pré-posta com NF-e.

Perguntas frequentes

Preciso do PRO para a pré-postagem sair com a NF-e?

Não. No grátis, as pré-postagens feitas pelo lojista (botões do pedido, formulário do painel e pré-postagem em massa) já usam a NF-e que o seu plugin entrega. O PRO acrescenta a pré-postagem automática no pagamento, que espera a nota, e o armazém Correios LOG+.

Uso o filtro ou os metadados?

Os dois se completam. Os metadados guardam a nota no pedido, e o lojista vê e pode corrigir. O filtro responde a partir dos dados do seu plugin e deixa você decidir na hora (por exemplo, só notas de produção). Com os metadados a integração já funciona; com o filtro ela fica mais precisa.

E se a nota demorar para sair?

A pré-postagem automática do PRO espera enquanto o seu filtro de espera responder true, sem prazo. Para liberar o envio com DC-e, passe a responder false e dispare o aviso de nota pronta. O lojista também pode pré-postar à mão a qualquer momento.

Posso entregar uma nota de homologação?

Entregue à pré-postagem só nota autorizada em produção. A chave de acesso não informa o ambiente, e o Correios Automático envia aos Correios a chave que receber. Em homologação, devolva null no filtro da nota e false no filtro de espera.

Como funciona com pedido dividido em volumes?

Cada volume é uma linha de frete e é pré-postado separadamente; os filtros recebem o shipping_item_id de cada um. A mesma NF-e vale para todos os volumes do pedido.

E no marketplace (Dokan, WCFM)?

Nas remessas de vendedor, o contexto traz vendor_id diferente de zero. Devolva o valor recebido e deixe o vendedor emitir a nota dele.

O Correios Automático confere a chave?

Na pré-postagem ele confere os 44 dígitos e o modelo 55. Na DANFE simplificada confere também o dígito verificador, o número, a série e o emitente. Valide a chave no seu plugin antes de entregar, por exemplo com AccessKey::hasValidCheckDigit().

E uma nota de emitente com CNPJ alfanumérico?

Na 1.14.0, o filtro e os metadados leem chaves só com dígitos. Para essa nota, informe número e chave na pré-postagem manual (botão “c/ Nota Fiscal” do pedido ou POST /preposts), que aceita letras na chave e a envia como informada.

O lojista digitou outra nota no pedido. Qual vale?

Uma resposta válida do filtro vem primeiro. Para respeitar a nota digitada, faça o filtro responder só quando os metadados estão vazios ou guardam a sua chave, como faz o exemplo completo.

Como vejo o que foi enviado aos Correios?

Em Correios Automático → Configurações → Depuração, ligue “Ativar Logs” e o nível Debug Log. Em WooCommerce → Status → Logs, a mensagem “Enviando prepostagem para os correios.” traz o envio com chaveNFe e numeroNotaFiscal (ou emiteDCe, quando vai com DC-e).