Multi-Tenant - 09 - Exemplo de NFSe para o Ambiente Nacional em Golang

Show time do mapper 😄
Vou criar um parser.go dentro do contexto nfse com dois mapeamentos principais:

  1. EmissaoNfseRequestdomínio interno (Nfse + []NfseItem)
  2. EmissaoNfseRequestpayload do Ambiente Nacional (NfseNacionalPayload)

Observação importante:
O NfseNacionalPayload abaixo é inspirado no layout nacional (ABRASF/Nacional), não é uma cópia fiel do schema oficial. A ideia é você ter um ponto único para ajustar quando estiver com o schema definitivo em mãos (XML/JSON do provedor).


internal/nfse/parser.go

package nfse

import (
	"fmt"
	"time"
)

const (
	layoutDataRFC3339 = time.RFC3339
	layoutDataSimples = "2006-01-02"
)

// ============================================================================
// 1) Mapper: EmissaoNfseRequest -> Domínio interno (Nfse + []NfseItem)
// ============================================================================

func MapEmissaoToDomain(tenantID int64, req EmissaoNfseRequest) (*Nfse, []NfseItem, error) {
	// Parse datas
	dataEmissao, err := time.Parse(layoutDataRFC3339, req.Transmissao.DataEmissao)
	if err != nil {
		return nil, nil, fmt.Errorf("data_emissao inválida (use RFC3339): %w", err)
	}
	competencia, err := time.Parse(layoutDataSimples, req.Identificacao.Competencia)
	if err != nil {
		return nil, nil, fmt.Errorf("competencia inválida (use YYYY-MM-DD): %w", err)
	}

	// Monta cabeçalho interno
	nf := &Nfse{
		TenantID:               tenantID,
		NumeroNfse:             req.Identificacao.NumeroNfse,
		Serie:                  req.Identificacao.Serie,
		Ambiente:               req.Identificacao.Ambiente,
		// Situação inicial – você pode ajustar depois (ex.: "em_emissao", "autorizada", etc)
		Situacao:               "emitida",
		PrestadorID:            0, // você provavelmente vai buscar pelo CNPJ/IM em outra tabela
		TomadorID:              0, // idem para o tomador

		DataEmissao:            dataEmissao,
		Competencia:            competencia,

		ValorServicos:          req.Servicos.ValorTotalServicos,
		ValorDeducoes:          req.Servicos.ValorDeducoes,
		ValorPis:               req.Servicos.ValorPis,
		ValorCofins:            req.Servicos.ValorCofins,
		ValorInss:              req.Servicos.ValorInss,
		ValorIr:                req.Servicos.ValorIr,
		ValorCsll:              req.Servicos.ValorCsll,
		ValorIss:               req.Servicos.ValorIss,
		ValorIssRetido:         req.Servicos.ValorIssRetido,
		IssRetido:              req.Servicos.ValorIssRetido > 0,

		CodigoMunicipioPrest:   req.Prestador.CodigoMunicipio,
		CodigoMunicipioTomador: &req.Tomador.Endereco.CodigoMunicipio,

		// Como regra geral, usamos o primeiro item como base para esses campos
		// (ajuste se preferir consolidar de outra forma)
		CodigoTribMunicipio:    firstItemCodigoTrib(req),
		ItemListaServico:       firstItemListaServ(req),
		Discriminacao:          req.InformacoesAdicionais.DiscriminacaoGeral,

		// Campos de retorno/protocolo/xml serão preenchidos após o envio
	}

	if req.Transmissao.DataEmissao != "" {
		// já parseado em dataEmissao, se quiser guardar num outro campo, ok
		// aqui não temos um campo extra pra isso no domínio
	}

	// Monta itens
	var itens []NfseItem
	for _, it := range req.Servicos.Itens {
		valorServ := it.ValorServicos
		if it.Quantidade == 0 {
			it.Quantidade = 1
		}

		item := NfseItem{
			TenantID:     tenantID,
			// nf.ID será preenchido pelo repo ao inserir, aqui deixamos 0
			NumeroItem:   it.NumeroItem,
			CodigoServico: it.CodigoServico,
			Descricao:     it.Discriminacao,
			Quantidade:    it.Quantidade,
			ValorUnitario: valorServ / it.Quantidade,
			ValorTotal:    valorServ,
			AliquotaIss:   it.AliquotaIss,
			ValorIss:      valorServ * it.AliquotaIss,
		}

		// Campos opcionais
		if it.CodigoTributacaoMunicipio != "" {
			item.CodigoTribMunicipio = &it.CodigoTributacaoMunicipio
		}
		if it.ItemListaServico != "" {
			item.ItemListaServico = &it.ItemListaServiço
		}

		itens = append(itens, item)
	}

	return nf, itens, nil
}

func firstItemCodigoTrib(req EmissaoNfseRequest) string {
	if len(req.Servicos.Itens) == 0 {
		return ""
	}
	return req.Servicos.Itens[0].CodigoTributacaoMunicipio
}

func firstItemListaServ(req EmissaoNfseRequest) string {
	if len(req.Servicos.Itens) == 0 {
		return ""
	}
	return req.Servicos.Itens[0].ItemListaServico
}

// ============================================================================
// 2) Mapper: EmissaoNfseRequest -> Payload Nacional (para webservice)
// ============================================================================

// Estrutura genérica inspirada no layout nacional.
// A ideia é: você ajusta esses nomes/campos conforme o schema oficial
// (XML/JSON) do Ambiente Nacional.

type NfseNacionalPayload struct {
	IdentificacaoRps     NacIdentificacaoRps     `json:"identificacao_rps"`
	PrestadorServico     NacPrestadorServico     `json:"prestador_servico"`
	TomadorServico       NacTomadorServico       `json:"tomador_servico"`
	Servico              NacServico              `json:"servico"`
	RegimeTributario     NacRegimeTributario     `json:"regime_tributario"`
	InformacoesAdicionais NacInformacoesAdicionais `json:"informacoes_adicionais"`
	Transmissao          NacTransmissao          `json:"transmissao"`
}

// ------------------------
// Blocos básicos nacionais
// ------------------------

type NacIdentificacaoRps struct {
	NumeroRps   int64  `json:"numero_rps"`
	SerieRps    string `json:"serie_rps"`
	TipoRps     string `json:"tipo_rps"` // geralmente "1" (RPS)
	Competencia string `json:"competencia"` // "YYYY-MM-DD"
}

type NacPrestadorServico struct {
	Cnpj               string `json:"cnpj"`
	InscricaoMunicipal string `json:"inscricao_municipal"`
	CodigoMunicipio    string `json:"codigo_municipio"`
}

type NacTomadorServico struct {
	CPFCNPJ string               `json:"cpf_cnpj"`
	RazaoSocial string           `json:"razao_social"`
	Endereco   NacEndereco       `json:"endereco"`
	Contato    *NacContato       `json:"contato,omitempty"`
}

type NacEndereco struct {
	Logradouro      string  `json:"logradouro"`
	Numero          string  `json:"numero"`
	Complemento     *string `json:"complemento,omitempty"`
	Bairro          string  `json:"bairro"`
	CodigoMunicipio string  `json:"codigo_municipio"`
	UF              string  `json:"uf"`
	CEP             string  `json:"cep"`
	Pais            string  `json:"pais"`
}

type NacContato struct {
	Telefone *string `json:"telefone,omitempty"`
	Email    *string `json:"email,omitempty"`
}

type NacRegimeTributario struct {
	NaturezaOperacao         string `json:"natureza_operacao"`
	RegimeEspecialTributacao string `json:"regime_especial_tributacao"`
	OptanteSimplesNacional   bool   `json:"optante_simples_nacional"`
	IncentivadorCultural     bool   `json:"incentivador_cultural"`
}

type NacServico struct {
	// Campos consolidados
	ValorServicos           float64 `json:"valor_servicos"`
	ValorDeducoes           float64 `json:"valor_deducoes"`
	ValorPis                float64 `json:"valor_pis"`
	ValorCofins             float64 `json:"valor_cofins"`
	ValorInss               float64 `json:"valor_inss"`
	ValorIr                 float64 `json:"valor_ir"`
	ValorCsll               float64 `json:"valor_csll"`
	ValorIss                float64 `json:"valor_iss"`
	ValorIssRetido          float64 `json:"valor_iss_retido"`
	OutrasRetencoes         float64 `json:"outras_retencoes"`
	DescontoIncondicionado  float64 `json:"desconto_incondicionado"`
	DescontoCondicionado    float64 `json:"desconto_condicionado"`

	// Campos específicos de serviço
	ItemListaServico        string  `json:"item_lista_servico"`
	CodigoTributacaoMunicipio string `json:"codigo_tributacao_municipio"`
	Descricao               string  `json:"discriminacao"`

	// Se o layout nacional permitir itens detalhados, você pode expor também:
	Itens                   []NacServicoItem `json:"itens,omitempty"`
}

type NacServicoItem struct {
	NumeroItem               int     `json:"numero_item"`
	CodigoTributacaoMunicipio string  `json:"codigo_tributacao_municipio"`
	ItemListaServico         string  `json:"item_lista_servico"`
	Descricao                string  `json:"discriminacao"`
	CodigoCNAE               *string `json:"codigo_cnae,omitempty"`
	CodigoServico            string  `json:"codigo_servico"`
	Quantidade               float64 `json:"quantidade"`
	ValorServicos            float64 `json:"valor_servicos"`
	AliquotaIss              float64 `json:"aliquota_iss"`
	IssRetido                bool    `json:"iss_retido"`
}

type NacInformacoesAdicionais struct {
	DiscriminacaoGeral string  `json:"discriminacao_geral"`
	Observacoes        *string `json:"observacoes,omitempty"`
}

type NacTransmissao struct {
	LoteIDExterno string `json:"lote_id_externo"`
	DataEmissao   string `json:"data_emissao"` // RFC3339
	Ambiente      string `json:"ambiente"`     // "PROD" ou "HOM"
	TipoEmissao   string `json:"tipo_emissao"` // "NORMAL", etc.
}

// ============================================================================
// Mapper: EmissaoNfseRequest -> NfseNacionalPayload
// ============================================================================

func MapEmissaoToNacionalPayload(req EmissaoNfseRequest) (*NfseNacionalPayload, error) {
	payload := &NfseNacionalPayload{
		IdentificacaoRps: NacIdentificacaoRps{
			NumeroRps:   req.Identificacao.NumeroNfse,
			SerieRps:    req.Identificacao.Serie,
			TipoRps:     "1", // RPS – ajuste se o nacional exigir outro código
			Competencia: req.Identificacao.Competencia,
		},
		PrestadorServico: NacPrestadorServico{
			Cnpj:               req.Prestador.CNPJ,
			InscricaoMunicipal: req.Prestador.InscricaoMunicipal,
			CodigoMunicipio:    req.Prestador.CodigoMunicipio,
		},
		TomadorServico: NacTomadorServico{
			CPFCNPJ:    req.Tomador.CPFCNPJ,
			RazaoSocial: req.Tomador.RazaoSocial,
			Endereco: NacEndereco{
				Logradouro:      req.Tomador.Endereco.Logradouro,
				Numero:          req.Tomador.Endereco.Numero,
				Complemento:     req.Tomador.Endereco.Complemento,
				Bairro:          req.Tomador.Endereco.Bairro,
				CodigoMunicipio: req.Tomador.Endereco.CodigoMunicipio,
				UF:              req.Tomador.Endereco.UF,
				CEP:             req.Tomador.Endereco.CEP,
				Pais:            req.Tomador.Endereco.Pais,
			},
			Contato: nil,
		},
		RegimeTributario: NacRegimeTributario{
			NaturezaOperacao:         req.Identificacao.NaturezaOperacao,
			RegimeEspecialTributacao: req.Identificacao.RegimeEspecialTributacao,
			OptanteSimplesNacional:   req.Identificacao.OptanteSimplesNacional,
			IncentivadorCultural:     req.Identificacao.IncentivadorCultural,
		},
		Servico: NacServico{
			ValorServicos:           req.Servicos.ValorTotalServicos,
			ValorDeducoes:           req.Servicos.ValorDeducoes,
			ValorPis:                req.Servicos.ValorPis,
			ValorCofins:             req.Servicos.ValorCofins,
			ValorInss:               req.Servicos.ValorInss,
			ValorIr:                 req.Servicos.ValorIr,
			ValorCsll:               req.Servicos.ValorCsll,
			ValorIss:                req.Servicos.ValorIss,
			ValorIssRetido:          req.Servicos.ValorIssRetido,
			OutrasRetencoes:         req.Servicos.OutrasRetencoes,
			DescontoIncondicionado:  req.Servicos.DescontoIncondicionado,
			DescontoCondicionado:    req.Servicos.DescontoCondicionado,
			ItemListaServico:        firstItemListaServ(req),
			CodigoTributacaoMunicipio: firstItemCodigoTrib(req),
			Descricao:               req.InformacoesAdicionais.DiscriminacaoGeral,
		},
		InformacoesAdicionais: NacInformacoesAdicionais{
			DiscriminacaoGeral: req.InformacoesAdicionais.DiscriminacaoGeral,
			Observacoes:        req.InformacoesAdicionais.Observacoes,
		},
		Transmissao: NacTransmissao{
			LoteIDExterno: req.Transmissao.LoteIDExterno,
			DataEmissao:   req.Transmissao.DataEmissao,
			Ambiente:      req.Identificacao.Ambiente,
			TipoEmissao:   req.Transmissao.TipoEmissao,
		},
	}

	// Contato opcional
	if req.Tomador.Contato != nil {
		payload.TomadorServico.Contato = &NacContato{
			Telefone: req.Tomador.Contato.Telefone,
			Email:    req.Tomador.Contato.Email,
		}
	}

	// Itens (caso queira mandar detalhado pro nacional)
	for _, it := range req.Servicos.Itens {
		item := NacServicoItem{
			NumeroItem:               it.NumeroItem,
			CodigoTributacaoMunicipio: it.CodigoTributacaoMunicipio,
			ItemListaServico:         it.ItemListaServico,
			Descricao:                it.Discriminacao,
			CodigoCNAE:               it.CodigoCNAE,
			CodigoServico:            it.CodigoServico,
			Quantidade:               it.Quantidade,
			ValorServicos:            it.ValorServicos,
			AliquotaIss:              it.AliquotaIss,
			IssRetido:                it.IssRetido,
		}
		payload.Servico.Itens = append(payload.Servico.Itens, item)
	}

	return payload, nil
}

Como usar isso na prática

No seu handler de emissão, você fica com algo assim:

// 1) Recebe EmissaoNfseRequest (sua API)
var req EmissaoNfseRequest
_ = json.NewDecoder(r.Body).Decode(&req)

// 2) Converte para domínio interno
nf, itens, err := nfse.MapEmissaoToDomain(tenantID, req)

// 3) Persiste nf + itens (service/repo)
nfCompleta, err := s.CriarNfseCompleta(ctx, tenantID, *nf, itens)

// 4) Em paralelo ou depois, gera payload nacional
payload, err := nfse.MapEmissaoToNacionalPayload(req)
// -> aqui você converte payload pra XML/JSON conforme o webservice exigir
// e envia usando ACBr / HTTP / etc.