Build an Invoice Generator: Desktop App with Wails
In this course, you will build a real, useful desktop application — an invoice generator. Create clients, generate invoices with line items, export professional PDF invoices, and track payment status. Everything runs offline with SQLite, no internet required.
What We're Building
An invoice generator is one of the most practical apps a freelancer or small business can use. Instead of paying $20/month for a SaaS tool, you build your own — and it runs entirely on your computer with no subscription:
- • Client management — store client names, emails, addresses, companies
- • Invoice creation — auto-numbered invoices with line items, tax calculation
- • PDF export — professional invoices ready to email to clients
- • Payment tracking — Draft, Sent, Paid, Overdue status workflow
- • Excel export — export all invoices for accounting and tax filing
- • Dashboard — total revenue, outstanding balance, overdue count
Everything is stored locally in SQLite. No server, no cloud, no account needed. Open the app and start invoicing.
Challenge: List Invoice Features
List 5 features an invoice generator needs beyond what's listed above. Think about: recurring invoices, multiple currencies, late fees, payment reminders, client portal. Which would you prioritize?
Scaffold the App
Use Grit's desktop scaffold command to create the project:
grit new-desktop invoicerThis creates a Wails v2 project with Go backend, React + TypeScript frontend, local auth, SQLite database, and export capabilities (PDF + Excel). Start the development server:
cd invoicer && grit startThe app opens as a native window. You should see the login page — create an account and explore the default dashboard. This is your starting point.
Challenge: Scaffold and Run
Scaffold the project and run grit start. Create an account and log in. Explore the default pages — dashboard, blog, contacts. These are the starter templates you will customize into an invoice app.
Design the Data Model
The invoice app needs three resources. Notice how they relate to each other: a Client has many Invoices, and an Invoice has many LineItems.
# Resource 1: Client
grit generate resource Client \
name:string email:string phone:string:optional \
address:text:optional company:string:optional
# Resource 2: Invoice
grit generate resource Invoice \
invoice_number:string:unique \
client_id:belongs_to:Client \
issue_date:date due_date:date status:string \
subtotal:float tax:float total:float \
notes:text:optional
# Resource 3: LineItem
grit generate resource LineItem \
description:string quantity:int \
unit_price:float total:float \
invoice_id:belongs_to:InvoiceThe relationships work like this:
- • Client — standalone entity. Has name, email, optional phone/address/company.
- • Invoice — belongs to a Client. Has dates, status, financial totals.
- • LineItem — belongs to an Invoice. Each line is one product/service with qty and price.
Challenge: Generate All 3 Resources
Generate all three resources. Check the Go models — are the belongs_to foreign keys correct? Can you see the relationships in the model structs?
Auto-Generate Invoice Numbers
Every invoice needs a unique, sequential number. Instead of making the user type one, auto-generate it. Add this method to the Invoice service:
func (s *InvoiceService) NextNumber() string {
var count int64
s.db.Model(&models.Invoice{}).Count(&count)
return fmt.Sprintf("INV-%03d", count+1)
}This counts existing invoices and generates the next number: INV-001, INV-002, INV-003, and so on. The %03d format pads with leading zeros up to 3 digits.
Call this method when creating a new invoice. The Wails binding exposes it to the frontend, so you can pre-fill the invoice number field automatically.
Challenge: Add Auto-Numbering
Add the NextNumber() method to your invoice service. Create 3 invoices — are they numbered INV-001, INV-002, INV-003? Delete the second one and create another — what number does it get?
Build the Client List
The client list is your first custom page. It shows all clients in a searchable, sortable DataTable — the same component Grit's desktop scaffold provides:
// The DataTable component handles:
// - Column sorting (click headers)
// - Search/filter
// - Pagination
// - Row selection
// - Actions (edit, delete)
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'company', label: 'Company' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
]
// Fetch clients from the Go backend via Wails binding
const clients = await GetAllClients()The client list is the foundation. Every invoice needs a client, so build this page first. Add a "New Client" button that opens the FormBuilder with fields for name, email, company, phone, and address.
Challenge: Create 5 Clients
Build the client list page and the create client form. Add 5 clients with different companies. Can you search by name? Sort by company? Edit a client's email?
Build the Invoice Form
The invoice form is the most complex page in the app. It has multiple steps and dynamic line items that calculate totals automatically:
Step 1: Invoice Details
- • Select client from dropdown (populated from your client list)
- • Invoice number (auto-filled, but editable)
- • Issue date (defaults to today)
- • Due date (defaults to 30 days from today)
Step 2: Line Items
- • Dynamic rows: description, quantity, unit price
- • Line total auto-calculated: quantity x unit price
- • "Add Line Item" button to add more rows
- • "Remove" button on each row
Step 3: Review
- • Subtotal (sum of all line totals)
- • Tax rate input (percentage, e.g., 10%)
- • Tax amount (subtotal x tax rate)
- • Grand total (subtotal + tax)
- • Optional notes field
// Each line item: total = quantity * unitPrice
const lineTotal = quantity * unitPrice
// Invoice totals
const subtotal = lineItems.reduce((sum, item) => sum + item.total, 0)
const taxAmount = subtotal * (taxRate / 100)
const grandTotal = subtotal + taxAmountChallenge: Create an Invoice with 4 Line Items
Build the invoice form. Create an invoice with 4 line items (e.g., "Web Design — 40 hours at $75", "Logo Design — 1 at $500", etc.). Verify: Are the line totals correct? Is the subtotal the sum of all lines? Does the tax calculate properly?
Invoice Detail View
The invoice detail page shows a complete, formatted view of an invoice. This is what the user sees when they click on an invoice from the list:
- • Header — invoice number, status badge, issue date, due date
- • Client info — name, company, email, address
- • Line items table — description, quantity, unit price, line total
- • Totals section — subtotal, tax, grand total
- • Status badge — color-coded: Draft (gray), Sent (blue), Paid (green), Overdue (red)
- • Action buttons — Export PDF, Mark as Sent, Mark as Paid, Edit, Delete
const statusColors = {
draft: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
sent: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
paid: 'bg-green-500/10 text-green-400 border-green-500/20',
overdue: 'bg-red-500/10 text-red-400 border-red-500/20',
}Challenge: View an Invoice
Build the invoice detail page. Navigate to one of your invoices. Is all the information displayed correctly — client name, line items, totals? Does the status badge use the right color?
PDF Export
The killer feature of an invoice app: generating a professional PDF that you can email to clients. Grit's desktop scaffold includes an export service that generates PDFs from Go:
The PDF should include:
- • Company header — your business name, address, logo (optional)
- • Invoice number and dates — prominently displayed
- • Bill To section — client name, company, address
- • Line items table — with headers, alternating row colors
- • Totals — subtotal, tax, grand total (bold)
- • Payment terms — "Payment due within 30 days"
- • Notes — any additional notes from the invoice
// Call the Go export function via Wails binding
const handleExportPDF = async () => {
try {
const filePath = await ExportInvoicePDF(invoice.id)
// filePath is where the PDF was saved
alert("PDF saved to: " + filePath)
} catch (error) {
console.error("Export failed:", error)
}
}gofpdf or go-wkhtmltopdf. The Wails binding makes it callable from the React frontend as a simple async function. No REST API needed — it's a direct function call.Challenge: Export a PDF Invoice
Add a "Export PDF" button to the invoice detail page. Generate a PDF for one of your invoices. Open the PDF — does it look professional? Is all the data correct: client info, line items, totals?
Payment Status Workflow
Invoices follow a status workflow. Each transition represents a real business action:
Draft → Sent → Paid
↓ ↑
└──→ Overdue ──┘
Draft: Invoice created but not sent to client
Sent: Invoice emailed/delivered to client
Paid: Client has paid the invoice
Overdue: Past due date and not yet paidThe status transitions should be explicit actions — buttons on the invoice detail page:
- • "Mark as Sent" — visible when status is Draft
- • "Mark as Paid" — visible when status is Sent or Overdue
- • Auto-Overdue — a background check: if status is Sent and due_date has passed, mark as Overdue
func (s *InvoiceService) UpdateStatus(id uint, status string) error {
validTransitions := map[string][]string{
"draft": {"sent"},
"sent": {"paid", "overdue"},
"overdue": {"paid"},
}
var invoice models.Invoice
if err := s.db.First(&invoice, id).Error; err != nil {
return err
}
allowed := validTransitions[invoice.Status]
for _, s := range allowed {
if s == status {
return s.db.Model(&invoice).Update("status", status).Error
}
}
return fmt.Errorf("cannot transition from %s to %s", invoice.Status, status)
}Challenge: Test the Status Workflow
Create an invoice. It should start as Draft. Mark it as Sent — does the badge change to blue? Mark it as Paid — does the badge change to green? Try marking a Paid invoice as Draft — does the validation prevent it?
Dashboard
The dashboard gives you a financial overview at a glance:
- • Total Revenue — sum of all Paid invoices
- • Outstanding — sum of Sent invoices (money owed to you)
- • Overdue — count and total of overdue invoices (needs attention)
- • Recent Invoices — last 5 invoices with status, client, and amount
func (s *InvoiceService) DashboardStats() (*DashboardStats, error) {
var stats DashboardStats
// Total revenue (paid invoices)
s.db.Model(&models.Invoice{}).
Where("status = ?", "paid").
Select("COALESCE(SUM(total), 0)").
Scan(&stats.TotalRevenue)
// Outstanding (sent invoices)
s.db.Model(&models.Invoice{}).
Where("status = ?", "sent").
Select("COALESCE(SUM(total), 0)").
Scan(&stats.Outstanding)
// Overdue count
s.db.Model(&models.Invoice{}).
Where("status = ?", "overdue").
Count(&stats.OverdueCount)
return &stats, nil
}Challenge: Verify Dashboard Accuracy
Create 10+ invoices across all statuses (3 Draft, 3 Sent, 2 Paid, 2 Overdue). Check the dashboard — does Total Revenue only count Paid invoices? Does Outstanding only count Sent? Is the Overdue count correct?
Excel Export
For accounting and tax purposes, you need to export all invoices to a spreadsheet. Grit's desktop scaffold includes Excel export via the excelize Go library:
Invoice Number | Client | Issue Date | Due Date | Status | Subtotal | Tax | Total
INV-001 | Acme | 2026-01-15 | 2026-02-14 | Paid | 3000.00 | 300.00 | 3300.00
INV-002 | Globex | 2026-01-20 | 2026-02-19 | Sent | 1500.00 | 150.00 | 1650.00
...The export includes:
- • All invoices with key columns (number, client, dates, status, amounts)
- • Summary row at the bottom with total revenue and total outstanding
- • Formatted headers — bold, with column width auto-fit
Challenge: Export to Excel
Add an "Export All to Excel" button on the invoices list page. Export your invoices. Open the file in Excel, Google Sheets, or LibreOffice Calc. Are all the columns present? Is the data accurate?
Build for Production
When your invoice app is complete, compile it into a standalone native binary:
grit compileThis produces a single executable file that includes:
- • The Go backend (compiled to native machine code)
- • The React frontend (bundled, minified, and embedded)
- • SQLite (embedded database, no external server needed)
The end user just double-clicks the file. No installation, no dependencies, no internet required. The SQLite database is created automatically on first launch in the app's data directory.
-ldflags "-s -w" to strip debug symbols, bringing it down to 15-25 MB. That's your entire app — backend, frontend, and database engine — in a single file.Challenge: Build and Test the Binary
Run grit compile. Find the output binary. Run it outside of dev mode — does the app open? Create an invoice, export it as PDF. Does everything work the same as in development?
What You Learned
- How to scaffold a desktop app with
grit new-desktop - Designing related data models (Client, Invoice, LineItem)
- Auto-generating sequential invoice numbers
- Building a multi-step form with dynamic line items
- Implementing a status workflow with valid transitions
- Exporting professional PDF invoices
- Building a financial dashboard with aggregated stats
- Exporting data to Excel for accounting
- Compiling into a standalone native binary
Challenge: Complete Invoicing Workflow
Build the complete workflow:
- Create 3 clients (different companies)
- Create 10 invoices: 2 Draft, 3 Sent, 3 Paid, 2 Overdue
- Each invoice should have 2-5 line items
- Export 2 invoices as PDF
- Export all invoices to Excel
- Check the dashboard — are the stats accurate?
Challenge: Share Your App
Compile the app with grit compile. Send the binary to a friend or test on a different computer. Does it run without installing Go, Node, or Wails? Does the SQLite database get created automatically on first launch? Create an invoice on the new machine and export it as PDF.
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.