A emissão é síncrona: Nfse.issue/2 monta, valida e assina a DPS, envia a POST /nfse e devolve a NFS-e calculada pela Sefin na mesma chamada.

O exemplo abaixo emite o corte de cabelo de R$ 49,90 do "Studio Ana", prestadora do Simples Nacional em Salvador, para um cliente pessoa física.

now = DateTime.truncate(DateTime.utc_now(), :second)

dps = %Nfse.DPS{
  dh_emi: DateTime.add(now, -60),
  ver_aplic: "studio-ana-1.0",
  serie: "1",
  n_dps: 42,
  d_compet: now |> DateTime.add(-3 * 3600) |> DateTime.to_date(),
  tp_emit: 1,
  c_loc_emi: "2927408",
  prest: %{
    cnpj: "11222333000181",
    reg_trib: %{op_simp_nac: 3, reg_ap_trib_sn: 1, reg_esp_trib: 0}
  },
  toma: %{cpf: "52998224725", x_nome: "Ana Souza"},
  serv: %{
    loc_prest: %{c_loc_prestacao: "2927408"},
    c_serv: %{c_trib_nac: "060101", x_desc_serv: "Corte de cabelo"}
  },
  valores: %{
    v_serv_prest: %{v_serv: Decimal.new("49.90")},
    trib: %{
      trib_mun: %{trib_issqn: 1, tp_ret_issqn: 1},
      tot_trib: %{p_tot_trib_sn: Decimal.new("6.00")}
    }
  }
}

case Nfse.issue(client, dps) do
  {:ok, %Nfse.Issued{} = issued} ->
    issued.access_key
    #=> "29274082211222333000181000000000000126110000000001"
    issued.dps_id
    #=> "DPS292740821122233300018100001000000000000042"

  {:error, %Nfse.Error{kind: :rejected, messages: messages}} ->
    messages

  {:error, %Nfse.Error{kind: kind, dps_id: dps_id}} when kind in [:timeout, :transport, :server] ->
    {:incerto, dps_id}

  {:error, %Nfse.Error{kind: :unexpected, status: status, dps_id: dps_id}} when status in 200..299 ->
    {:incerto, dps_id}

  {:error, %Nfse.Error{}} = error ->
    error
end

O dh_emi vai um minuto atrás do relógio da máquina: um relógio adiantado faria o horário de emissão cair à frente do da Sefin, e a DPS seria recusada. A d_compet é a data em Brasília (UTC-3, sem horário de verão desde 2019) e não Date.utc_today/0, que a partir das 21h já está no dia seguinte.

Guarde issued.access_key e issued.xml na hora. O xml é a NFS-e exatamente como a Sefin a devolveu, e é ele que você distribui ao tomador. issued.nfse traz o mesmo documento lido, e issued.alerts os avisos da Sefin, que não impedem a emissão.

Como os campos são escritos

Os campos seguem o nome do XSD em snake_case, com as siglas NFSe, DFSe, NFe e DFe contadas como uma palavra só: cLocEmi vira c_loc_emi e infNFSe vira inf_nfse. Cada grupo aninhado aceita um mapa com as mesmas chaves, e a tabela de campos de cada módulo está em Nfse.Schema.V1_01.

Você não preenche:

  • o Id da DPS, que Nfse.DPS.build/2 calcula a partir do município, da inscrição de quem emite, da série e do n_dps
  • a versao, sempre "1.01"
  • o tp_amb, que vem do ambiente do cliente

O n_dps é a sequência da sua empresa. Escolha-o antes de enviar e guarde-o: o Id da DPS sai dele, e é pelo Id que se confere uma emissão incerta.

Dinheiro é sempre Decimal. Um valor com mais de duas casas (Decimal.new("49.905")) volta como erro :fraction_digits, nunca arredondado, e Decimal.new("49.9") sai como 49.90.

Erros antes da rede

Um documento que não passa no schema volta como %Nfse.Error{kind: :validation}, sem tocar a rede e sem assinar. Todos os erros vêm juntos, cada um com o caminho do campo:

{:error, %Nfse.Error{kind: :validation, details: details}} =
  Nfse.issue(client, %{dps | toma: %{cpf: "52998224725", x_nome: "Ana Souza", cpf_: "1"}})

details
#=> [%{path: "toma.cpf_", error: :unknown_field}]

Uma chave desconhecida num mapa aninhado nunca é descartada em silêncio. Texto fora do Latin-1 (emoji, aspas curvas, reticências) num campo derivado de TSString, como ver_aplic, também volta como erro :pattern com o caminho do campo. Já x_nome e x_desc_serv não têm esse padrão no XSD e a biblioteca o segue: se a Sefin aceita emoji nesses campos, só a execução contra a produção restrita mostra (veja o README).

Erros da Sefin

kindSignificadoO que fazer
:rejected400, regra de negócio. messages traz o código (E....) e a descriçãocorrigir o dado e emitir de novo
:forbidden403, certificado ou ator sem acessoconferir o certificado e o CNPJ
:timeout, :transport, :serversem resposta ou 5xx; a nota pode ter sido emitidaconferir, sem reenviar: Conferir após timeout
:unexpecteda resposta não pôde ser lida; com status 2xx, a nota foi emitidaguardar details e conferir: ver abaixo

Todo erro de Nfse.issue/2 posterior à montagem da DPS traz o dps_id.

Uma resposta 201 que não se consegue ler (sem chaveAcesso, com o XML corrompido ou com um XML que não passa na leitura estrita) volta como :unexpected com status: 201, nunca como :validation, porque a nota existe. Nesse caso details traz access_key, xml (o que deles já se leu, ou nil) e o motivo em reason; guarde a chave e o XML antes de investigar, e confira como em Conferir após timeout o que faltar.

{:error, %Nfse.Error{kind: :unexpected, status: 201, details: %{access_key: access_key, xml: xml}}} =
  Nfse.issue(client, dps)

Nfse.fetch/2 segue a mesma regra: uma nota que chega num 200 e não se lê volta como :unexpected, com o XML em details.xml.

Consultar uma nota

{:ok, %Nfse.Document{} = document} = Nfse.fetch(client, access_key)
document.nfse.inf_nfse.n_nfse

Emissão por decisão judicial

Quando uma decisão administrativa ou judicial manda desviar das regras de validação, Nfse.issue_by_court_order/2 envia a NFS-e inteira, montada e assinada por Nfse.NFSe.build/2, a POST /decisao-judicial/nfse. O município precisa cadastrar a decisão, com o número do processo, e autorizar o contribuinte antes; a responsabilidade por cada campo é de quem emite.

Nfse.issue_by_court_order(client, %Nfse.NFSe{
  x_loc_emi: "Salvador",
  x_loc_prestacao: "Salvador",
  n_nfse: 1,
  c_loc_incid: "2927408",
  x_loc_incid: "Salvador",
  x_trib_nac: "Barbearia, cabeleireiros, manicuros, pedicuros e congêneres.",
  ver_aplic: "studio-ana-1.0",
  emit: %{
    cnpj: "11222333000181",
    x_nome: "Studio Ana",
    ender_nac: %{
      x_lgr: "Rua das Flores",
      nro: "100",
      x_bairro: "Centro",
      c_mun: "2927408",
      uf: "BA",
      cep: "40000000"
    }
  },
  valores: %{
    v_bc: Decimal.new("49.90"),
    p_aliq_aplic: Decimal.new("2.00"),
    v_issqn: Decimal.new("1.00"),
    v_liq: Decimal.new("49.90")
  },
  dps: %{inf_dps: inf_dps}
})

Aqui inf_dps são os campos da DPS do exemplo acima, como mapa. Este fluxo não pôde ser provado contra a Sefin sem a autorização de um município: veja "O que não está confirmado" em Nfse.NFSe. A escrita sai uma única vez e valem as mesmas regras de Conferir após timeout.