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:
- Multi-tenant:
tenant_id BIGINT NOT NULL - PK simples:
id BIGSERIAL - PostgreSQL
- RLS com
app.tenant_id - Modelo bem típico para NFS-e nacional.
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/empresasmulti-tenant, aqui os FKsprestador_idetomador_idapontam 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, ¬as, query, tenantID, competencia, limit, offset); err != nil {
return nil, err
}
return notas, nil
}
Com isso você tem:
nfseenfse_itens100% no padrão multi-tenant que a gente definiu.- RLS fechando a porta por tenant.
- Repositório em Go plugável no mesmo infra de Tx/RLS que montamos.