BillWise: Offline-First Billing for a Drone Repair Business
An offline-first Windows billing app built with Tauri v2 and Rust for a real drone repair business — invoicing, inventory, and customers, no cloud required.
5 min read
- Role
- Solo developer — design to deployment
- Timeline
- Completed — in active production use
- Services
- Backend Engineering
- Stack
- Tauri v2, Rust, React, TypeScript, SQLite, Tailwind CSS
0
servers required
offline-first by design — all business data stays on the shop's machine
$0
monthly subscription
owned outright; no cloud dependency, no recurring cost
1
business in production
built for a real drone repair business and actively used
Business Problem
My cousin runs a drone repair business. The shop needed software to generate invoices, track customer payments, manage inventory, keep customer records, and view reports — the operational core of a small repair business.
Everything available off the shelf failed one of three ways: it demanded a subscription, it depended on cloud infrastructure the business didn't want its data living in, or it buried the five things the shop actually does under features it never would. The goal was software that matches the business workflow exactly — and works entirely offline.
Challenges
| Constraint | Consequence |
|---|---|
| Completely offline | No server to lean on — integrity must be guaranteed on one machine |
| A real business depends on it | Billing data has to be correct and stay correct over time |
| I was learning Rust | Every production decision was also a learning decision |
The biggest technical challenge was honest to name: learning Rust while building an application a real business would depend on. The ownership and borrowing model forced me to be explicit about who owns data at every point where the UI, the IPC boundary, and the database meet — exactly the places where billing bugs like to live. Getting comfortable with Tauri's IPC layer and driving SQLite from Rust were the two steepest parts of that curve.
My Role
Solo developer: requirements with the business owner, UI, the Rust core, the data model, and packaging the Windows desktop application. Every decision below is one I made and lived with.
Solution
BillWise is a Windows desktop application. A React + TypeScript interface (styled with Tailwind CSS) runs inside a Tauri v2 shell; all business logic lives in Rust; data persists to a local SQLite file. There is no server and no network dependency — the machine is the system.
Invoice snapshots
Invoices are stored as snapshots: when an invoice is generated, the customer details and line items are persisted as they were at that moment. If the customer's information changes later, previously generated invoices still show the original details.
An invoice is a historical document. It should never change because the present changed.
Joining invoices against live customer tables would silently rewrite history on every edit; snapshots make that class of bug impossible.

Architecture
Architecture
React pages (Dashboard · Customers · Products · Invoices · Payments · Reports · Settings · Backup)
│
▼ typed api wrapper (src/lib/tauri.ts)
Tauri invoke() IPC
│
▼
Rust core (src-tauri/src/main.rs) — 29 #[tauri::command] handlers
│ validate_invoice() · calculate_totals() · status_from_amounts()
▼
Mutex<Connection> → raw SQL via rusqlite
│
▼
billing.db — one SQLite file in the app data directoryDiagram description
The React and TypeScript interface renders inside a Tauri v2 window and never touches data directly. Each page — dashboard, customers, products, invoices, payments, reports, settings, backup — calls a typed wrapper around Tauri's invoke() IPC bridge. On the other side, 29 Rust command handlers own the business logic: validate_invoice, calculate_totals, and payment recalculation all run in Rust, never in the browser. Handlers acquire a mutex-guarded SQLite connection and issue raw SQL through rusqlite — no ORM, no middleware, no event system — against billing.db, a single file in the app's data directory. GitHub Actions builds the Windows installer. No network calls exist anywhere in the data path.
Engineering Decisions
The featured code path: transactional invoice creation
Creating an invoice is the one operation that must never half-happen.
This is create_invoice from src-tauri/src/main.rs — the most
architecturally significant code in the project, doing all of it inside a
single transaction:
#[tauri::command(rename_all = "camelCase")]
fn create_invoice(state: State<DbState>, input: CreateInvoiceInput) -> AppResult<InvoiceDetail> {
validate_invoice(&input)?;
let mut conn = lock_conn(&state)?;
let tx = conn.transaction().map_err(|err| err.to_string())?;
// 1. Atomic invoice number generation
let (prefix, next_number): (String, i64) = tx
.query_row(
"SELECT invoice_prefix, next_invoice_number FROM settings WHERE id=1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|err| err.to_string())?;
let invoice_number = format!("{}-{:06}", prefix, next_number);
// 2. Server-side total calculation (never trust the frontend)
let totals = calculate_totals(&input.items);
let initial_paid = input.initial_payment.as_ref()
.map(|payment| payment.amount).unwrap_or(0.0);
if initial_paid > totals.total_amount {
return Err("Advance payment cannot exceed invoice total".to_string());
}
let balance = totals.total_amount - initial_paid;
let status = status_from_amounts(initial_paid, balance);
// 3. Customer snapshot stored inline (immutable historical record)
tx.execute(
"INSERT INTO invoices (invoice_number, customer_id, customer_name_snapshot,
customer_phone_snapshot, customer_address_snapshot, customer_gstin_snapshot,
invoice_date, subtotal, discount_amount, tax_amount, total_amount,
paid_amount, balance_amount, status, notes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
invoice_number, input.customer_id,
input.customer_name, input.customer_phone,
input.customer_address, input.customer_gstin,
input.invoice_date, totals.subtotal,
totals.discount_amount, totals.tax_amount,
totals.total_amount, initial_paid, balance, status,
input.notes
],
).map_err(|err| err.to_string())?;
let invoice_id = tx.last_insert_rowid();
// 4. Product/item snapshots (prices frozen in time)
for item in &input.items {
let item_total = calculate_item(item);
tx.execute(
"INSERT INTO invoice_items (invoice_id, product_id, item_name,
description, quantity, unit, unit_price, discount_amount,
tax_rate, tax_amount, line_total)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
invoice_id, item.product_id, item.item_name,
item.description, item.quantity, item.unit,
item.unit_price, item.discount_amount,
item.tax_rate, item_total.tax_amount, item_total.line_total
],
).map_err(|err| err.to_string())?;
}
// 5. Optional initial payment
if let Some(payment) = input.initial_payment {
if payment.amount > 0.0 {
tx.execute(
"INSERT INTO payments (invoice_id, amount, payment_mode,
payment_date, notes) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
invoice_id, payment.amount, payment.payment_mode,
payment.payment_date,
payment.notes.or_else(|| Some("Advance payment".to_string()))
],
).map_err(|err| err.to_string())?;
}
}
// 6. Increment counter LAST (if anything above fails, number stays clean)
tx.execute(
"UPDATE settings SET next_invoice_number = next_invoice_number + 1,
updated_at=CURRENT_TIMESTAMP WHERE id=1", []
).map_err(|err| err.to_string())?;
tx.commit().map_err(|err| err.to_string())?;
get_invoice_by_id(&conn, invoice_id)
}This one function encodes the project's three most important architectural decisions:
- Transactional safety — if anything fails (validation, an INSERT, the counter increment), the entire transaction rolls back. No orphaned line items, no skipped invoice numbers.
- Snapshot immutability — customer name, phone, address, GSTIN, and item prices and rates are frozen into the invoice at creation time. If a customer changes their phone number a year later, the old invoice still shows the original — no JOINs needed to reconstruct historical state.
- Server-side authority —
calculate_totals()andvalidate_invoice()run in Rust, not in the browser. The frontend's live preview is a UX convenience; the Rust core is the single source of truth for all monetary calculations.
Technology Stack
React, TypeScript, and Tailwind CSS for an interface that could be shaped precisely around the shop's workflow. Tauri v2 for the desktop shell with a Rust core — Rust owns validation, business rules, and every database write. SQLite because an embedded, transactional, single-file database is exactly the right amount of database for a single-business desktop application.
Business Results
0
servers required
offline-first by design — all business data stays on the shop's machine
$0
monthly subscription
owned outright; no cloud dependency, no recurring cost
1
business in production
built for a real drone repair business and actively used
The application is complete and in active use by the business it was built for. The result this project optimized for was never a percentage — it was fit: software that matches how the shop actually works, owned outright, with no subscription and no dependency on anyone else's infrastructure being up.
Lessons Learned
The simplest architecture that solves the problem is a decision, not a compromise — nothing here needed a server, so there isn't one. Designing around the actual business workflow beats designing around a generic billing domain model. Data integrity is worth more than feature count in software a business trusts its money to. And offline-first, in a cloud-default era, is still a perfectly valid architecture — sometimes the best sync strategy is having nothing to sync.