Todas as publicações

PDFs contábeis em Go: livro diário, razão e balancete

Gere PDFs contábeis em Go: livro diário, razão e balancete a partir de um slice de lançamentos. Código gpdf completo, com quebra de página automática.

Publicado em 14 de novembro de 2026
por Taiki Noda

TL;DR

Um único slice de lançamentos contábeis basta para produzir um livro diário, um livro razão e um balancete de verificação a partir do mesmo programa Go. Com o gpdf v1.0.17, o main.go inteiro tem 240 linhas. Ele inclui tabelas com grade, valores alinhados à direita, linhas de cabeçalho que se repetem nas quebras de página e numeração de página no formato 1 / 3. Sem Chromium, sem CGO, sem licença de ferramenta de relatórios.

Um livro diário de um mês (67 lançamentos) tem 2 páginas e leva cerca de 9 ms em um Apple M1. Com 10.000 lançamentos são 170 páginas e cerca de 0,7 segundo.

Um relatório é uma agregação, não uma tabela

Quando alguém pergunta como produzir relatórios contábeis em Go, a primeira dúvida costuma ser como desenhar as linhas da grade. O trabalho não está aí.

Um livro diário, um livro razão e um balancete de verificação parecem três documentos diferentes, mas saem dos mesmos dados. O diário lista os lançamentos em ordem de data. O razão pega os mesmos lançamentos, reagrupa por conta e acrescenta um saldo acumulado. O balancete reduz cada conta a uma linha. A diferença entre os três é um GROUP BY diferente.

Então o desenho é um tipo de dado, três funções de agregação e uma função de renderização. A função de renderização é o c.Table do gpdf, e ela é fina. Por isso este post gasta a maior parte do espaço no lado da agregação.

Ele foi escrito para quem está construindo sistemas de contabilidade, faturamento ou despesas em Go e encontrou as palavras "emissão de relatórios" nos requisitos.

O exemplo usa a escrituração japonesa: os nomes dos relatórios são 仕訳帳 (livro diário), 総勘定元帳 (livro razão) e 合計残高試算表 (balancete de verificação), e os nomes das contas estão em japonês. A estrutura é o mesmo método das partidas dobradas usado em qualquer lugar. Manter o texto em japonês também mostra que a saída CJK não exige nenhum tratamento especial além de registrar uma fonte.

O modelo de dados: um único tipo de lançamento

type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

Um lançamento é uma conta de débito, uma conta de crédito e um valor. O valor é um int64 em ienes inteiros. Com float64, no dia em que o balancete estiver errado por 1 você vai passar a noite procurando a diferença. Em um mundo onde 0,1 + 0,2 não é 0,3, conferir se débitos e créditos são iguais não funciona.

As contas são descritas por duas tabelas.

var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

accounts é a ordem de exibição: caixa, depósitos bancários, contas a receber, contas a pagar, capital social, vendas, compras, aluguel. O razão e o balancete seguem essa ordem. debitSide marca as contas que aumentam pelo lado do débito, que são os ativos e as despesas. Passivos, patrimônio líquido e receitas aumentam pelo lado do crédito. O razão precisa dessa direção para calcular a coluna de saldo.

Isso é uma simplificação. Lançamentos reais nem sempre são um para um. Um lançamento de folha de pagamento tem uma linha de débito e três de crédito: depósitos bancários, imposto de renda retido e contribuição previdenciária retida. Para suportar lançamentos compostos, troque Entry por um número de comprovante mais um slice de linhas. As funções de agregação passam a percorrer as linhas, e o código de renderização não muda em nada.

O código completo

Coloque NotoSansJP-Regular.ttf em um diretório onde você rodou go mod init e salve o código abaixo como main.go. Testado com o gpdf v1.0.17.

go get github.com/gpdf-dev/gpdf
package main

import (
	"fmt"
	"log"
	"os"
	"sort"
	"strconv"

	"github.com/gpdf-dev/gpdf/document"
	"github.com/gpdf-dev/gpdf/pdf"
	"github.com/gpdf-dev/gpdf/template"
)

// Entry é um lançamento contábil. Valores em ienes inteiros, int64, nunca float64.
type Entry struct {
	Date, Debit, Credit, Memo string
	Amount                    int64
}

// Plano de contas na ordem de exibição: caixa, depósitos bancários, contas a receber,
// contas a pagar, capital social, vendas, compras, despesa de aluguel.
var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}

// Contas de natureza devedora (ativos e despesas). As demais são de natureza credora.
var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}

func yen(n int64) string {
	if n == 0 {
		return ""
	}
	s := strconv.FormatInt(n, 10)
	for i := len(s) - 3; i > 0; i -= 3 {
		s = s[:i] + "," + s[i:]
	}
	return s
}

func newDoc(font []byte, title string) *template.Document {
	doc := template.New(
		template.WithPageSize(document.A4),
		template.WithMargins(document.UniformEdges(document.Mm(15))),
		template.WithFont("NotoSansJP", font),
		template.WithDefaultFont("NotoSansJP", 9),
	)
	doc.Header(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(6, func(c *template.ColBuilder) {
				c.Text(title, template.FontSize(16))
			})
			r.Col(6, func(c *template.ColBuilder) {
				c.Text("株式会社サンプル商事", template.AlignRight())
				c.Text("自 2026年4月1日 至 2026年4月30日", template.AlignRight())
			})
		})
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) { c.Spacer(document.Mm(4)) })
		})
	})
	doc.Footer(func(p *template.PageBuilder) {
		p.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder,
					template.AlignCenter(), template.FontSize(8), template.TextColor(pdf.Gray(0.4)))
			})
		})
	})
	return doc
}

func table(c *template.ColBuilder, header []string, rows [][]string, widths []float64, aligns []document.TextAlign) {
	// c.Table não tem opção de padding de célula na v1.0.17, então o texto encosta
	// na borda da célula. Um espaço no lado do alinhamento afasta o texto da linha.
	pad := func(row []string) []string {
		out := make([]string, len(row))
		for i, s := range row {
			switch {
			case s == "":
			case aligns[i] == document.AlignRight:
				out[i] = s + " "
			case aligns[i] == document.AlignLeft:
				out[i] = " " + s
			default:
				out[i] = s
			}
		}
		return out
	}
	header = pad(header)
	padded := make([][]string, len(rows))
	for i, row := range rows {
		padded[i] = pad(row)
	}
	grid := template.Border(template.BorderWidth(document.Pt(0.5)), template.BorderColor(pdf.Gray(0.3)))
	c.Table(header, padded,
		template.ColumnWidths(widths...),
		template.ColumnAlign(aligns...),
		template.TableHeaderStyle(template.BgColor(pdf.RGBHex(0xEEEEEE))),
		template.WithTableCellBorder(grid),
		template.WithTableBorderCollapse(true),
	)
}

func save(doc *template.Document, name string) {
	b, err := doc.Generate()
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile(name, b, 0o644); err != nil {
		log.Fatal(err)
	}
}

const L, R = document.AlignLeft, document.AlignRight

// Livro diário (仕訳帳): uma linha por lançamento, em ordem de data.
func journal(font []byte, entries []Entry) {
	var total int64
	rows := make([][]string, 0, len(entries)+1)
	for _, e := range entries {
		rows = append(rows, []string{e.Date, e.Debit, yen(e.Amount), e.Credit, yen(e.Amount), e.Memo})
		total += e.Amount
	}
	rows = append(rows, []string{"", "合計", yen(total), "", yen(total), ""})

	doc := newDoc(font, "仕訳帳")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
				[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})
		})
	})
	save(doc, "journal.pdf")
}

// Livro razão (総勘定元帳): transporta cada lançamento para suas contas, com saldo acumulado.
func ledger(font []byte, entries []Entry) {
	doc := newDoc(font, "総勘定元帳")
	page := doc.AddPage()
	for _, acct := range accounts {
		var bal int64
		var rows [][]string
		for _, e := range entries {
			var dr, cr int64
			var counter string
			switch acct {
			case e.Debit:
				dr, counter = e.Amount, e.Credit
			case e.Credit:
				cr, counter = e.Amount, e.Debit
			default:
				continue
			}
			if debitSide[acct] {
				bal += dr - cr
			} else {
				bal += cr - dr
			}
			rows = append(rows, []string{e.Date, counter, e.Memo, yen(dr), yen(cr), yen(bal)})
		}
		if len(rows) == 0 {
			continue
		}
		page.AutoRow(func(r *template.RowBuilder) {
			r.Col(12, func(c *template.ColBuilder) {
				c.Text(acct, template.FontSize(11))
				c.Spacer(document.Mm(1))
				table(c, []string{"日付", "相手科目", "摘要", "借方", "貸方", "残高"}, rows,
					[]float64{10, 14, 34, 14, 14, 14}, []document.TextAlign{L, L, L, R, R, R})
				c.Spacer(document.Mm(5))
			})
		})
	}
	save(doc, "ledger.pdf")
}

// Balancete de verificação (合計残高試算表): totais de débito e crédito por conta, mais o saldo líquido.
func trialBalance(font []byte, entries []Entry) {
	dr, cr := map[string]int64{}, map[string]int64{}
	for _, e := range entries {
		dr[e.Debit] += e.Amount
		cr[e.Credit] += e.Amount
	}
	var sumDr, sumCr, balDr, balCr int64
	var rows [][]string
	for _, a := range accounts {
		var bd, bc int64
		if d := dr[a] - cr[a]; d > 0 {
			bd = d
		} else {
			bc = -d
		}
		rows = append(rows, []string{yen(bd), yen(dr[a]), a, yen(cr[a]), yen(bc)})
		sumDr, sumCr, balDr, balCr = sumDr+dr[a], sumCr+cr[a], balDr+bd, balCr+bc
	}
	if sumDr != sumCr || balDr != balCr {
		log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
	}
	rows = append(rows, []string{yen(balDr), yen(sumDr), "合計", yen(sumCr), yen(balCr)})

	doc := newDoc(font, "合計残高試算表")
	doc.AddPage().AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			table(c, []string{"借方残高", "借方合計", "勘定科目", "貸方合計", "貸方残高"}, rows,
				[]float64{19, 19, 24, 19, 19}, []document.TextAlign{R, R, document.AlignCenter, R, R})
		})
	})
	save(doc, "trial_balance.pdf")
}

func main() {
	font, err := os.ReadFile("NotoSansJP-Regular.ttf")
	if err != nil {
		log.Fatal(err)
	}
	entries := []Entry{
		{"04/01", "普通預金", "資本金", "設立出資", 3000000},
		{"04/01", "現金", "普通預金", "小口現金引出", 100000},
	}
	// Em produção isto é uma consulta ao banco. Na demo, geramos um mês de lançamentos.
	for d := 2; d <= 30; d++ {
		day := fmt.Sprintf("04/%02d", d)
		entries = append(entries,
			Entry{day, "仕入高", "買掛金", fmt.Sprintf("商品仕入 伝票 P-%03d", d), int64(40000 + d*1300)},
			Entry{day, "売掛金", "売上高", fmt.Sprintf("商品売上 伝票 S-%03d", d), int64(88000 + d*2100)},
		)
		if d%10 == 0 {
			entries = append(entries,
				Entry{day, "普通預金", "売掛金", "売掛金回収", 600000},
				Entry{day, "買掛金", "普通預金", "買掛金支払", 300000},
			)
		}
	}
	entries = append(entries, Entry{"04/30", "支払家賃", "普通預金", "5月分家賃", 180000})
	sort.SliceStable(entries, func(i, j int) bool { return entries[i].Date < entries[j].Date })

	journal(font, entries)
	ledger(font, entries)
	trialBalance(font, entries)
}

go run . grava journal.pdf, ledger.pdf e trial_balance.pdf. Esta é a primeira página do livro razão.

Primeira página de um livro razão gerado com o gpdf: tabelas com grade para as contas de caixa, depósitos bancários e contas a receber, com colunas de data, contrapartida, histórico, débito, crédito e saldo

O livro diário: definir larguras e alinhamento

O diário é o mais simples dos três. Cada lançamento vira uma linha, e uma linha de totais vai no final.

table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
	[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})

Duas coisas são especificadas. template.ColumnWidths recebe as larguras como porcentagens que somam 100. template.ColumnAlign recebe um alinhamento por coluna: à direita para valores, à esquerda para o resto. ColumnAlign também se aplica à linha de cabeçalho, então o título fica alinhado à direita acima dos números.

Os separadores de milhar são adicionados pela função yen antes de o valor chegar à tabela. O gpdf não tem formatação de números. c.Table recebe um [][]string, e formatar cada célula é trabalho de quem chama. Zero vira string vazia porque, no razão, uma linha com valor a débito deve ter a célula de crédito em branco. Um relatório cheio de 0 impressos é difícil de ler.

A linha de totais coloca o mesmo total em débito e em crédito. Com lançamentos um para um isso é verdade por definição. Se você estender o código para lançamentos compostos, some os dois lados separadamente.

O livro razão: transporte e saldos acumulados

O razão é o diário transportado para as contas. Se um lançamento debita contas a receber e credita vendas, ele aparece uma vez na coluna de débito do razão de contas a receber e uma vez na coluna de crédito do razão de vendas. É por isso que 67 lançamentos viram 134 linhas de razão.

O código percorre todos os lançamentos para cada conta.

switch acct {
case e.Debit:
	dr, counter = e.Amount, e.Credit
case e.Credit:
	cr, counter = e.Amount, e.Debit
default:
	continue
}

Se a conta está no lado do débito, o valor vai para a coluna de débito e a conta de crédito é registrada como contrapartida. Se está no lado do crédito, o inverso. Caso contrário, o lançamento é ignorado.

Este é um loop de contas × lançamentos, e 8 × 67 não é nada. Com 300 contas e 100.000 lançamentos, distribua antes os lançamentos em um map[string][]Entry. Nesse volume a agregação provavelmente já é feita em SQL, e o Go recebe as linhas agrupadas por conta.

A coluna de saldo usa debitSide para decidir a direção.

if debitSide[acct] {
	bal += dr - cr
} else {
	bal += cr - dr
}

Depósitos bancários é uma conta de natureza devedora, então sobe nos depósitos (débito) e desce nos pagamentos (crédito). Contas a pagar é de natureza credora, então é o oposto. Deixe esse desvio de fora e você terá um razão de contas a pagar cujo saldo fica cada vez mais negativo. Os números estão aritmeticamente corretos e a equipe de contabilidade vai devolver o relatório mesmo assim, o que faz deste um dos bugs mais difíceis de explicar.

Um parêntese: não há razão lógica para o débito ficar à esquerda e o crédito à direita. É uma convenção que sobrevive desde que as partidas dobradas tomaram forma na Itália do século XV, e é preciso decorar. As palavras também não ajudam: "débito" e "crédito" descrevem a posição da outra parte, o contrário do que a maioria dos iniciantes espera. Chamar as variáveis de dr e cr pelo menos evita que o código aumente a confusão.

No lado do layout, cada conta adiciona um page.AutoRow.

page.AutoRow(func(r *template.RowBuilder) {
	r.Col(12, func(c *template.ColBuilder) {
		c.Text(acct, template.FontSize(11))
		c.Spacer(document.Mm(1))
		table(c, /* ... */)
		c.Spacer(document.Mm(5))
	})
})

Nome da conta, tabela, espaçamento, empilhados oito vezes. Nada neste código calcula onde uma página termina.

O balancete de verificação: falhar antes de renderizar

O balancete mostra o total de débitos e o total de créditos por conta, e coloca a diferença no lado em que ela cair. Por convenção, o nome da conta fica no meio, com débitos à esquerda e créditos à direita, então as colunas são saldo devedor, total de débitos, conta, total de créditos, saldo credor.

As linhas mais importantes deste relatório vêm antes de a tabela ser desenhada.

if sumDr != sumCr || balDr != balCr {
	log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
}

Um balancete existe para pegar erros de transporte. Se o total de débitos e o total de créditos não batem, há um lançamento quebrado em algum lugar. Não faz sentido diagramar com capricho um PDF que não fecha. Pare.

Nos dados de exemplo os totais são 11,269,600 ienes e os saldos são 7,389,600 ienes, e os dois lados batem. Só com lançamentos um para um, eles têm que bater. Essas três linhas passam a importar no momento em que os dados vêm de um banco. Em produção, retorne um erro em vez de chamar log.Fatalf, para poder descobrir qual conta está errada.

Quebras de página: nada a fazer

O código acima não tem lógica de quebra de página. Ele não conta linhas e não olha a altura restante.

Quando uma tabela não cabe no espaço restante, o motor de layout do gpdf divide o corpo e redesenha a linha de cabeçalho no topo da página seguinte. A página 2 do diário começa com a linha de cabeçalho, e a continuação da tabela de contas a receber no razão também. O título do relatório e o período em doc.Header e o número de página em doc.Footer aparecem em todas as páginas. Como faço uma tabela se estender por várias páginas? explica o mecanismo.

O número de página são dois placeholders dentro de uma string comum.

c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder, ...)

O total é preenchido depois que a paginação termina. Veja Como colocar número de página X de Y no rodapé do gpdf?.

Medições

Estes números são só do livro diário, usando lançamentos de venda gerados em quantidades diferentes. Apple M1, Go 1.25.2, gpdf v1.0.17. O tempo vai de ter os bytes da fonte em memória até o PDF estar gravado em disco.

LançamentosPáginasTempoTamanho do arquivo
6828.8 ms146 KB
1,0001768 ms1.9 MB
10,000170~0.7 s19 MB

8.8 ms para 68 lançamentos parece lento ao lado do número de benchmark do gpdf, 13 µs para uma página. A maior parte desse tempo é a fonte: a Noto Sans JP é um TTF de 9.6 MB, analisado e reduzido a subconjunto em toda execução. Fora isso, cada linha adicional custa cerca de 70 µs e o crescimento é linear. Em um lote noturno que produz os relatórios mensais de uma empresa inteira, a consulta ao banco vira gargalo antes da geração do PDF.

O tamanho do arquivo merece ser dito sem rodeios. Cada página tem cerca de 115 KB. O gpdf v1.0.17 comprime os streams de fonte e de imagem, mas não os operadores de desenho da página, e um relatório com borda em toda célula tem muitos operadores. 19 MB para 10.000 linhas é grande demais para um anexo de e-mail. Dividir por mês, ou trocar a grade por linhas zebradas com template.TableStripe, são as saídas práticas.

Do que abrimos mão

Padding de célula. O c.Table da v1.0.17 não tem opção de padding interno da célula. Com bordas, o texto encosta na linha. A função table do exemplo adiciona um espaço no lado do alinhamento de cada célula. Não é bonito, e funciona. Com listras zebra no lugar das bordas o problema não aparece.

Subtotais por página e linhas de transporte. Livros em papel tradicionalmente terminam cada página com uma linha "a transportar" e começam a seguinte com "de transporte". Para isso, o código de agregação precisa saber quais linhas caem em qual página. O gpdf decide as quebras de página na hora do layout, então a API builder não tem como informar. Você pode fixar o número de linhas por página e quebrar as páginas por conta própria, mas isso significa abrir mão da paginação automática. Ainda não encontramos um bom jeito de fazer isso.

Requisitos legais. Este código produz PDFs que têm a aparência de livros contábeis. Se eles atendem às regras de guarda de registros da sua jurisdição é outra questão. No Japão, por exemplo, livros criados em um sistema contábil em geral são guardados como dados, conforme a Lei de Preservação de Livros Eletrônicos, e o PDF é uma saída para consulta ou entrega. Consulte as orientações da Agência Nacional de Impostos (em japonês) ou o seu contador. No Brasil, as regras de escrituração contábil digital, como o SPED, são igualmente uma questão à parte.

FAQ

Preciso de uma ferramenta de relatórios para produzir PDFs contábeis em Go? Não para relatórios baseados em tabela, como livro diário, razão ou balancete. Passe um [][]string para o c.Table do gpdf e você tem bordas, larguras de coluna, alinhamento à direita, quebra de página automática e linhas de cabeçalho repetidas. O gpdf tem licença MIT e zero dependências externas, então também não há licença de runtime.

Posso usar orientação paisagem? Sim. Troque a opção por template.WithPageSize(document.A4.Landscape()). Um diário com históricos longos fica mais legível em paisagem. As larguras de coluna são porcentagens, então nada mais muda no código.

Funciona com texto que não seja japonês? Sim. Substitua as strings e registre, com template.WithFont, uma fonte que cubra o seu sistema de escrita. Para japonês, Noto Sans JP, IPAex Gothic e Source Han Sans funcionam do mesmo jeito. Veja Como incorporar uma fonte japonesa no gpdf?.

Valores negativos podem aparecer em vermelho ou entre parênteses? Parênteses ou sinal de menos são questão de formatação de string, então trate isso em yen. O c.Table não tem opção de cor de texto por célula. Para números em vermelho, construa um document.Table diretamente, como descrito em Tabelas em PDFs com Go: larguras, listras zebra, quebra de página.

Posso gerar PDF/A para arquivamento de longo prazo? Sim. Passe gpdf.WithPDFA() para gpdf.NewDocument. O PDF/A exige fontes incorporadas, o que um relatório CJK já tem.

Experimente o gpdf

gpdf é uma biblioteca de PDF para Go. MIT, zero dependências, CJK nativo.

go get github.com/gpdf-dev/gpdf

⭐ Star no GitHub · Ler a documentação

Próximas leituras

Se você chegar a um jeito limpo de fazer as linhas de transporte, abra uma issue no GitHub.