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:
@@ -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 }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user