Initial commit: Voiced invoice desktop app

Offline-first invoicing app for Arun P, built by Bongbetic.

- Tauri v2 (Rust) + React/TypeScript + IBM Carbon Design System UI
- SQLite (rusqlite) storage with a persistent invoice-number series
- Guided first-run setup, pre-filled vendor/bank details
- Fixed-amount or rate x quantity line items (per sec/min/hour/session/unit)
- GST as CGST+SGST or IGST, Indian amount-in-words
- Optional signature, with a computer-generated note as fallback
- Print-ready PDF export via @react-pdf/renderer
- Windows cross-compile via cargo-xwin (NSIS installer)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-10-03 23:07:51 +05:30
co-authored by CommandCodeBot
commit 3af9f3f66f
125 changed files with 13660 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import { invoke } from "@tauri-apps/api/core";
import type {
BankAccount,
Client,
Invoice,
InvoiceInput,
InvoiceSeries,
InvoiceSummary,
Settings,
} from "./types";
export const api = {
getSettings: () => invoke<Settings>("get_settings"),
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
listBanks: () => invoke<BankAccount[]>("list_banks"),
saveBank: (bank: BankAccount) => invoke<BankAccount>("save_bank", { bank }),
deleteBank: (id: number) => invoke<void>("delete_bank", { id }),
getActiveSeries: () => invoke<InvoiceSeries | null>("get_active_series"),
listSeries: () => invoke<InvoiceSeries[]>("list_series"),
startNewSeries: (prefix: string, padding: number) =>
invoke<InvoiceSeries>("start_new_series", { prefix, padding }),
listClients: () => invoke<Client[]>("list_clients"),
saveClient: (client: Client) => invoke<Client>("save_client", { client }),
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
createInvoice: (input: InvoiceInput) => invoke<Invoice>("create_invoice", { input }),
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
deleteInvoice: (id: number) => invoke<void>("delete_invoice", { id }),
importAsset: (sourcePath: string, kind: string) =>
invoke<string>("import_asset", { sourcePath, kind }),
saveAssetBytes: (kind: string, fileName: string, dataBase64: string) =>
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
saveBinaryFile: (path: string, dataBase64: string) =>
invoke<void>("save_binary_file", { path, dataBase64 }),
};
+42
View File
@@ -0,0 +1,42 @@
const inr = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/** Indian-grouped number, e.g. 25000 -> "25,000.00" */
export function formatAmount(amount: number): string {
const value = Number.isFinite(amount) ? amount : 0;
return inr.format(value);
}
/** Currency string with the rupee symbol, e.g. "₹25,000.00" */
export function formatCurrency(amount: number, symbol = "₹"): string {
return `${symbol}${formatAmount(amount)}`;
}
/** "2024-06-13" -> "13 Jun 2024" (falls back to the raw value if unparseable). */
export function formatDate(iso: string): string {
if (!iso) return "";
const date = new Date(`${iso}T00:00:00`);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
}
/** Today as an ISO date (YYYY-MM-DD) in local time. */
export function todayIso(): string {
const now = new Date();
const offset = now.getTimezoneOffset();
return new Date(now.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
/** ISO date `days` from `iso` (defaults to today). */
export function addDays(iso: string, days: number): string {
const base = iso ? new Date(`${iso}T00:00:00`) : new Date();
base.setDate(base.getDate() + (Number.isFinite(days) ? days : 0));
const offset = base.getTimezoneOffset();
return new Date(base.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
export function round2(n: number): number {
return Math.round((Number.isFinite(n) ? n : 0) * 100) / 100;
}
+54
View File
@@ -0,0 +1,54 @@
import { round2 } from "./format";
import type { InvoiceItem, TaxType } from "./types";
/** Amount for a single line: a fixed total, or rate × quantity. */
export function computeLineAmount(item: InvoiceItem): number {
if (item.mode === "rate") {
return round2((item.rate || 0) * (item.quantity || 0));
}
return round2(item.amount || 0);
}
export interface Totals {
subtotal: number;
discount: number;
taxable: number;
cgst: number;
sgst: number;
igst: number;
taxTotal: number;
total: number;
}
export function computeTotals(
items: InvoiceItem[],
discount: number,
taxType: TaxType,
taxRate: number,
): Totals {
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
const taxable = round2(subtotal - discountValue);
let cgst = 0;
let sgst = 0;
let igst = 0;
if (taxType === "cgst_sgst") {
cgst = round2((taxable * (taxRate / 2)) / 100);
sgst = round2((taxable * (taxRate / 2)) / 100);
} else if (taxType === "igst") {
igst = round2((taxable * taxRate) / 100);
}
const taxTotal = round2(cgst + sgst + igst);
return {
subtotal,
discount: discountValue,
taxable,
cgst,
sgst,
igst,
taxTotal,
total: round2(taxable + taxTotal),
};
}
+75
View File
@@ -0,0 +1,75 @@
const ONES = [
"",
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Ten",
"Eleven",
"Twelve",
"Thirteen",
"Fourteen",
"Fifteen",
"Sixteen",
"Seventeen",
"Eighteen",
"Nineteen",
];
const TENS = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"];
function twoDigits(n: number): string {
if (n < 20) return ONES[n];
const tens = Math.floor(n / 10);
const ones = n % 10;
return TENS[tens] + (ones ? " " + ONES[ones] : "");
}
function threeDigits(n: number): string {
const hundreds = Math.floor(n / 100);
const rest = n % 100;
let out = "";
if (hundreds > 0) out += ONES[hundreds] + " Hundred";
if (rest > 0) out += (out ? " " : "") + twoDigits(rest);
return out;
}
/** Whole-number to words using the Indian numbering system (thousand, lakh, crore). */
function wholeToWords(n: number): string {
if (n === 0) return "Zero";
const parts: string[] = [];
const crore = Math.floor(n / 10000000);
let rest = n % 10000000;
if (crore > 0) parts.push(`${wholeToWords(crore)} Crore`);
const lakh = Math.floor(rest / 100000);
rest %= 100000;
if (lakh > 0) parts.push(`${twoDigits(lakh)} Lakh`);
const thousand = Math.floor(rest / 1000);
rest %= 1000;
if (thousand > 0) parts.push(`${twoDigits(thousand)} Thousand`);
if (rest > 0) parts.push(threeDigits(rest));
return parts.join(" ");
}
/** e.g. 25000 -> "Indian Rupees Twenty Five Thousand Only" */
export function amountInWords(amount: number, currency = "Indian Rupees"): string {
const value = Number.isFinite(amount) ? Math.abs(amount) : 0;
const rupees = Math.floor(value);
const paise = Math.round((value - rupees) * 100);
let out = "";
if (currency) out += `${currency} `;
out += wholeToWords(rupees);
if (paise > 0) out += ` and ${twoDigits(paise)} Paise`;
return `${out} Only`;
}
+94
View File
@@ -0,0 +1,94 @@
import { pdf } from "@react-pdf/renderer";
import { save } from "@tauri-apps/plugin-dialog";
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
import { api } from "./api";
import type { BankAccount, Invoice, Settings } from "./types";
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result ?? "");
resolve(result.substring(result.indexOf(",") + 1));
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
export async function buildPdfProps(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<InvoicePdfProps> {
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath)
: null;
let resolvedBank = bank;
if (!resolvedBank && invoice.bankSnapshot) {
try {
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
} catch {
resolvedBank = null;
}
}
return {
vendor: settings,
bank: resolvedBank,
logoDataUri,
signatureDataUri,
number: invoice.number,
invoiceDate: invoice.invoiceDate,
dueDate: invoice.dueDate,
clientName: invoice.clientName,
clientAddress: invoice.clientAddress,
clientGstin: invoice.clientGstin,
poNumber: invoice.poNumber,
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
items: invoice.items,
subtotal: invoice.subtotal,
discount: invoice.discount,
taxType: invoice.taxType,
taxRate: invoice.taxRate,
cgst: invoice.cgstAmount,
sgst: invoice.sgstAmount,
igst: invoice.igstAmount,
total: invoice.total,
amountInWords: invoice.amountInWords,
notes: invoice.notes,
};
}
/** Render props straight to a PDF blob. */
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> {
return pdf(<InvoiceDocument {...props} />).toBlob();
}
/** Render a PDF and prompt the user for a save location. */
export async function exportPdfFromProps(
props: InvoicePdfProps,
fileName: string,
): Promise<string | null> {
const blob = await renderPdfBlob(props);
const dataBase64 = await blobToBase64(blob);
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
const path = await save({
defaultPath: `${suggested}.pdf`,
filters: [{ name: "PDF document", extensions: ["pdf"] }],
});
if (!path) return null;
await api.saveBinaryFile(path, dataBase64);
return path;
}
export async function exportInvoicePdf(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<string | null> {
const props = await buildPdfProps(invoice, settings, bank);
return exportPdfFromProps(props, invoice.number);
}
+168
View File
@@ -0,0 +1,168 @@
export type TaxType = "none" | "cgst_sgst" | "igst";
export type LineMode = "fixed" | "rate";
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
export interface Settings {
vendorName: string;
vendorAddress: string;
vendorEmail: string;
vendorPhone: string;
vendorPan: string;
vendorGstin: string;
vendorStateCode: string;
logoPath: string | null;
signaturePath: string | null;
defaultBankId: number | null;
defaultTaxRate: number;
defaultTaxType: TaxType;
paymentTermsDays: number;
currency: string;
onboarded: boolean;
theme: string;
}
export interface BankAccount {
id: number | null;
label: string;
bankName: string;
accountName: string;
accountNo: string;
branch: string;
ifsc: string;
isDefault: boolean;
}
export interface InvoiceSeries {
id: number;
prefix: string;
padding: number;
nextNumber: number;
isActive: boolean;
createdAt: string;
nextInvoiceNumber: string;
}
export interface Client {
id: number | null;
name: string;
address: string;
gstin: string;
stateCode: string;
poNumber: string;
createdAt: string;
}
export interface InvoiceItem {
id?: number | null;
description: string;
mode: LineMode;
rate: number;
unit: LineUnit;
quantity: number;
amount: number;
sortOrder: number;
}
export interface InvoiceInput {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
subtotal: number;
discount: number;
taxType: TaxType;
taxRate: number;
cgstAmount: number;
sgstAmount: number;
igstAmount: number;
total: number;
amountInWords: string;
bankAccountId: number | null;
bankSnapshot: string;
signaturePath: string | null;
notes: string;
saveClient: boolean;
items: InvoiceItem[];
}
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
id: number;
number: string;
seriesId: number | null;
status: string;
createdAt: string;
updatedAt: string;
items: InvoiceItem[];
}
export interface InvoiceSummary {
id: number;
number: string;
invoiceDate: string;
dueDate: string;
clientName: string;
total: number;
status: string;
createdAt: string;
}
export const EMPTY_ITEM: InvoiceItem = {
description: "",
mode: "fixed",
rate: 0,
unit: "unit",
quantity: 1,
amount: 0,
sortOrder: 0,
};
export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "01", name: "Jammu & Kashmir" },
{ code: "02", name: "Himachal Pradesh" },
{ code: "03", name: "Punjab" },
{ code: "04", name: "Chandigarh" },
{ code: "05", name: "Uttarakhand" },
{ code: "06", name: "Haryana" },
{ code: "07", name: "Delhi" },
{ code: "08", name: "Rajasthan" },
{ code: "09", name: "Uttar Pradesh" },
{ code: "10", name: "Bihar" },
{ code: "11", name: "Sikkim" },
{ code: "12", name: "Arunachal Pradesh" },
{ code: "13", name: "Nagaland" },
{ code: "14", name: "Manipur" },
{ code: "15", name: "Mizoram" },
{ code: "16", name: "Tripura" },
{ code: "17", name: "Meghalaya" },
{ code: "18", name: "Assam" },
{ code: "19", name: "West Bengal" },
{ code: "20", name: "Jharkhand" },
{ code: "21", name: "Odisha" },
{ code: "22", name: "Chhattisgarh" },
{ code: "23", name: "Madhya Pradesh" },
{ code: "24", name: "Gujarat" },
{ code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" },
{ code: "27", name: "Maharashtra" },
{ code: "29", name: "Karnataka" },
{ code: "30", name: "Goa" },
{ code: "31", name: "Lakshadweep" },
{ code: "32", name: "Kerala" },
{ code: "33", name: "Tamil Nadu" },
{ code: "34", name: "Puducherry" },
{ code: "35", name: "Andaman & Nicobar Islands" },
{ code: "36", name: "Telangana" },
{ code: "37", name: "Andhra Pradesh" },
{ code: "38", name: "Ladakh" },
];
export const UNIT_LABELS: Record<LineUnit, string> = {
second: "per second",
minute: "per minute",
hour: "per hour",
session: "per session",
unit: "per unit",
};
+42
View File
@@ -0,0 +1,42 @@
const PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
const IFSC_RE = /^[A-Z]{4}0[A-Z0-9]{6}$/;
const GSTIN_RE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[0-9+\-()\s]{6,16}$/;
const norm = (v: string) => v.trim().toUpperCase();
export function panError(value: string): string | undefined {
if (!value.trim()) return undefined;
return PAN_RE.test(norm(value)) ? undefined : "Enter a valid PAN, e.g. CPOPP7388K";
}
export function ifscError(value: string): string | undefined {
if (!value.trim()) return undefined;
return IFSC_RE.test(norm(value)) ? undefined : "Enter a valid IFSC, e.g. SBIN0017060";
}
export function gstinError(value: string): string | undefined {
const v = norm(value);
if (!v || v === "NA") return undefined;
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
}
export function emailError(value: string): string | undefined {
if (!value.trim()) return undefined;
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
}
export function phoneError(value: string): string | undefined {
if (!value.trim()) return undefined;
return PHONE_RE.test(value.trim()) ? undefined : "Enter a valid phone number";
}
export function accountError(value: string): string | undefined {
if (!value.trim()) return undefined;
return /^[0-9]{6,20}$/.test(value.trim()) ? undefined : "Account number should be 6–20 digits";
}
export const isPan = (v: string) => PAN_RE.test(norm(v));
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));