Initial commit: Voiced invoice desktop app
Offline-first invoicing app for Test Vendor, 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>
@@ -0,0 +1,36 @@
|
|||||||
|
# Fallback CI: build the Windows installer on a Windows runner.
|
||||||
|
# Requires a Gitea Actions runner labelled `windows` on git.bongbetic.com.
|
||||||
|
# The primary build path is `npm run app:build:windows` (cross-compile via cargo-xwin).
|
||||||
|
name: Windows build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build the app
|
||||||
|
run: npm run app:build
|
||||||
|
|
||||||
|
- name: Upload installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: voiced-windows
|
||||||
|
path: src-tauri/target/release/bundle/nsis/*.exe
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
|
||||||
|
# Rust / Tauri
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
|
xwin/
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Local env
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Database (runtime data lives in the OS app-data dir, never here)
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Voiced
|
||||||
|
|
||||||
|
An offline-first desktop invoicing app for **Test Vendor**, built by **Bongbetic**.
|
||||||
|
|
||||||
|
Voiced turns a few details into a clean, GST-ready PDF invoice. It keeps a running
|
||||||
|
invoice-number series in a local database, remembers your vendor and bank details, and
|
||||||
|
exports print-ready PDFs — no account, no network required.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Automatic invoice numbering** — a persistent series (e.g. `INV/2026-001`) that keeps
|
||||||
|
counting up, with a one-click "start a new series" that resets the counter.
|
||||||
|
- **Guided first-run setup** — your name, address, contact, PAN, GSTIN, logo, signature and
|
||||||
|
bank details, pre-filled from your existing invoice and editable at any time.
|
||||||
|
- **Two line-item modes** — a fixed amount, or a rate × quantity where the unit can be per
|
||||||
|
second / minute / hour / session / unit (e.g. 10 sessions × ₹2,500).
|
||||||
|
- **GST support** — toggle taxes on/off and split as **CGST + SGST** (within state) or
|
||||||
|
**IGST** (inter-state), with live totals and Indian amount-in-words.
|
||||||
|
- **Signatures** — attach a PNG/JPEG per invoice, or omit it and the invoice notes that it is
|
||||||
|
computer-generated and needs no signature.
|
||||||
|
- **PDF export** — a print-ready A4 invoice styled to match the original `format.pdf`.
|
||||||
|
- **Saved clients**, invoice history with re-export, and light/dark (Carbon g10/g100) themes.
|
||||||
|
- **Input validation** for PAN, IFSC, GSTIN, email and phone.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
| --------- | ------------------------------------------------------------------- |
|
||||||
|
| Shell | [Tauri v2](https://v2.tauri.app/) (Rust core, WebView2 on Windows) |
|
||||||
|
| UI | React + TypeScript + [IBM Carbon Design System](https://carbondesignsystem.com/) (`@carbon/react`) |
|
||||||
|
| Fonts | IBM Plex, self-hosted (works offline) |
|
||||||
|
| Storage | SQLite via `rusqlite` (bundled) |
|
||||||
|
| PDF | `@react-pdf/renderer` with bundled IBM Plex |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+** and npm
|
||||||
|
- **Rust** (stable) via [rustup](https://rustup.rs/)
|
||||||
|
- Linux dev only: WebKitGTK 4.1, libsoup3, GTK3, librsvg, `pkg-config`, `clang`/`llvm`/`lld`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run app:dev # launches the desktop app with hot reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Other scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # type-check + build the web frontend
|
||||||
|
npm run app:build # package the app for the current platform
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building for Windows (cross-compile from Linux/macOS)
|
||||||
|
|
||||||
|
Tauri only supports the MSVC target. Cross-compiling uses [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin)
|
||||||
|
and produces an **NSIS installer** plus a portable `.exe`. A `.msi` still requires a real
|
||||||
|
Windows host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-time setup
|
||||||
|
rustup target add x86_64-pc-windows-msvc
|
||||||
|
cargo install --locked cargo-xwin
|
||||||
|
# plus NSIS (makensis), LLVM/LLD and clang on the host
|
||||||
|
|
||||||
|
npm run app:build:windows
|
||||||
|
```
|
||||||
|
|
||||||
|
Output lands in `src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/` and the
|
||||||
|
portable binary in the sibling `release/` folder.
|
||||||
|
|
||||||
|
## Where data lives
|
||||||
|
|
||||||
|
| What | Location |
|
||||||
|
| ------------------- | ------------------------------------------------------------- |
|
||||||
|
| Database | `<app-data>/voiced.db` — on Windows `%APPDATA%\com.bongbetic.voiced\` |
|
||||||
|
| Logos & signatures | `<app-data>/assets/` |
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ React + Carbon frontend
|
||||||
|
views/ Onboarding · NewInvoice · InvoiceHistory · SeriesSettings · AppSettings
|
||||||
|
components/ AppShell · InvoicePreview · ImagePicker
|
||||||
|
pdf/ InvoiceDocument (react-pdf template)
|
||||||
|
lib/ api · types · invoice · numberToWords · validators · format · pdf
|
||||||
|
src-tauri/ Rust backend (Tauri commands, SQLite schema, asset store)
|
||||||
|
public/brand/ Bongbetic + vendor brand assets
|
||||||
|
public/fonts/ IBM Plex (woff/woff2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
© Bongbetic. All rights reserved. This is a private application.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/brand/favicon-32.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>Voiced</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "voiced",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
||||||
|
"type": "module",
|
||||||
|
"author": "Bongbetic",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"app:dev": "tauri dev",
|
||||||
|
"app:build": "tauri build",
|
||||||
|
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@carbon/icons-react": "^11.89.0",
|
||||||
|
"@carbon/react": "^1.117.0",
|
||||||
|
"@carbon/styles": "^1.116.0",
|
||||||
|
"@ibm/plex": "^6.4.1",
|
||||||
|
"@react-pdf/renderer": "^4.9.0",
|
||||||
|
"@tauri-apps/api": "^2.12.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"react-is": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.12.1",
|
||||||
|
"@types/node": "^26.6.4",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"sass": "^1.105.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="383.692000pt" height="497.251424pt" viewBox="0 0 383.692000 497.251424"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
Created by potrace 1.16, written by Peter Selinger 2001-2019
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(2.765143,475.709540) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M805 4456 c-17 -13 -60 -15 -267 -16 l-248 0 -15 -24 c-14 -21 -16
|
||||||
|
-60 -14 -270 1 -163 6 -251 13 -262 10 -12 52 -16 258 -20 292 -6 293 -6 283
|
||||||
|
66 -9 62 1 67 120 64 50 -1 117 -2 151 -3 107 -2 96 119 95 -1067 -2 -998 -1
|
||||||
|
-1045 18 -1150 22 -124 38 -174 77 -241 55 -95 160 -217 212 -247 11 -6 44
|
||||||
|
-25 74 -43 77 -46 219 -83 317 -83 95 0 243 35 310 73 65 37 185 149 226 212
|
||||||
|
162 244 128 577 -82 790 -99 100 -229 157 -362 159 -67 1 -75 -1 -92 -24 -17
|
||||||
|
-23 -54 -172 -66 -266 -11 -84 32 -144 102 -144 38 0 100 -35 130 -74 57 -74
|
||||||
|
56 -163 -2 -227 -70 -77 -187 -84 -288 -17 -55 37 -87 81 -115 162 -19 56 -20
|
||||||
|
83 -20 471 0 332 3 415 14 430 12 17 31 18 309 17 265 -2 305 -4 379 -23 176
|
||||||
|
-44 411 -202 498 -333 14 -21 37 -52 51 -69 41 -51 114 -195 143 -282 91 -280
|
||||||
|
68 -628 -57 -864 -89 -167 -237 -343 -350 -415 -92 -59 -220 -126 -256 -136
|
||||||
|
-20 -5 -55 -16 -78 -24 -122 -45 -380 -60 -545 -31 -306 54 -544 214 -721 484
|
||||||
|
-68 102 -90 147 -121 240 -21 63 -42 144 -47 180 -4 36 -9 394 -9 794 -1 707
|
||||||
|
-2 729 -21 748 -17 17 -34 19 -212 19 -175 0 -196 -2 -210 -18 -16 -17 -17
|
||||||
|
-84 -17 -756 0 -697 1 -744 20 -859 12 -67 37 -169 57 -227 72 -212 256 -521
|
||||||
|
363 -610 24 -19 70 -60 102 -90 120 -110 283 -206 502 -294 193 -78 608 -108
|
||||||
|
831 -60 39 8 91 19 117 24 61 12 243 85 275 110 14 11 33 20 43 20 9 0 35 13
|
||||||
|
57 29 22 16 53 36 69 45 58 33 133 93 237 189 86 80 123 125 204 245 206 306
|
||||||
|
274 537 274 927 0 186 -3 214 -42 375 -39 161 -142 376 -243 508 -39 51 -84
|
||||||
|
110 -99 130 -45 59 -155 160 -239 219 -42 30 -80 63 -83 74 -5 14 8 36 49 83
|
||||||
|
71 81 110 153 143 268 24 84 25 101 21 227 -6 158 -20 211 -84 337 -57 110
|
||||||
|
-116 193 -186 261 -62 60 -233 176 -284 193 -20 7 -48 18 -62 26 -15 8 -34 14
|
||||||
|
-43 14 -9 0 -36 7 -60 15 -129 46 -152 47 -834 52 -576 4 -652 3 -670 -11z
|
||||||
|
m1445 -484 c114 -41 154 -65 225 -136 93 -91 117 -143 119 -251 2 -121 -28
|
||||||
|
-188 -121 -274 -61 -57 -82 -70 -150 -91 -68 -22 -96 -25 -223 -25 -126 0
|
||||||
|
-155 3 -219 24 -48 16 -90 38 -120 64 -54 46 -116 133 -132 185 -6 20 -15 138
|
||||||
|
-20 262 -7 197 -6 228 8 249 l15 24 282 -6 c246 -5 288 -8 336 -25z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
[package]
|
||||||
|
name = "voiced"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||||
|
authors = ["Bongbetic"]
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77"
|
||||||
|
|
||||||
|
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
|
||||||
|
[lib]
|
||||||
|
name = "voiced_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
thiserror = "2"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capabilities for the Voiced main window.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:default",
|
||||||
|
"opener:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 999 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 997 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 604 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 884 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
use crate::AppState;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
fn safe_kind(kind: &str) -> String {
|
||||||
|
kind.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_name(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extension_of(path: &str) -> String {
|
||||||
|
Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.to_lowercase())
|
||||||
|
.unwrap_or_else(|| "png".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mime_for(ext: &str) -> &'static str {
|
||||||
|
match ext {
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
_ => "image/png",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> {
|
||||||
|
let dir = state.data_dir.join("assets");
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> {
|
||||||
|
let dir = assets_dir(state)?;
|
||||||
|
let file = format!(
|
||||||
|
"{}-{}.{}",
|
||||||
|
safe_kind(kind),
|
||||||
|
uuid::Uuid::new_v4().simple(),
|
||||||
|
ext
|
||||||
|
);
|
||||||
|
let path = dir.join(file);
|
||||||
|
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
||||||
|
Ok(path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a user-picked file into the app's asset store and return the stored path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_asset(
|
||||||
|
state: State<AppState>,
|
||||||
|
source_path: String,
|
||||||
|
kind: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
|
||||||
|
let ext = extension_of(&source_path);
|
||||||
|
write_asset(&state, &kind, &ext, &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used when the frontend already holds the bytes (base64) instead of a path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_asset_bytes(
|
||||||
|
state: State<AppState>,
|
||||||
|
kind: String,
|
||||||
|
file_name: String,
|
||||||
|
data_base64: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let bytes = STANDARD
|
||||||
|
.decode(data_base64.as_bytes())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let ext = extension_of(&sanitize_name(&file_name));
|
||||||
|
write_asset(&state, &kind, &ext, &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_asset_data_uri(path: String) -> Result<String, String> {
|
||||||
|
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||||
|
let ext = extension_of(&path);
|
||||||
|
Ok(format!(
|
||||||
|
"data:{};base64,{}",
|
||||||
|
mime_for(&ext),
|
||||||
|
STANDARD.encode(bytes)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
|
||||||
|
let dir = assets_dir(&state)?;
|
||||||
|
let target = PathBuf::from(&path);
|
||||||
|
// Only allow deleting files inside the managed assets directory.
|
||||||
|
if target.starts_with(&dir) && target.exists() {
|
||||||
|
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
|
||||||
|
let bytes = STANDARD
|
||||||
|
.decode(data_base64.as_bytes())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(parent) = Path::new(&path).parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
std::fs::write(&path, bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use crate::models::Client;
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Row};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||||
|
Ok(Client {
|
||||||
|
id: Some(row.get(0)?),
|
||||||
|
name: row.get(1)?,
|
||||||
|
address: row.get(2)?,
|
||||||
|
gstin: row.get(3)?,
|
||||||
|
state_code: row.get(4)?,
|
||||||
|
po_number: row.get(5)?,
|
||||||
|
created_at: row.get(6)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, address, gstin, state_code, po_number, created_at
|
||||||
|
FROM clients ORDER BY name COLLATE NOCASE ASC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], map_client)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let id = match client.id {
|
||||||
|
Some(id) => {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
|
||||||
|
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![
|
||||||
|
client.name,
|
||||||
|
client.address,
|
||||||
|
client.gstin,
|
||||||
|
client.state_code,
|
||||||
|
client.po_number,
|
||||||
|
chrono::Utc::now().to_rfc3339()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
map_client,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
use crate::db::format_number;
|
||||||
|
use crate::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name,
|
||||||
|
client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount,
|
||||||
|
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||||
|
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at";
|
||||||
|
|
||||||
|
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||||
|
Ok(Invoice {
|
||||||
|
id: row.get(0)?,
|
||||||
|
number: row.get(1)?,
|
||||||
|
series_id: row.get(2)?,
|
||||||
|
invoice_date: row.get(3)?,
|
||||||
|
due_date: row.get(4)?,
|
||||||
|
client_id: row.get(5)?,
|
||||||
|
client_name: row.get(6)?,
|
||||||
|
client_address: row.get(7)?,
|
||||||
|
client_gstin: row.get(8)?,
|
||||||
|
po_number: row.get(9)?,
|
||||||
|
place_of_supply_state_code: row.get(10)?,
|
||||||
|
subtotal: row.get(11)?,
|
||||||
|
discount: row.get(12)?,
|
||||||
|
tax_type: row.get(13)?,
|
||||||
|
tax_rate: row.get(14)?,
|
||||||
|
cgst_amount: row.get(15)?,
|
||||||
|
sgst_amount: row.get(16)?,
|
||||||
|
igst_amount: row.get(17)?,
|
||||||
|
total: row.get(18)?,
|
||||||
|
amount_in_words: row.get(19)?,
|
||||||
|
bank_account_id: row.get(20)?,
|
||||||
|
bank_snapshot: row.get(21)?,
|
||||||
|
signature_path: row.get(22)?,
|
||||||
|
notes: row.get(23)?,
|
||||||
|
status: row.get(24)?,
|
||||||
|
created_at: row.get(25)?,
|
||||||
|
updated_at: row.get(26)?,
|
||||||
|
items: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, description, mode, rate, unit, quantity, amount, sort_order
|
||||||
|
FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![invoice_id], |row| {
|
||||||
|
Ok(InvoiceItem {
|
||||||
|
id: Some(row.get(0)?),
|
||||||
|
description: row.get(1)?,
|
||||||
|
mode: row.get(2)?,
|
||||||
|
rate: row.get(3)?,
|
||||||
|
unit: row.get(4)?,
|
||||||
|
quantity: row.get(5)?,
|
||||||
|
amount: row.get(6)?,
|
||||||
|
sort_order: row.get(7)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
|
||||||
|
let mut invoice = conn.query_row(
|
||||||
|
&format!("SELECT {} FROM invoices WHERE id = ?1", INVOICE_COLS),
|
||||||
|
params![id],
|
||||||
|
map_invoice,
|
||||||
|
)?;
|
||||||
|
invoice.items = fetch_items(conn, id)?;
|
||||||
|
Ok(invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let series: Option<(String, i64, i64)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT prefix, padding, next_number FROM invoice_series
|
||||||
|
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
match series {
|
||||||
|
Some((prefix, padding, next)) => Ok(format_number(&prefix, padding, next)),
|
||||||
|
None => Err("No active invoice series. Create one under Series.".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Invoice, String> {
|
||||||
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let series = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT id, prefix, padding, next_number FROM invoice_series
|
||||||
|
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
||||||
|
[],
|
||||||
|
|r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, i64>(0)?,
|
||||||
|
r.get::<_, String>(1)?,
|
||||||
|
r.get::<_, i64>(2)?,
|
||||||
|
r.get::<_, i64>(3)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
||||||
|
|
||||||
|
let (series_id, prefix, padding, next_number) = series;
|
||||||
|
let number = format_number(&prefix, padding, next_number);
|
||||||
|
|
||||||
|
// Persist the client first when the user asked to save a new one.
|
||||||
|
let client_id = match input.client_id {
|
||||||
|
Some(id) => Some(id),
|
||||||
|
None if input.save_client && !input.client_name.trim().is_empty() => {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![
|
||||||
|
input.client_name,
|
||||||
|
input.client_address,
|
||||||
|
input.client_gstin,
|
||||||
|
input.place_of_supply_state_code,
|
||||||
|
input.po_number,
|
||||||
|
chrono::Utc::now().to_rfc3339()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Some(tx.last_insert_rowid())
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
tx.execute(
|
||||||
|
r#"INSERT INTO invoices
|
||||||
|
(number, series_id, invoice_date, due_date, client_id, client_name, client_address,
|
||||||
|
client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type,
|
||||||
|
tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||||
|
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||||
|
?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#,
|
||||||
|
params![
|
||||||
|
number,
|
||||||
|
series_id,
|
||||||
|
input.invoice_date,
|
||||||
|
input.due_date,
|
||||||
|
client_id,
|
||||||
|
input.client_name,
|
||||||
|
input.client_address,
|
||||||
|
input.client_gstin,
|
||||||
|
input.po_number,
|
||||||
|
input.place_of_supply_state_code,
|
||||||
|
input.subtotal,
|
||||||
|
input.discount,
|
||||||
|
input.tax_type,
|
||||||
|
input.tax_rate,
|
||||||
|
input.cgst_amount,
|
||||||
|
input.sgst_amount,
|
||||||
|
input.igst_amount,
|
||||||
|
input.total,
|
||||||
|
input.amount_in_words,
|
||||||
|
input.bank_account_id,
|
||||||
|
input.bank_snapshot,
|
||||||
|
input.signature_path,
|
||||||
|
input.notes,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let invoice_id = tx.last_insert_rowid();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO invoice_items
|
||||||
|
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for (i, item) in input.items.iter().enumerate() {
|
||||||
|
stmt.execute(params![
|
||||||
|
invoice_id,
|
||||||
|
item.description,
|
||||||
|
item.mode,
|
||||||
|
item.rate,
|
||||||
|
item.unit,
|
||||||
|
item.quantity,
|
||||||
|
item.amount,
|
||||||
|
i as i64,
|
||||||
|
])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the serial for the next invoice in this series.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
|
||||||
|
params![series_id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
tx.commit().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
|
||||||
|
FROM invoices ORDER BY id DESC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(InvoiceSummary {
|
||||||
|
id: row.get(0)?,
|
||||||
|
number: row.get(1)?,
|
||||||
|
invoice_date: row.get(2)?,
|
||||||
|
due_date: row.get(3)?,
|
||||||
|
client_name: row.get(4)?,
|
||||||
|
total: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod assets;
|
||||||
|
pub mod clients;
|
||||||
|
pub mod invoice;
|
||||||
|
pub mod series;
|
||||||
|
pub mod settings;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use crate::db::format_number;
|
||||||
|
use crate::models::InvoiceSeries;
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Row};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> {
|
||||||
|
let prefix: String = row.get(1)?;
|
||||||
|
let padding: i64 = row.get(2)?;
|
||||||
|
let next_number: i64 = row.get(3)?;
|
||||||
|
Ok(InvoiceSeries {
|
||||||
|
id: row.get(0)?,
|
||||||
|
next_invoice_number: format_number(&prefix, padding, next_number),
|
||||||
|
prefix,
|
||||||
|
padding,
|
||||||
|
next_number,
|
||||||
|
is_active: row.get::<_, i64>(4)? != 0,
|
||||||
|
created_at: row.get(5)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIES_SELECT: &str =
|
||||||
|
"SELECT id, prefix, padding, next_number, is_active, created_at FROM invoice_series";
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_active_series(state: State<AppState>) -> Result<Option<InvoiceSeries>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!(
|
||||||
|
"{} WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
||||||
|
SERIES_SELECT
|
||||||
|
))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut rows = stmt.query_map([], map_series).map_err(|e| e.to_string())?;
|
||||||
|
match rows.next() {
|
||||||
|
Some(r) => Ok(Some(r.map_err(|e| e.to_string())?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_series(state: State<AppState>) -> Result<Vec<InvoiceSeries>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!("{} ORDER BY id DESC", SERIES_SELECT))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], map_series)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivates the current series and starts a fresh one, resetting the counter to 1.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_new_series(
|
||||||
|
state: State<AppState>,
|
||||||
|
prefix: String,
|
||||||
|
padding: i64,
|
||||||
|
) -> Result<InvoiceSeries, String> {
|
||||||
|
let prefix = prefix.trim().to_string();
|
||||||
|
if prefix.is_empty() {
|
||||||
|
return Err("Series prefix cannot be empty".into());
|
||||||
|
}
|
||||||
|
let padding = padding.clamp(1, 8);
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("UPDATE invoice_series SET is_active = 0", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
||||||
|
VALUES (?1, ?2, 1, 1, ?3)",
|
||||||
|
params![prefix, padding, chrono::Utc::now().to_rfc3339()],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
conn.query_row(
|
||||||
|
&format!("{} WHERE id = ?1", SERIES_SELECT),
|
||||||
|
params![id],
|
||||||
|
map_series,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use crate::models::{BankAccount, Settings};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Row};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||||
|
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
||||||
|
default_tax_type, payment_terms_days, currency, onboarded, theme";
|
||||||
|
|
||||||
|
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||||
|
Ok(Settings {
|
||||||
|
vendor_name: row.get(0)?,
|
||||||
|
vendor_address: row.get(1)?,
|
||||||
|
vendor_email: row.get(2)?,
|
||||||
|
vendor_phone: row.get(3)?,
|
||||||
|
vendor_pan: row.get(4)?,
|
||||||
|
vendor_gstin: row.get(5)?,
|
||||||
|
vendor_state_code: row.get(6)?,
|
||||||
|
logo_path: row.get(7)?,
|
||||||
|
signature_path: row.get(8)?,
|
||||||
|
default_bank_id: row.get(9)?,
|
||||||
|
default_tax_rate: row.get(10)?,
|
||||||
|
default_tax_type: row.get(11)?,
|
||||||
|
payment_terms_days: row.get(12)?,
|
||||||
|
currency: row.get(13)?,
|
||||||
|
onboarded: row.get::<_, i64>(14)? != 0,
|
||||||
|
theme: row.get(15)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_settings(state: State<AppState>) -> Result<Settings, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||||
|
[],
|
||||||
|
map_settings,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Settings, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.execute(
|
||||||
|
r#"UPDATE app_settings SET
|
||||||
|
vendor_name = ?1, vendor_address = ?2, vendor_email = ?3, vendor_phone = ?4,
|
||||||
|
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
|
||||||
|
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
||||||
|
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||||
|
onboarded = ?15, theme = ?16
|
||||||
|
WHERE id = 1"#,
|
||||||
|
params![
|
||||||
|
settings.vendor_name,
|
||||||
|
settings.vendor_address,
|
||||||
|
settings.vendor_email,
|
||||||
|
settings.vendor_phone,
|
||||||
|
settings.vendor_pan,
|
||||||
|
settings.vendor_gstin,
|
||||||
|
settings.vendor_state_code,
|
||||||
|
settings.logo_path,
|
||||||
|
settings.signature_path,
|
||||||
|
settings.default_bank_id,
|
||||||
|
settings.default_tax_rate,
|
||||||
|
settings.default_tax_type,
|
||||||
|
settings.payment_terms_days,
|
||||||
|
settings.currency,
|
||||||
|
if settings.onboarded { 1 } else { 0 },
|
||||||
|
settings.theme,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
drop(conn);
|
||||||
|
get_settings(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
|
||||||
|
Ok(BankAccount {
|
||||||
|
id: Some(row.get(0)?),
|
||||||
|
label: row.get(1)?,
|
||||||
|
bank_name: row.get(2)?,
|
||||||
|
account_name: row.get(3)?,
|
||||||
|
account_no: row.get(4)?,
|
||||||
|
branch: row.get(5)?,
|
||||||
|
ifsc: row.get(6)?,
|
||||||
|
is_default: row.get::<_, i64>(7)? != 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_banks(state: State<AppState>) -> Result<Vec<BankAccount>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
|
||||||
|
FROM bank_accounts ORDER BY is_default DESC, id ASC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], map_bank)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccount, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
let is_default = if bank.is_default { 1 } else { 0 };
|
||||||
|
|
||||||
|
let id = match bank.id {
|
||||||
|
Some(id) => {
|
||||||
|
conn.execute(
|
||||||
|
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
|
||||||
|
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
|
||||||
|
params![
|
||||||
|
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
||||||
|
bank.branch, bank.ifsc, is_default, id
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO bank_accounts
|
||||||
|
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
||||||
|
params![
|
||||||
|
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
||||||
|
bank.branch, bank.ifsc, is_default
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_default == 1 {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE bank_accounts SET is_default = 0 WHERE id <> ?1",
|
||||||
|
params![id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||||
|
params![id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
|
||||||
|
FROM bank_accounts WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
map_bank,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// The full schema. Every statement is idempotent so it doubles as the migration step.
|
||||||
|
const SCHEMA: &str = r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
vendor_name TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_address TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_email TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_phone TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_pan TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_gstin TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_state_code TEXT NOT NULL DEFAULT '29',
|
||||||
|
logo_path TEXT,
|
||||||
|
signature_path TEXT,
|
||||||
|
default_bank_id INTEGER,
|
||||||
|
default_tax_rate REAL NOT NULL DEFAULT 18,
|
||||||
|
default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst',
|
||||||
|
payment_terms_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'INR',
|
||||||
|
onboarded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
theme TEXT NOT NULL DEFAULT 'g10'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bank_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_name TEXT NOT NULL DEFAULT '',
|
||||||
|
account_name TEXT NOT NULL DEFAULT '',
|
||||||
|
account_no TEXT NOT NULL DEFAULT '',
|
||||||
|
branch TEXT NOT NULL DEFAULT '',
|
||||||
|
ifsc TEXT NOT NULL DEFAULT '',
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_series (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
padding INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
gstin TEXT NOT NULL DEFAULT '',
|
||||||
|
state_code TEXT NOT NULL DEFAULT '',
|
||||||
|
po_number TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
number TEXT NOT NULL UNIQUE,
|
||||||
|
series_id INTEGER,
|
||||||
|
invoice_date TEXT NOT NULL,
|
||||||
|
due_date TEXT NOT NULL DEFAULT '',
|
||||||
|
client_id INTEGER,
|
||||||
|
client_name TEXT NOT NULL DEFAULT '',
|
||||||
|
client_address TEXT NOT NULL DEFAULT '',
|
||||||
|
client_gstin TEXT NOT NULL DEFAULT '',
|
||||||
|
po_number TEXT NOT NULL DEFAULT '',
|
||||||
|
place_of_supply_state_code TEXT NOT NULL DEFAULT '',
|
||||||
|
subtotal REAL NOT NULL DEFAULT 0,
|
||||||
|
discount REAL NOT NULL DEFAULT 0,
|
||||||
|
tax_type TEXT NOT NULL DEFAULT 'none',
|
||||||
|
tax_rate REAL NOT NULL DEFAULT 0,
|
||||||
|
cgst_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
sgst_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
igst_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
total REAL NOT NULL DEFAULT 0,
|
||||||
|
amount_in_words TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_account_id INTEGER,
|
||||||
|
bank_snapshot TEXT NOT NULL DEFAULT '',
|
||||||
|
signature_path TEXT,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'issued',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
mode TEXT NOT NULL DEFAULT 'fixed',
|
||||||
|
rate REAL NOT NULL DEFAULT 0,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'unit',
|
||||||
|
quantity REAL NOT NULL DEFAULT 1,
|
||||||
|
amount REAL NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
|
conn.execute_batch(SCHEMA)?;
|
||||||
|
seed(&conn)?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert the first-run defaults the very first time the app is opened.
|
||||||
|
/// Vendor details are pre-filled from the owner's existing invoice.
|
||||||
|
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
let settings_count: i64 =
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
||||||
|
if settings_count == 0 {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO app_settings
|
||||||
|
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||||
|
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||||
|
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
||||||
|
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'cgst_sgst', 30, 'INR', 0, 'g10')"#,
|
||||||
|
params![
|
||||||
|
"Test Vendor",
|
||||||
|
"1 Test Road, Bangalore - 560001",
|
||||||
|
"vendor@example.com",
|
||||||
|
"9000000000",
|
||||||
|
"ABCDE1234F",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bank_count: i64 =
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?;
|
||||||
|
if bank_count == 0 {
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO bank_accounts
|
||||||
|
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#,
|
||||||
|
params![
|
||||||
|
"Primary",
|
||||||
|
"State Bank of India",
|
||||||
|
"Test Vendor",
|
||||||
|
"000000000000",
|
||||||
|
"000001",
|
||||||
|
"TEST0000001",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let bank_id = conn.last_insert_rowid();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
||||||
|
params![bank_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let series_count: i64 =
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM invoice_series", [], |r| r.get(0))?;
|
||||||
|
if series_count == 0 {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let year = chrono::Local::now().format("%Y").to_string();
|
||||||
|
conn.execute(
|
||||||
|
r#"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
||||||
|
VALUES (?1, 3, 1, 1, ?2)"#,
|
||||||
|
params![format!("INV/{}-", year), now],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
||||||
|
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||||
|
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Mutex<Connection>,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.expect("could not resolve the application data directory");
|
||||||
|
std::fs::create_dir_all(&data_dir).expect("could not create the application data directory");
|
||||||
|
|
||||||
|
let conn = db::open(&data_dir.join("voiced.db")).expect("could not open the database");
|
||||||
|
app.manage(AppState {
|
||||||
|
db: Mutex::new(conn),
|
||||||
|
data_dir,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::settings::get_settings,
|
||||||
|
commands::settings::save_settings,
|
||||||
|
commands::settings::list_banks,
|
||||||
|
commands::settings::save_bank,
|
||||||
|
commands::settings::delete_bank,
|
||||||
|
commands::series::get_active_series,
|
||||||
|
commands::series::list_series,
|
||||||
|
commands::series::start_new_series,
|
||||||
|
commands::clients::list_clients,
|
||||||
|
commands::clients::save_client,
|
||||||
|
commands::clients::delete_client,
|
||||||
|
commands::invoice::peek_next_invoice_number,
|
||||||
|
commands::invoice::create_invoice,
|
||||||
|
commands::invoice::list_invoices,
|
||||||
|
commands::invoice::get_invoice,
|
||||||
|
commands::invoice::delete_invoice,
|
||||||
|
commands::assets::import_asset,
|
||||||
|
commands::assets::save_asset_bytes,
|
||||||
|
commands::assets::read_asset_data_uri,
|
||||||
|
commands::assets::remove_asset,
|
||||||
|
commands::assets::save_binary_file,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running the Voiced application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents an extra console window on Windows in release builds.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
voiced_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Settings {
|
||||||
|
pub vendor_name: String,
|
||||||
|
pub vendor_address: String,
|
||||||
|
pub vendor_email: String,
|
||||||
|
pub vendor_phone: String,
|
||||||
|
pub vendor_pan: String,
|
||||||
|
pub vendor_gstin: String,
|
||||||
|
pub vendor_state_code: String,
|
||||||
|
pub logo_path: Option<String>,
|
||||||
|
pub signature_path: Option<String>,
|
||||||
|
pub default_bank_id: Option<i64>,
|
||||||
|
pub default_tax_rate: f64,
|
||||||
|
pub default_tax_type: String,
|
||||||
|
pub payment_terms_days: i64,
|
||||||
|
pub currency: String,
|
||||||
|
pub onboarded: bool,
|
||||||
|
pub theme: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BankAccount {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bank_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_no: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub branch: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ifsc: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_default: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceSeries {
|
||||||
|
pub id: i64,
|
||||||
|
pub prefix: String,
|
||||||
|
pub padding: i64,
|
||||||
|
pub next_number: i64,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
/// The number the next invoice created under this series would receive.
|
||||||
|
pub next_invoice_number: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Client {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gstin: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub state_code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub po_number: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceItem {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
/// "fixed" = a single amount; "rate" = rate x quantity.
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate: f64,
|
||||||
|
/// second | minute | hour | session | unit
|
||||||
|
#[serde(default = "default_unit")]
|
||||||
|
pub unit: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quantity: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub amount: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort_order: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mode() -> String {
|
||||||
|
"fixed".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_unit() -> String {
|
||||||
|
"unit".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceInput {
|
||||||
|
pub invoice_date: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub due_date: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_gstin: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub po_number: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub place_of_supply_state_code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subtotal: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub discount: f64,
|
||||||
|
#[serde(default = "default_tax_type")]
|
||||||
|
pub tax_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tax_rate: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cgst_amount: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sgst_amount: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub igst_amount: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub amount_in_words: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bank_account_id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bank_snapshot: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub signature_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub save_client: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: Vec<InvoiceItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tax_type() -> String {
|
||||||
|
"none".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Invoice {
|
||||||
|
pub id: i64,
|
||||||
|
pub number: String,
|
||||||
|
pub series_id: Option<i64>,
|
||||||
|
pub invoice_date: String,
|
||||||
|
pub due_date: String,
|
||||||
|
pub client_id: Option<i64>,
|
||||||
|
pub client_name: String,
|
||||||
|
pub client_address: String,
|
||||||
|
pub client_gstin: String,
|
||||||
|
pub po_number: String,
|
||||||
|
pub place_of_supply_state_code: String,
|
||||||
|
pub subtotal: f64,
|
||||||
|
pub discount: f64,
|
||||||
|
pub tax_type: String,
|
||||||
|
pub tax_rate: f64,
|
||||||
|
pub cgst_amount: f64,
|
||||||
|
pub sgst_amount: f64,
|
||||||
|
pub igst_amount: f64,
|
||||||
|
pub total: f64,
|
||||||
|
pub amount_in_words: String,
|
||||||
|
pub bank_account_id: Option<i64>,
|
||||||
|
pub bank_snapshot: String,
|
||||||
|
pub signature_path: Option<String>,
|
||||||
|
pub notes: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub items: Vec<InvoiceItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceSummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub number: String,
|
||||||
|
pub invoice_date: String,
|
||||||
|
pub due_date: String,
|
||||||
|
pub client_name: String,
|
||||||
|
pub total: f64,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Voiced",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.bongbetic.voiced",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Voiced",
|
||||||
|
"width": 1360,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 1024,
|
||||||
|
"minHeight": 720,
|
||||||
|
"resizable": true,
|
||||||
|
"center": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis"],
|
||||||
|
"publisher": "Bongbetic",
|
||||||
|
"copyright": "© Bongbetic",
|
||||||
|
"category": "Business",
|
||||||
|
"shortDescription": "Offline invoicing by Bongbetic",
|
||||||
|
"longDescription": "Voiced is an offline-first invoice generator with GST support, signature capture and PDF export.",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"webviewInstallMode": {
|
||||||
|
"type": "downloadBootstrapper"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"installMode": "currentUser"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||