skip to content
Profile picture Oscar Corner

Building a Self-Hosted Invoice Generator with Go

/ 6 min read

Building a Self-Hosted Invoice Generator with Go

All the code is available on GitHub at github.com/wazor12/invoice-generator.

My girlfriend is self-employed in the UK, and we built an initial spreadsheet where she could track her hours and invoices. However, the process was manual and error-prone. So with some AI assistance I decided to build her a tool to track and make her life easier. Many tools already exist but are too complex or not really built with her use case in mind — something simple that does one thing and does it well. I also added OCR-based timesheet import: she can photograph a paper log and the app extracts entries using AI, then adds them to the database.

Why This Stack?

Go for the backend — uses the standard library net/http with Go 1.22’s enhanced ServeMux for routing. No framework, just clean handler functions. I’ve been a fan of Go and it has served me well.

SQLite via modernc.org/sqlite — pure Go, no CGO, no separate database server. The entire database is a single file. Migrations run automatically at startup from sorted .sql files. Who wants to host a whole database anyway.

HTMX 2.0 for the frontend — inline editing, partial table updates, form submissions without full page reloads. Combined with server-rendered Go templates this avoids needing any JavaScript framework. With the constant supply chain attacks that npm is having, the less I have to rely on that ecosystem the better.

Pico CSS — a minimal semantic CSS framework. No design system to fight, just clean defaults that look good. AI suggested this and it seems to work quite well (I can’t build a UI to save my life). I might have to replace it since it’s been dead for a while.

Typst for PDF generation — a modern typesetting engine that compiles to PDF. The invoice template is a Typst file that renders beautifully formatted invoices. I had several options here: use something like Gotenberg to generate PDFs with Chrome built in, or something smaller. I wanted to play around with Typst.

AWS Bedrock for OCR. Initially I wanted to use olmocr and self-host it, but the server I have doesn’t have a GPU powerful enough for it, and all the providers listed didn’t really work. So back to old reliable AWS using the Qwen3-VL model that seems to work well and is cheap enough.

Architecture

The app is a single binary serving HTML over HTTP. There’s also an optional Python microservice for OCR-based paper timesheet import using AWS Bedrock.

┌─────────────┐     ┌──────────────┐
│  Go Web App │◄────│  Browser     │
│  (port 8080)│     │  (HTMX)      │
└──────┬──────┘     └──────────────┘

┌──────▼──────┐     ┌──────────────┐
│  SQLite     │     │  Typst       │
│  (invoices) │     │  (PDF gen)   │
└─────────────┘     └──────────────┘

┌──────▼──────────┐
│  OCR Service    │
│  (Python:8000)  │
│  → AWS Bedrock  │
└─────────────────┘

Core Features

Time Entry Management

Full CRUD for work log entries with date, category, hours, and notes. Inline editing via HTMX partials — clicking an entry row turns it into an edit form without a page reload.

Rate Management

Hourly rates per category with date-range validity. This was important because rates change over time — you can have overlapping date ranges for different rate periods.

Invoice Generation

This is the heart of the app. Select a month and category, and it generates an invoice by:

  1. Finding all entries for that month/category
  2. Looking up the applicable hourly rate for each entry
  3. Computing totals
  4. Storing a snapshot of business settings at generation time
  5. Generating an invoice number in the format INV-YYYY-MM-SLUG

Invoice numbers auto-deduplicate — if INV-2026-05-CONSULTING already exists, it becomes INV-2026-05-CONSULTING-2.

PDF Preview & Generation

Invoices can be previewed as printable HTML, or downloaded as a PDF generated by Typst. The template at templates/invoice-maker.typ produces clean, professional invoices.

Email Delivery

Send invoices directly as PDF attachments via SMTP. Customer details (name, email, address) are configured in settings.

OCR Paper Entry Import

An optional feature using AWS Bedrock (Claude 3.5 Haiku) to import handwritten timesheets. Photograph your paper log, upload it, and the OCR service extracts draft entries for review. Confirmed entries are added to the database.

Code Highlights

Entry Point

The server setup is minimal — open the database, run migrations, register routes:

func main() {
	cfg := config.Load()
	logger := handler.NewLogger(cfg.LogLevel, cfg.LogFormat)

	store, err := db.Open(cfg.DatabasePath)
	if err != nil {
		logger.Error("open database", "error", err)
		os.Exit(1)
	}
	defer store.Close()

	if err := store.Migrate(cfg.MigrationsDir); err != nil {
		logger.Error("run migrations", "error", err)
		os.Exit(1)
	}

	app := handler.New(store, cfg, logger)
	server := &http.Server{
		Addr:    cfg.Address,
		Handler: app.Routes(),
	}

	logger.Info("server_started", "addr", cfg.Address)
	server.ListenAndServe()
}

Routes

All routes use the new Go 1.22+ pattern syntax with method prefixes and path parameters:

func (a *App) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /", a.dashboard)
	mux.HandleFunc("GET /entries", a.entriesPage)
	mux.HandleFunc("POST /entries", a.createEntry)
	mux.HandleFunc("POST /entries/{id}/delete", a.deleteEntry)
	mux.HandleFunc("GET /rates", a.ratesPage)
	mux.HandleFunc("POST /rates", a.createRate)
	mux.HandleFunc("GET /invoices", a.invoicesPage)
	mux.HandleFunc("POST /invoices/generate", a.generateInvoice)
	mux.HandleFunc("GET /invoices/{id}", a.invoicePreview)
	mux.HandleFunc("GET /invoices/{id}/pdf", a.invoicePDF)
	mux.HandleFunc("POST /invoices/{id}/send", a.sendInvoice)
	mux.HandleFunc("GET /settings", a.settingsPage)
	mux.HandleFunc("POST /settings", a.saveSettings)
	// ...
	return a.recoverMiddleware(mux)
}

Database Migrations

Migrations are plain SQL files run in filename order at startup:

func (s *Store) Migrate(dir string) error {
	files, err := filepath.Glob(filepath.Join(dir, "*.sql"))
	if err != nil {
		return fmt.Errorf("glob migrations: %w", err)
	}
	sort.Strings(files)

	for _, file := range files {
		contents, err := os.ReadFile(file)
		if err != nil {
			return fmt.Errorf("read migration %s: %w", file, err)
		}
		if _, err := s.db.Exec(string(contents)); err != nil {
			return fmt.Errorf("run migration %s: %w", file, err)
		}
	}
	return nil
}

Database Pragmas

On database open, three pragmas are set for reliability:

pragmas := []string{
	"PRAGMA journal_mode = WAL;",
	"PRAGMA foreign_keys = ON;",
	"PRAGMA busy_timeout = 5000;",
}

Deployment

I have a homelab that already has a Traefik instance and wildcard certificate and backups, so it’s just a matter of adding the service in my stack and deploying.

Docker Compose

The app and optional OCR service run via Docker Compose:

services:
  invoice-app:
    image: registry.oscarcorner.com/invoices
    build:
      context: ./invoice-service
    ports:
      - "8080:8080"
    volumes:
      - ./data:/app/data
    environment:
      OCR_ENABLED: "true"
      OCR_SERVICE_URL: "http://ocr-service:8000"
    restart: unless-stopped

  ocr-service:
    image: registry.oscarcorner.com/invoices-ocr
    build:
      context: ./ocr-service
    ports:
      - "8000:8000"
    restart: unless-stopped

What still needs to be done

  • Build a thorough testing strategy.
  • Improve logging and traces.
  • Build a CI/CD pipeline to automatically deploy new updates.

What I Learned

Building this reinforced a few things:

Go’s standard library is enough. No web framework needed — net/http with the new router, html/template for server-rendered templates, database/sql for database access. The result is zero external dependencies for the core functionality.

HTMX pairs well with server-rendered Go. The pattern of returning HTML fragments for HTMX requests and full pages for direct navigation is simple and effective. The isHTMX() check on the HX-Request header lets handlers return the right response for each context.

SQLite is great for single-user apps. No database server to manage, just a file. WAL mode gives good concurrent read/write performance. The pure Go implementation means no CGO dependency.

Typst produces beautiful PDFs. The typesetting language is intuitive and the output quality is excellent. It’s a solid alternative to LaTeX for document generation.

Building AI agents is simpler than I thought and it works quite well.