Build a Point of Sale App
Build a real-world Point of Sale system for a retail store. This advanced tutorial covers product catalog, sales transactions, customer management, receipt generation, inventory tracking, and daily reporting — all running as a native desktop application.
What We're Building
By the end of this tutorial you will have a fully functional POS desktop application that a small retail store can use day-to-day. The app runs as a single native binary with no browser, no internet dependency, and no external database server.
Why a Desktop App?
A POS system needs to be fast, reliable, and work without an internet connection. Grit Desktop gives you all of that:
- Offline-first — SQLite stores everything locally. No cloud database, no network latency, no downtime when the internet goes out.
- Single binary — The Go backend, React frontend, and SQLite database all compile into one executable. Drop it on any machine and it runs.
- Native performance — Go handles the business logic. No Electron overhead, no JVM, no interpreter.
- Easy distribution — Hand a store owner a USB drive with a single
.exefile. They double-click it and start selling.
This tutorial assumes you have completed the Getting Started guide and have Go, Node.js, and the Wails CLI installed.
Tech Stack
Scaffold the Project
Create a new Grit Desktop project. This generates a complete Wails application with Go backend, React frontend, authentication, and two starter resources (Blog and Contact).
Verify everything works by starting the dev server:
You should see a native desktop window open with the default dashboard. Close it when you are satisfied it works — we have resources to generate.
Plan the Data Model
Before generating code, plan the resources. A POS system revolves around five core entities. Here is the complete data model we will generate:
| Resource | Fields | Purpose |
|---|---|---|
| Product | name, sku, price, cost, stock, category, barcode | Product catalog with pricing and inventory |
| Customer | name, email, phone, address, loyalty_points | Customer records and loyalty program |
| Sale | customer_id, total, tax, payment_method, status, notes | Sales transactions |
| SaleItem | sale_id, product_id, quantity, unit_price, subtotal | Individual line items within a sale |
| Expense | description, amount, category, date | Business expenses for profit calculation |
The Sale and SaleItem tables form a one-to-many relationship: each sale contains multiple line items. The SaleItem also references Product via product_id. These foreign keys are declared as uint fields — GORM handles the rest.
Generate All Resources
Run these five commands to generate the entire data layer, service layer, and frontend pages. Each command creates 4 new files and performs 10 code injections.
After all five commands complete, here is what you have:
The 50 injections span across db.go, main.go, app.go, types.go, cmd/studio/main.go, and sidebar.tsx. Every resource gets its own sidebar entry, route file, and bound methods automatically.
If you want to review what each injection does, see the Resource Generation reference page for the complete 10-marker table.
Explore the Generated Models
Open internal/models/product.go. Grit generated a complete GORM model with all seven fields, timestamps, and soft delete support:
package modelsimport ("time""gorm.io/gorm")type Product struct {ID uint `gorm:"primaryKey" json:"id"`Name string `json:"name"`Sku string `json:"sku"`Price float64 `json:"price"`Cost float64 `json:"cost"`Stock int `json:"stock"`Category string `json:"category"`Barcode string `json:"barcode"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`}
Now look at the Sale model. Notice the customer_id field is generated as uint, which GORM treats as a foreign key. You can optionally add a relationship struct tag to enable eager loading:
package modelsimport ("time""gorm.io/gorm")type Sale struct {ID uint `gorm:"primaryKey" json:"id"`CustomerID uint `json:"customer_id"`Total float64 `json:"total"`Tax float64 `json:"tax"`PaymentMethod string `json:"payment_method"`Status string `json:"status"`Notes string `json:"notes"`CreatedAt time.Time `json:"created_at"`UpdatedAt time.Time `json:"updated_at"`DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`// Optional: add relationships for eager loading// Customer Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"`// Items []SaleItem `gorm:"foreignKey:SaleID" json:"items,omitempty"`}
The SaleItem model has both sale_id and product_id as foreign keys, linking each line item to its parent sale and the product being sold. This is the backbone of the transaction system.
Customize the Product Service
The generated ProductService already has full CRUD methods (List, ListAll, GetByID, Create, Update, Delete). For a POS system, we need additional queries. Add a low-stock alert method and a barcode lookup:
// GetLowStock returns products with stock below the given threshold.func (s *ProductService) GetLowStock(threshold int) ([]models.Product, error) {var products []models.Productresult := s.db.Where("stock < ?", threshold).Order("stock ASC").Find(&products)return products, result.Error}// GetByBarcode looks up a single product by its barcode string.func (s *ProductService) GetByBarcode(barcode string) (*models.Product, error) {var product models.Productresult := s.db.Where("barcode = ?", barcode).First(&product)if result.Error != nil {return nil, result.Error}return &product, nil}// GetByCategory returns all products in a given category.func (s *ProductService) GetByCategory(category string) ([]models.Product, error) {var products []models.Productresult := s.db.Where("category = ?", category).Order("name ASC").Find(&products)return products, result.Error}// DecrementStock reduces the stock of a product by the given quantity.// Returns an error if insufficient stock is available.func (s *ProductService) DecrementStock(productID uint, qty int) error {return s.db.Model(&models.Product{}).Where("id = ? AND stock >= ?", productID, qty).Update("stock", gorm.Expr("stock - ?", qty)).Error}
Now expose these new methods through the App struct in app.go. Wails automatically binds these as callable functions in the frontend:
// GetLowStockProducts returns products with stock below 10 units.func (a *App) GetLowStockProducts() ([]models.Product, error) {return a.product.GetLowStock(10)}// LookupBarcode finds a product by its barcode for the POS scanner.func (a *App) LookupBarcode(barcode string) (*models.Product, error) {return a.product.GetByBarcode(barcode)}// GetProductsByCategory returns all products in a given category.func (a *App) GetProductsByCategory(category string) ([]models.Product, error) {return a.product.GetByCategory(category)}
Add Sale Processing Logic
This is the core of the POS system. When a cashier completes a sale, the app needs to do four things atomically:
- Create the
Salerecord with totals and tax - Create a
SaleItemfor each product in the cart - Decrement the
stockon eachProduct - Award loyalty points to the
Customer(if any)
All four operations must succeed or fail together. GORM transactions make this straightforward. First, define the input type in internal/types/types.go:
// ProcessSaleInput is the payload from the frontend checkout form.type ProcessSaleInput struct {CustomerID uint `json:"customer_id"`PaymentMethod string `json:"payment_method"`Notes string `json:"notes"`TaxRate float64 `json:"tax_rate"`Items []SaleItemInput `json:"items"`}// SaleItemInput represents a single line item in the cart.type SaleItemInput struct {ProductID uint `json:"product_id"`Quantity int `json:"quantity"`UnitPrice float64 `json:"unit_price"`}
Now add the ProcessSale method to app.go. This method wraps all four operations in a single database transaction:
// ProcessSale handles the complete checkout flow within a database// transaction. It creates the sale, line items, updates stock, and// awards loyalty points.func (a *App) ProcessSale(input types.ProcessSaleInput) (*models.Sale, error) {var sale models.Saleerr := a.db.Transaction(func(tx *gorm.DB) error {// Calculate totals from line itemsvar subtotal float64for _, item := range input.Items {subtotal += item.UnitPrice * float64(item.Quantity)}tax := subtotal * input.TaxRatetotal := subtotal + tax// 1. Create the Sale recordsale = models.Sale{CustomerID: input.CustomerID,Total: total,Tax: tax,PaymentMethod: input.PaymentMethod,Status: "completed",Notes: input.Notes,}if err := tx.Create(&sale).Error; err != nil {return fmt.Errorf("create sale: %w", err)}// 2. Create SaleItem records for each line itemfor _, item := range input.Items {saleItem := models.SaleItem{SaleID: sale.ID,ProductID: item.ProductID,Quantity: item.Quantity,UnitPrice: item.UnitPrice,Subtotal: item.UnitPrice * float64(item.Quantity),}if err := tx.Create(&saleItem).Error; err != nil {return fmt.Errorf("create sale item: %w", err)}// 3. Decrement product stockresult := tx.Model(&models.Product{}).Where("id = ? AND stock >= ?", item.ProductID, item.Quantity).Update("stock", gorm.Expr("stock - ?", item.Quantity))if result.Error != nil {return fmt.Errorf("decrement stock: %w", result.Error)}if result.RowsAffected == 0 {return fmt.Errorf("insufficient stock for product %d", item.ProductID)}}// 4. Award loyalty points to the customer (1 point per dollar)if input.CustomerID > 0 {points := int(total)err := tx.Model(&models.Customer{}).Where("id = ?", input.CustomerID).Update("loyalty_points", gorm.Expr("loyalty_points + ?", points)).Errorif err != nil {return fmt.Errorf("update loyalty points: %w", err)}}return nil})if err != nil {return nil, err}return &sale, nil}
If any step fails — for example, insufficient stock on a product — the entire transaction rolls back. No partial sales, no orphaned records, no inventory discrepancies. The gorm.Expr calls ensure the stock decrement and loyalty point update happen atomically at the database level.
The result.RowsAffected == 0 check is critical. It catches the case where a product has been sold out between when the cashier added it to the cart and when they hit checkout.Customize the Frontend
The generated list and form pages are fully functional out of the box, but a POS system needs a checkout-oriented UI. Let us build a barcode lookup component and a Quick Sale page.
First, create a barcode search input. Wails generates TypeScript bindings for every bound Go method, so you can call LookupBarcode directly from React:
import { useState } from "react";import { LookupBarcode } from "../../wailsjs/go/main/App";interface Product {id: number;name: string;price: number;stock: number;barcode: string;}interface BarcodeScannerProps {onProductFound: (product: Product) => void;}export function BarcodeScanner({ onProductFound }: BarcodeScannerProps) {const [barcode, setBarcode] = useState("");const [error, setError] = useState("");const handleScan = async () => {if (!barcode.trim()) return;setError("");try {const product = await LookupBarcode(barcode.trim());if (product) {onProductFound(product);setBarcode("");}} catch (err) {setError("Product not found");}};return (<div className="flex gap-2"><inputtype="text"value={barcode}onChange={(e) => setBarcode(e.target.value)}onKeyDown={(e) => e.key === "Enter" && handleScan()}placeholder="Scan or type barcode..."className="flex-1 px-3 py-2 rounded-md border bg-background"autoFocus/><buttononClick={handleScan}className="px-4 py-2 rounded-md bg-primary text-primary-foreground">Add</button>{error && <span className="text-destructive text-sm">{error}</span>}</div>);}
Next, create the Quick Sale page. This page displays a running cart, lets the cashier scan barcodes, and processes the sale on checkout. Add this as a new page at frontend/src/pages/pos/index.tsx:
import { useState } from "react";import { BarcodeScanner } from "../../components/barcode-scanner";import { ProcessSale } from "../../../wailsjs/go/main/App";interface CartItem {product_id: number;name: string;quantity: number;unit_price: number;}export default function POSPage() {const [cart, setCart] = useState<CartItem[]>([]);const [paymentMethod, setPaymentMethod] = useState("cash");const TAX_RATE = 0.08; // 8% sales taxconst subtotal = cart.reduce((sum, item) => sum + item.unit_price * item.quantity, 0);const tax = subtotal * TAX_RATE;const total = subtotal + tax;const addToCart = (product: any) => {setCart((prev) => {const existing = prev.find((i) => i.product_id === product.id);if (existing) {return prev.map((i) =>i.product_id === product.id? { ...i, quantity: i.quantity + 1 }: i);}return [...prev, {product_id: product.id,name: product.name,quantity: 1,unit_price: product.price,}];});};const handleCheckout = async () => {try {await ProcessSale({customer_id: 0,payment_method: paymentMethod,notes: "",tax_rate: TAX_RATE,items: cart.map((item) => ({product_id: item.product_id,quantity: item.quantity,unit_price: item.unit_price,})),});setCart([]);alert("Sale completed!");} catch (err) {alert("Error: " + err);}};return (<div className="p-6"><h1 className="text-2xl font-bold mb-4">Quick Sale</h1><BarcodeScanner onProductFound={addToCart} />{/* Cart items table, totals, checkout button */}{/* ... build out the full UI here */}</div>);}
To register this page, create a route file at frontend/src/routes/_layout/pos.index.tsx and wrap the component with createFileRoute:
// frontend/src/routes/_layout/pos.index.tsximport { createFileRoute } from "@tanstack/react-router";export const Route = createFileRoute("/_layout/pos/")({component: POSPage,});// ... POSPage component from above
Add a sidebar entry in sidebar.tsx so the cashier can navigate to the POS page. The generated sidebar uses Lucide icons — ShoppingCart is a good fit for the POS entry.
The generated product list page at /products still works for inventory management. The POS page is an additional view optimized for checkout speed.Add Receipt Generation
After completing a sale, the store needs a receipt. Go makes it easy to generate a text-based receipt that can be saved as a file or sent to a thermal printer. Add this method to app.go:
// GenerateReceipt builds a plain-text receipt for a completed sale.// Returns the receipt string for display or printing.func (a *App) GenerateReceipt(saleID uint) (string, error) {// Fetch the salevar sale models.Saleif err := a.db.First(&sale, saleID).Error; err != nil {return "", fmt.Errorf("sale not found: %w", err)}// Fetch the line itemsvar items []models.SaleItemif err := a.db.Where("sale_id = ?", saleID).Find(&items).Error; err != nil {return "", fmt.Errorf("fetch items: %w", err)}// Fetch product names for each itemtype lineItem struct {Name stringQty intPrice float64Subtotal float64}var lines []lineItemfor _, item := range items {var product models.Producta.db.First(&product, item.ProductID)lines = append(lines, lineItem{Name: product.Name,Qty: item.Quantity,Price: item.UnitPrice,Subtotal: item.Subtotal,})}// Fetch customer name if availablecustomerName := "Walk-in Customer"if sale.CustomerID > 0 {var customer models.Customerif err := a.db.First(&customer, sale.CustomerID).Error; err == nil {customerName = customer.Name}}// Build the receiptvar b strings.Builderb.WriteString("========================================\n")b.WriteString(" POS SYSTEM RECEIPT \n")b.WriteString("========================================\n")b.WriteString(fmt.Sprintf("Date: %s\n", sale.CreatedAt.Format("2006-01-02 15:04")))b.WriteString(fmt.Sprintf("Sale #: %d\n", sale.ID))b.WriteString(fmt.Sprintf("Customer: %s\n", customerName))b.WriteString(fmt.Sprintf("Payment: %s\n", sale.PaymentMethod))b.WriteString("----------------------------------------\n")b.WriteString(fmt.Sprintf("%-20s %5s %10s\n", "Item", "Qty", "Amount"))b.WriteString("----------------------------------------\n")for _, line := range lines {b.WriteString(fmt.Sprintf("%-20s %5d %10.2f\n", line.Name, line.Qty, line.Subtotal))}b.WriteString("----------------------------------------\n")b.WriteString(fmt.Sprintf("%-26s %10.2f\n", "Subtotal:", sale.Total-sale.Tax))b.WriteString(fmt.Sprintf("%-26s %10.2f\n", "Tax:", sale.Tax))b.WriteString(fmt.Sprintf("%-26s %10.2f\n", "TOTAL:", sale.Total))b.WriteString("========================================\n")b.WriteString(" Thank you for your purchase! \n")b.WriteString("========================================\n")return b.String(), nil}
On the frontend, call GenerateReceipt after a successful sale and display it in a modal or print dialog:
import { GenerateReceipt } from "../../../wailsjs/go/main/App";// After successful ProcessSale call:const receipt = await GenerateReceipt(sale.id);// Display in a <pre> block or open the system print dialog
For thermal printers, the plain-text format works directly. Most receipt printers accept raw text via their driver. For PDF export, you can use a Go library like jung-kurt/gofpdf or signintech/gopdf to convert the receipt into a downloadable PDF.
Daily Reports
A store owner needs to see how the day went. Add a report method that aggregates sales, expenses, and profit for a given date. First, define the report struct in internal/types/types.go:
// DailyReport aggregates the financial data for a single day.type DailyReport struct {Date string `json:"date"`TotalSales float64 `json:"total_sales"`TotalTax float64 `json:"total_tax"`TotalExpenses float64 `json:"total_expenses"`NetProfit float64 `json:"net_profit"`TransactionCount int `json:"transaction_count"`TopProducts []TopProduct `json:"top_products"`}// TopProduct represents a best-selling product for the report.type TopProduct struct {ProductID uint `json:"product_id"`ProductName string `json:"product_name"`QuantitySold int `json:"quantity_sold"`Revenue float64 `json:"revenue"`}
Now add the report method to app.go. It runs three queries: one for sales totals, one for expenses, and one for the top-selling products:
// GetDailyReport returns aggregated sales, expenses, and profit// for a specific date (format: "2006-01-02").func (a *App) GetDailyReport(date string) (*types.DailyReport, error) {report := &types.DailyReport{Date: date}// Parse the date to get start and end boundariesstart, err := time.Parse("2006-01-02", date)if err != nil {return nil, fmt.Errorf("invalid date format: %w", err)}end := start.Add(24 * time.Hour)// Aggregate salesvar salesResult struct {Total float64Tax float64Count int64}a.db.Model(&models.Sale{}).Where("created_at >= ? AND created_at < ? AND status = ?", start, end, "completed").Select("COALESCE(SUM(total), 0) as total, COALESCE(SUM(tax), 0) as tax, COUNT(*) as count").Scan(&salesResult)report.TotalSales = salesResult.Totalreport.TotalTax = salesResult.Taxreport.TransactionCount = int(salesResult.Count)// Aggregate expensesvar expenseTotal float64a.db.Model(&models.Expense{}).Where("date >= ? AND date < ?", start, end).Select("COALESCE(SUM(amount), 0)").Scan(&expenseTotal)report.TotalExpenses = expenseTotalreport.NetProfit = report.TotalSales - report.TotalTax - report.TotalExpenses// Top 5 selling productsvar topProducts []types.TopProducta.db.Model(&models.SaleItem{}).Select("sale_items.product_id, products.name as product_name, SUM(sale_items.quantity) as quantity_sold, SUM(sale_items.subtotal) as revenue").Joins("JOIN products ON products.id = sale_items.product_id").Joins("JOIN sales ON sales.id = sale_items.sale_id").Where("sales.created_at >= ? AND sales.created_at < ? AND sales.status = ?", start, end, "completed").Group("sale_items.product_id, products.name").Order("quantity_sold DESC").Limit(5).Scan(&topProducts)report.TopProducts = topProductsreturn report, nil}
The frontend can call GetDailyReport("2026-03-04") and render the results in a dashboard with stats cards and a top-products table. The generated DataTable component works perfectly for displaying the top products list.
For weekly or monthly reports, follow the same pattern but adjust the date range calculation. You could addGetWeeklyReportandGetMonthlyReportmethods that callGetDailyReportacross a range and aggregate the results.
Build and Distribute
Your POS system is feature-complete. Compile it into a native executable:
This runs wails build under the hood and produces a single binary in build/bin/. The binary includes everything:
Build for a specific platform if you need cross-platform distribution:
For Windows, you can also build an NSIS installer for a professional installation experience:
Distribution Checklist
| Task | Details |
|---|---|
| App icon | Place a 1024x1024 PNG at build/appicon.png before building |
| Database location | SQLite creates app.db in the working directory by default |
| No internet required | Everything is local -- the app works fully offline |
| Data backup | Users can back up by copying the app.db file |
| Updates | Distribute new versions by replacing the executable |
| Windows WebView2 | Required on Windows -- included in Windows 11, installable on Windows 10 |
That is it. Hand the store owner a USB drive with the .exe file. They double-click it, and they have a full POS system with product management, barcode scanning, checkout, receipts, and daily reports. No installation wizard, no database setup, no cloud account required.
Complete Project Structure
Here is the final project structure after all five resources have been generated and the custom POS features added:
pos-system/├── main.go # Wails entry point├── app.go # App struct + ProcessSale, reports, receipts├── wails.json # Wails project configuration├── go.mod / go.sum├── internal/│ ├── config/│ │ └── config.go # App configuration│ ├── db/│ │ └── db.go # GORM setup + AutoMigrate (7 models)│ ├── models/│ │ ├── user.go # Auth user│ │ ├── product.go # Product catalog│ │ ├── customer.go # Customer records│ │ ├── sale.go # Sales transactions│ │ ├── sale_item.go # Sale line items│ │ └── expense.go # Business expenses│ ├── services/│ │ ├── auth.go # Authentication│ │ ├── product.go # Product CRUD + barcode lookup│ │ ├── customer.go # Customer CRUD│ │ ├── sale.go # Sale CRUD│ │ ├── sale_item.go # SaleItem CRUD│ │ └── expense.go # Expense CRUD│ └── types/│ └── types.go # Input structs + DailyReport├── frontend/│ └── src/│ ├── components/│ │ ├── sidebar.tsx # Navigation with all resources│ │ └── barcode-scanner.tsx # Barcode input component│ ├── routes/│ │ ├── __root.tsx # Root route│ │ ├── _layout.tsx # Auth guard + sidebar│ │ └── _layout/ # Protected routes│ │ ├── products.index.tsx│ │ ├── customers.index.tsx│ │ ├── sales.index.tsx│ │ ├── sale-items.index.tsx│ │ ├── expenses.index.tsx│ │ └── pos.index.tsx # Quick Sale checkout└── cmd/└── studio/└── main.go # GORM Studio (7 models registered)
Potential Enhancements
The POS system built in this tutorial is production-ready for a single store. Here are ideas for taking it further:
What's Next
Now that you have built a complete desktop application, explore these related pages: