Multi-Tenant - 06 - Exemplo concreto de DER

Segue um exemplo de tabelas nfse (cabeçalho) e nfse_itens (itens da nota), já no padrão multi-tenant + RLS + patterns que a gente alinhou.

Vou considerar:


1. Tabela nfse (cabeçalho) – multi-tenant, RLS, índices

-- =====================================================================
-- TABELA: nfse (cabeçalho da NFS-e)
-- =====================================================================
CREATE TABLE public.nfse (
  id                     BIGSERIAL   PRIMARY KEY,
  tenant_id              BIGINT      NOT NULL,

  -- Identificação básica
  numero_nfse            BIGINT      NOT NULL,       -- número da NFS-e
  serie                  TEXT        NOT NULL,       -- série
  ambiente               TEXT        NOT NULL,       -- prod/homolog
  situacao               TEXT        NOT NULL,       -- emitida, cancelada, rejeitada, etc.

  -- Referências
  prestador_id           BIGINT      NOT NULL,       -- FK para pessoas/empresas prestadoras
  tomador_id             BIGINT      NOT NULL,       -- FK para pessoas/empresas tomadoras

  -- Dados fiscais/valores
  data_emissao           TIMESTAMPTZ NOT NULL,
  competencia            DATE        NOT NULL,
  valor_servicos         NUMERIC(15,2) NOT NULL,
  valor_deducoes         NUMERIC(15,2) NOT NULL DEFAULT 0,
  valor_pis              NUMERIC(15,2) NOT NULL DEFAULT 0,
  valor_cofins           NUMERIC(15,2) NOT NULL DEFAULT 0,
  valor_inss             NUMERIC(15,2) NOT NULL DEFAULT 0,
  valor_ir               NUMERIC(15,2) NOT NULL DEFAULT 0,
  valor_csll             NUMERIC(15,2) NOT NULL DEFAULT 0,
  valor_iss              NUMERIC(15,2) NOT NULL DEFAULT 0,
  valor_iss_retido       NUMERIC(15,2) NOT NULL DEFAULT 0,

  iss_retido             BOOLEAN     NOT NULL DEFAULT FALSE,
  codigo_municipio_prestacao VARCHAR(7) NOT NULL,  -- IBGE
  codigo_municipio_tomador  VARCHAR(7) NULL,

  -- Informações de serviço (padrão nacional)
  codigo_tributacao_municipio TEXT    NOT NULL,
  item_lista_servico          TEXT    NOT NULL,
  discriminacao               TEXT    NOT NULL,

  -- Dados de transmissão/retorno
  protocolo_envio        TEXT        NULL,
  data_envio             TIMESTAMPTZ NULL,
  codigo_mensagem_retorno TEXT       NULL,
  mensagem_retorno        TEXT       NULL,
  xml_envio              TEXT        NULL,
  xml_retorno            TEXT        NULL,

  -- Auditoria
  created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at             TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant
CREATE INDEX nfse_tenant_id_idx
    ON public.nfse (tenant_id, id);

-- Unicidade por tenant: número + série + ambiente
CREATE UNIQUE INDEX nfse_uq_tenant_numero_serie_ambiente
    ON public.nfse (tenant_id, numero_nfse, serie, ambiente);

-- Índice para consultas por competencia
CREATE INDEX nfse_tenant_competencia_idx
    ON public.nfse (tenant_id, competencia);

-- Índice para consultas por prestador em período
CREATE INDEX nfse_tenant_prestador_competencia_idx
    ON public.nfse (tenant_id, prestador_id, competencia);

-- Trigger updated_at
CREATE TRIGGER nfse_set_updated_at
BEFORE UPDATE ON public.nfse
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

-- RLS: cada tenant só enxerga suas NFS-e
ALTER TABLE public.nfse ENABLE ROW LEVEL SECURITY;

CREATE POLICY nfse_tenant_policy
ON public.nfse
USING (tenant_id = current_settingBIGINT;

Se você tiver tabela de pessoas/empresas multi-tenant, aqui os FKs prestador_id e tomador_id apontam pra ela.


2. Tabela nfse_itens – multi-tenant, RLS, índices

-- =====================================================================
-- TABELA: nfse_itens (itens da NFS-e)
-- =====================================================================
CREATE TABLE public.nfse_itens (
  id                     BIGSERIAL   PRIMARY KEY,
  tenant_id              BIGINT      NOT NULL,

  nfse_id                BIGINT      NOT NULL,       -- FK para nfse.id
  numero_item            INT         NOT NULL,       -- 1,2,3...

  codigo_servico         TEXT        NOT NULL,
  descricao              TEXT        NOT NULL,
  quantidade             NUMERIC(15,4) NOT NULL DEFAULT 1,
  valor_unitario         NUMERIC(15,4) NOT NULL,
  valor_total            NUMERIC(15,2) NOT NULL,

  aliquota_iss           NUMERIC(7,4) NOT NULL DEFAULT 0,
  valor_iss              NUMERIC(15,2) NOT NULL DEFAULT 0,

  -- Campos adicionais conforme padrão nacional
  codigo_tributacao_municipio TEXT    NULL,
  item_lista_servico          TEXT    NULL,

  created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at             TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Índice padrão multi-tenant
CREATE INDEX nfse_itens_tenant_id_idx
    ON public.nfse_itens (tenant_id, id);

-- Índice para acesso aos itens por nfse
CREATE INDEX nfse_itens_tenant_nfse_idx
    ON public.nfse_itens (tenant_id, nfse_id, numero_item);

-- Garante que não tenha item duplicado (mesmo número_item) dentro da nota
CREATE UNIQUE INDEX nfse_itens_uq_tenant_nfse_item
    ON public.nfse_itens (tenant_id, nfse_id, numero_item);

-- Foreign key para nfse
ALTER TABLE public.nfse_itens
  ADD CONSTRAINT nfse_itens_nfse_fk
    FOREIGN KEY (nfse_id)
    REFERENCES public.nfse (id)
    ON DELETE CASCADE;

-- Trigger updated_at
CREATE TRIGGER nfse_itens_set_updated_at
BEFORE UPDATE ON public.nfse_itens
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

-- RLS
ALTER TABLE public.nfse_itens ENABLE ROW LEVEL SECURITY;

CREATE POLICY nfse_itens_tenant_policy
ON public.nfse_itens
USING (tenant_id = current_settingBIGINT;

3. Domain em Go para NFS-e (internal/nfse/domain.go)

package nfse

import "time"

type Nfse struct {
	ID                     int64      `db:"id" json:"id"`
	TenantID               int64      `db:"tenant_id" json:"tenant_id"`
	NumeroNfse             int64      `db:"numero_nfse" json:"numero_nfse"`
	Serie                  string     `db:"serie" json:"serie"`
	Ambiente               string     `db:"ambiente" json:"ambiente"`
	Situacao               string     `db:"situacao" json:"situacao"`
	PrestadorID            int64      `db:"prestador_id" json:"prestador_id"`
	TomadorID              int64      `db:"tomador_id" json:"tomador_id"`
	DataEmissao            time.Time  `db:"data_emissao" json:"data_emissao"`
	Competencia            time.Time  `db:"competencia" json:"competencia"` // date -> usar só a parte da data
	ValorServicos          float64    `db:"valor_servicos" json:"valor_servicos"`
	ValorDeducoes          float64    `db:"valor_deducoes" json:"valor_deducoes"`
	ValorPis               float64    `db:"valor_pis" json:"valor_pis"`
	ValorCofins            float64    `db:"valor_cofins" json:"valor_cofins"`
	ValorInss              float64    `db:"valor_inss" json:"valor_inss"`
	ValorIr                float64    `db:"valor_ir" json:"valor_ir"`
	ValorCsll              float64    `db:"valor_csll" json:"valor_csll"`
	ValorIss               float64    `db:"valor_iss" json:"valor_iss"`
	ValorIssRetido         float64    `db:"valor_iss_retido" json:"valor_iss_retido"`
	IssRetido              bool       `db:"iss_retido" json:"iss_retido"`
	CodigoMunicipioPrest   string     `db:"codigo_municipio_prestacao" json:"codigo_municipio_prestacao"`
	CodigoMunicipioTomador *string    `db:"codigo_municipio_tomador" json:"codigo_municipio_tomador,omitempty"`
	CodigoTribMunicipio    string     `db:"codigo_tributacao_municipio" json:"codigo_tributacao_municipio"`
	ItemListaServico       string     `db:"item_lista_servico" json:"item_lista_servico"`
	Discriminacao          string     `db:"discriminacao" json:"discriminacao"`
	ProtocoloEnvio         *string    `db:"protocolo_envio" json:"protocolo_envio,omitempty"`
	DataEnvio              *time.Time `db:"data_envio" json:"data_envio,omitempty"`
	CodigoMensagemRetorno  *string    `db:"codigo_mensagem_retorno" json:"codigo_mensagem_retorno,omitempty"`
	MensagemRetorno        *string    `db:"mensagem_retorno" json:"mensagem_retorno,omitempty"`
	XmlEnvio               *string    `db:"xml_envio" json:"xml_envio,omitempty"`
	XmlRetorno             *string    `db:"xml_retorno" json:"xml_retorno,omitempty"`
	CreatedAt              time.Time  `db:"created_at" json:"created_at"`
	UpdatedAt              time.Time  `db:"updated_at" json:"updated_at"`
}

type NfseItem struct {
	ID                     int64      `db:"id" json:"id"`
	TenantID               int64      `db:"tenant_id" json:"tenant_id"`
	NfseID                 int64      `db:"nfse_id" json:"nfse_id"`
	NumeroItem             int        `db:"numero_item" json:"numero_item"`
	CodigoServico          string     `db:"codigo_servico" json:"codigo_servico"`
	Descricao              string     `db:"descricao" json:"descricao"`
	Quantidade             float64    `db:"quantidade" json:"quantidade"`
	ValorUnitario          float64    `db:"valor_unitario" json:"valor_unitario"`
	ValorTotal             float64    `db:"valor_total" json:"valor_total"`
	AliquotaIss            float64    `db:"aliquota_iss" json:"aliquota_iss"`
	ValorIss               float64    `db:"valor_iss" json:"valor_iss"`
	CodigoTribMunicipio    *string    `db:"codigo_tributacao_municipio" json:"codigo_tributacao_municipio,omitempty"`
	ItemListaServico       *string    `db:"item_lista_servico" json:"item_lista_servico,omitempty"`
	CreatedAt              time.Time  `db:"created_at" json:"created_at"`
	UpdatedAt              time.Time  `db:"updated_at" json:"updated_at"`
}

// View de conveniência: NFS-e com itens
type NfseCompleta struct {
	Cabecalho Nfse       `json:"cabecalho"`
	Itens     []NfseItem `json:"itens"`
}

4. Repositório NFSe com runner(ctx) e multi-tenant (internal/nfse/repository_sqlx.go)

Reutilizando o padrão DBTX + dbctx.TxFromContext:

package nfse

import (
	"context"
	"database/sql"

	"fsgo/pkg/dbctx"

	"github.com/jmoiron/sqlx"
)

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	PrepareNamedContext(ctx context.Context, query string) (*sqlx.NamedStmt, error)
	GetContext(ctx context.Context, dest any, query string, args ...any) error
	SelectContext(ctx context.Context, dest any, query string, args ...any) error
}

type Repository interface {
	CriarNfse(ctx context.Context, nf *Nfse, itens []NfseItem) error
	ObterPorID(ctx context.Context, tenantID, id int64) (*NfseCompleta, error)
	ListarPorCompetencia(ctx context.Context, tenantID int64, competencia string, limit, offset int) ([]Nfse, error)
}

type repository struct {
	db *sqlx.DB
}

func NewRepository(db *sqlx.DB) Repository {
	return &repository{db: db}
}

// escolhe DB ou Tx do contexto
func (r *repository) runner(ctx context.Context) DBTX {
	if tx := dbctx.TxFromContext(ctx); tx != nil {
		return tx
	}
	return r.db
}

func (r *repository) CriarNfse(ctx context.Context, nf *Nfse, itens []NfseItem) error {
	db := r.runner(ctx)

	// insere cabeçalho
	queryCab := `
		INSERT INTO public.nfse (
			tenant_id, numero_nfse, serie, ambiente, situacao,
			prestador_id, tomador_id, data_emissao, competencia,
			valor_servicos, valor_deducoes,
			valor_pis, valor_cofins, valor_inss, valor_ir, valor_csll,
			valor_iss, valor_iss_retido, iss_retido,
			codigo_municipio_prestacao, codigo_municipio_tomador,
			codigo_tributacao_municipio, item_lista_servico, discriminacao,
			protocolo_envio, data_envio, codigo_mensagem_retorno,
			mensagem_retorno, xml_envio, xml_retorno
		) VALUES (
			:tenant_id, :numero_nfse, :serie, :ambiente, :situacao,
			:prestador_id, :tomador_id, :data_emissao, :competencia,
			:valor_servicos, :valor_deducoes,
			:valor_pis, :valor_cofins, :valor_inss, :valor_ir, :valor_csll,
			:valor_iss, :valor_iss_retido, :iss_retido,
			:codigo_municipio_prestacao, :codigo_municipio_tomador,
			:codigo_tributacao_municipio, :item_lista_servico, :discriminacao,
			:protocolo_envio, :data_envio, :codigo_mensagem_retorno,
			:mensagem_retorno, :xml_envio, :xml_retorno
		)
		RETURNING id, created_at, updated_at
	`
	stmtCab, err := db.PrepareNamedContext(ctx, queryCab)
	if err != nil {
		return err
	}
	defer stmtCab.Close()

	if err := stmtCab.QueryRowxContext(ctx, nf).Scan(&nf.ID, &nf.CreatedAt, &nf.UpdatedAt); err != nil {
		return err
	}

	// insere itens
	queryItem := `
		INSERT INTO public.nfse_itens (
			tenant_id, nfse_id, numero_item,
			codigo_servico, descricao,
			quantidade, valor_unitario, valor_total,
			aliquota_iss, valor_iss,
			codigo_tributacao_municipio, item_lista_servico
		) VALUES (
			:tenant_id, :nfse_id, :numero_item,
			:codigo_servico, :descricao,
			:quantidade, :valor_unitario, :valor_total,
			:aliquota_iss, :valor_iss,
			:codigo_tributacao_municipio, :item_lista_servico
		)
		RETURNING id, created_at, updated_at
	`
	stmtItem, err := db.PrepareNamedContext(ctx, queryItem)
	if err != nil {
		return err
	}
	defer stmtItem.Close()

	for i := range itens {
		itens[i].TenantID = nf.TenantID
		itens[i].NfseID = nf.ID
		if err := stmtItem.QueryRowxContext(ctx, &itens[i]).Scan(&itens[i].ID, &itens[i].CreatedAt, &itens[i].UpdatedAt); err != nil {
			return err
		}
	}

	return nil
}

func (r *repository) ObterPorID(ctx context.Context, tenantID, id int64) (*NfseCompleta, error) {
	db := r.runner(ctx)

	var nf Nfse
	queryCab := `
		SELECT *
		  FROM public.nfse
		 WHERE tenant_id = $1
		   AND id = $2
	`
	if err := db.GetContext(ctx, &nf, queryCab, tenantID, id); err != nil {
		return nil, err
	}

	var itens []NfseItem
	queryItens := `
		SELECT *
		  FROM public.nfse_itens
		 WHERE tenant_id = $1
		   AND nfse_id = $2
		 ORDER BY numero_item
	`
	if err := db.SelectContext(ctx, &itens, queryItens, tenantID, id); err != nil {
		return nil, err
	}

	return &NfseCompleta{
		Cabecalho: nf,
		Itens:     itens,
	}, nil
}

func (r *repository) ListarPorCompetencia(ctx context.Context, tenantID int64, competencia string, limit, offset int) ([]Nfse, error) {
	db := r.runner(ctx)

	var notas []Nfse
	query := `
		SELECT *
		  FROM public.nfse
		 WHERE tenant_id = $1
		   AND competencia = $2::date
		 ORDER BY data_emissao, id
		 LIMIT $3 OFFSET $4
	`
	if err := db.SelectContext(ctx, &notas, query, tenantID, competencia, limit, offset); err != nil {
		return nil, err
	}
	return notas, nil
}

Com isso você tem: