Multi-Tenant - 07 - Exemplo concreto em Go

Vamos fechar a stack da NFS-e então: service + handler HTTP com chi e multi-tenant em /tenants/{tenantID}/nfse.

Vou assumir:

Abaixo vão só os arquivos novos/ajustados.


internal/nfse/service.go

package nfse

import "context"

type Service struct {
	repo Repository
}

func NewService(repo Repository) *Service {
	return &Service{repo: repo}
}

func (s *Service) CriarNfseCompleta(ctx context.Context, tenantID int64, cab Nfse, itens []NfseItem) (*NfseCompleta, error) {
	cab.TenantID = tenantID
	for i := range itens {
		itens[i].TenantID = tenantID
	}
	if err := s.repo.CriarNfse(ctx, &cab, itens); err != nil {
		return nil, err
	}
	return &NfseCompleta{
		Cabecalho: cab,
		Itens:     itens,
	}, nil
}

func (s *Service) ObterNfseCompleta(ctx context.Context, tenantID, id int64) (*NfseCompleta, error) {
	return s.repo.ObterPorID(ctx, tenantID, id)
}

func (s *Service) ListarPorCompetencia(ctx context.Context, tenantID int64, competencia string, limit, offset int) ([]Nfse, error) {
	return s.repo.ListarPorCompetencia(ctx, tenantID, competencia, limit, offset)
}

internal/nfse/handler_http.go

package nfse

import (
	"encoding/json"
	"net/http"
	"strconv"

	"fsgo/pkg/httpcontext"

	"github.com/go-chi/chi/v5"
)

// ---------------------------------------------------------------------
// DTOs (requests)
// ---------------------------------------------------------------------

type criarNfseItemRequest struct {
	NumeroItem          int     `json:"numero_item"`
	CodigoServico       string  `json:"codigo_servico"`
	Descricao           string  `json:"descricao"`
	Quantidade          float64 `json:"quantidade"`
	ValorUnitario       float64 `json:"valor_unitario"`
	ValorTotal          float64 `json:"valor_total"`
	AliquotaIss         float64 `json:"aliquota_iss"`
	ValorIss            float64 `json:"valor_iss"`
	CodigoTribMunicipio *string `json:"codigo_tributacao_municipio,omitempty"`
	ItemListaServico    *string `json:"item_lista_servico,omitempty"`
}

type criarNfseRequest struct {
	NumeroNfse            int64   `json:"numero_nfse"`
	Serie                 string  `json:"serie"`
	Ambiente              string  `json:"ambiente"`
	Situacao              string  `json:"situacao"`
	PrestadorID           int64   `json:"prestador_id"`
	TomadorID             int64   `json:"tomador_id"`
	DataEmissao           string  `json:"data_emissao"`   // ISO8601
	Competencia           string  `json:"competencia"`    // "YYYY-MM-DD"
	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"`
	IssRetido             bool    `json:"iss_retido"`
	CodigoMunicipioPrest  string  `json:"codigo_municipio_prestacao"`
	CodigoMunicipioTomador *string `json:"codigo_municipio_tomador,omitempty"`
	CodigoTribMunicipio   string  `json:"codigo_tributacao_municipio"`
	ItemListaServico      string  `json:"item_lista_servico"`
	Discriminacao         string  `json:"discriminacao"`

	// opcionalmente você pode mandar XML/protocolo já preenchidos
	ProtocoloEnvio        *string `json:"protocolo_envio,omitempty"`
	DataEnvio             *string `json:"data_envio,omitempty"` // ISO
	CodigoMensagemRetorno *string `json:"codigo_mensagem_retorno,omitempty"`
	MensagemRetorno       *string `json:"mensagem_retorno,omitempty"`
	XmlEnvio              *string `json:"xml_envio,omitempty"`
	XmlRetorno            *string `json:"xml_retorno,omitempty"`

	Itens []criarNfseItemRequest `json:"itens"`
}

type listarNfseResponse struct {
	Notas []Nfse `json:"notas"`
}

// ---------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------

type Handler struct {
	svc *Service
}

func NewHandler(svc *Service) *Handler {
	return &Handler{svc: svc}
}

// Monta as rotas de NFS-e
func (h *Handler) Routes(r chi.Router) {
	r.Route("/tenants/{tenantID}", func(r chi.Router) {
		// Aqui assumo que o middleware DBTxMiddleware já setou tenantID no contexto
		r.Route("/nfse", func(r chi.Router) {
			r.Get("/", h.ListarPorCompetencia) // GET /tenants/{tenantID}/nfse?competencia=YYYY-MM-DD
			r.Post("/", h.CriarNfse)           // POST /tenants/{tenantID}/nfse
			r.Get("/{id}", h.ObterNfse)        // GET /tenants/{tenantID}/nfse/{id}
		})
	})
}

// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------

func (h *Handler) getTenantID(r *http.Request) (int64, error) {
	id, ok := httpcontext.TenantIDFromContext(r.Context())
	if !ok {
		// fallback: tenta ler da URL se não tiver no contexto
		tenantStr := chi.URLParam(r, "tenantID")
		return strconv.ParseInt(tenantStr, 10, 64)
	}
	return id, nil
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

// ---------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------

// POST /tenants/{tenantID}/nfse
func (h *Handler) CriarNfse(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil || tenantID <= 0 {
		http.Error(w, "tenant_id inválido", http.StatusBadRequest)
		return
	}

	var req criarNfseRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "JSON inválido", http.StatusBadRequest)
		return
	}

	// Parse de datas
	dataEmissao, err := time.Parse(time.RFC3339, req.DataEmissao)
	if err != nil {
		http.Error(w, "data_emissao inválida (use RFC3339)", http.StatusBadRequest)
		return
	}
	competencia, err := time.Parse("2006-01-02", req.Competencia)
	if err != nil {
		http.Error(w, "competencia inválida (use YYYY-MM-DD)", http.StatusBadRequest)
		return
	}

	nf := Nfse{
		TenantID:               tenantID,
		NumeroNfse:             req.NumeroNfse,
		Serie:                  req.Serie,
		Ambiente:               req.Ambiente,
		Situacao:               req.Situacao,
		PrestadorID:            req.PrestadorID,
		TomadorID:              req.TomadorID,
		DataEmissao:            dataEmissao,
		Competencia:            competencia,
		ValorServicos:          req.ValorServicos,
		ValorDeducoes:          req.ValorDeducoes,
		ValorPis:               req.ValorPis,
		ValorCofins:            req.ValorCofins,
		ValorInss:              req.ValorInss,
		ValorIr:                req.ValorIr,
		ValorCsll:              req.ValorCsll,
		ValorIss:               req.ValorIss,
		ValorIssRetido:         req.ValorIssRetido,
		IssRetido:              req.IssRetido,
		CodigoMunicipioPrest:   req.CodigoMunicipioPrest,
		CodigoMunicipioTomador: req.CodigoMunicipioTomador,
		CodigoTribMunicipio:    req.CodigoTribMunicipio,
		ItemListaServico:       req.ItemListaServico,
		Discriminacao:          req.Discriminacao,
		ProtocoloEnvio:         req.ProtocoloEnvio,
		CodigoMensagemRetorno:  req.CodigoMensagemRetorno,
		MensagemRetorno:        req.MensagemRetorno,
		XmlEnvio:               req.XmlEnvio,
		XmlRetorno:             req.XmlRetorno,
	}

	if req.DataEnvio != nil {
		if t, err := time.Parse(time.RFC3339, *req.DataEnvio); err == nil {
			nf.DataEnvio = &t
		}
	}

	var itens []NfseItem
	for _, it := range req.Itens {
		item := NfseItem{
			TenantID:            tenantID,
			NumeroItem:          it.NumeroItem,
			CodigoServico:       it.CodigoServico,
			Descricao:           it.Descricao,
			Quantidade:          it.Quantidade,
			ValorUnitario:       it.ValorUnitario,
			ValorTotal:          it.ValorTotal,
			AliquotaIss:         it.AliquotaIss,
			ValorIss:            it.ValorIss,
			CodigoTribMunicipio: it.CodigoTribMunicipio,
			ItemListaServico:    it.ItemListaServico,
		}
		itens = append(itens, item)
	}

	nfCompleta, err := h.svc.CriarNfseCompleta(r.Context(), tenantID, nf, itens)
	if err != nil {
		http.Error(w, "erro ao criar NFS-e: "+err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusCreated, nfCompleta)
}

// GET /tenants/{tenantID}/nfse/{id}
func (h *Handler) ObterNfse(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil || tenantID <= 0 {
		http.Error(w, "tenant_id inválido", http.StatusBadRequest)
		return
	}

	idStr := chi.URLParam(r, "id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		http.Error(w, "id inválido", http.StatusBadRequest)
		return
	}

	nf, err := h.svc.ObterNfseCompleta(r.Context(), tenantID, id)
	if err != nil {
		http.Error(w, "erro ao buscar NFS-e: "+err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusOK, nf)
}

// GET /tenants/{tenantID}/nfse?competencia=YYYY-MM-DD
func (h *Handler) ListarPorCompetencia(w http.ResponseWriter, r *http.Request) {
	tenantID, err := h.getTenantID(r)
	if err != nil || tenantID <= 0 {
		http.Error(w, "tenant_id inválido", http.StatusBadRequest)
		return
	}

	comp := r.URL.Query().Get("competencia")
	if comp == "" {
		http.Error(w, "parâmetro competencia é obrigatório (YYYY-MM-DD)", http.StatusBadRequest)
		return
	}

	// para simplificar, limit/offset fixos
	notas, err := h.svc.ListarPorCompetencia(r.Context(), tenantID, comp, 100, 0)
	if err != nil {
		http.Error(w, "erro ao listar NFS-e: "+err.Error(), http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusOK, listarNfseResponse{Notas: notas})
}

No main.go, é só fazer o wiring igual ao contexto identity:

// ...
nfseRepo := nfse.NewRepository(db)
nfseSvc := nfse.NewService(nfseRepo)
nfseHandler := nfse.NewHandler(nfseSvc)

r := chi.NewRouter()
r.Use(DBTxMiddleware(db))

nfseHandler.Routes(r)
// outros handlers...

// ...