Skip to main content
Owais
product

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

ConstraintConsequence
Completely offlineNo server to lean on — integrity must be guaranteed on one machine
A real business depends on itBilling data has to be correct and stay correct over time
I was learning RustEvery 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.

Five screens of the BillWise desktop app: a dashboard with today's totals and recent invoices, the create-invoice form with customer and item snapshots and backend-calculated totals, business settings with invoice numbering, a reports summary with a sales chart, and backup & restore.
The five core screens — dashboard, invoice creation, settings, reports, and backup & restore. Anonymized: demo data, business identity removed.

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 directory
Everything runs on one machine: React pages call a typed wrapper around Tauri's invoke() IPC; 29 Rust command handlers own validation, calculation, and every write to a single SQLite file.
Diagram 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

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() and validate_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.

What I'd Improve Today

Facing something similar?

Next case study

Want results like this on your system?