diff --git a/README.md b/README.md index 687ac2a..7a7026f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Voiced -An offline-first GST invoicing desktop app for **Test Vendor**, built by **Bongbetic**. +An offline-first GST invoicing desktop app built by **Bongbetic**. Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a local database, remembers your vendor, client and bank details, and works without an account or a network diff --git a/docs/templates/07-crimson-grid.md b/docs/templates/07-crimson-grid.md index 1dffacf..dc82d8a 100644 --- a/docs/templates/07-crimson-grid.md +++ b/docs/templates/07-crimson-grid.md @@ -74,7 +74,7 @@ Everything can be built with View backgroundColor and borders. Svg is only neede ## Mapping to Voiced fields -- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. public/brand/sample-wordmark-logo.png), hide the text lockup. +- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. src/pdf/testing/fixtures/sample-wordmark-logo.png), hide the text lockup. - Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned). - 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'. - Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'. diff --git a/scripts/logo/contact-sheet.mjs b/scripts/logo/contact-sheet.mjs index 19db500..a237145 100644 --- a/scripts/logo/contact-sheet.mjs +++ b/scripts/logo/contact-sheet.mjs @@ -116,7 +116,7 @@ async function main() { const { computeLogoPlacement, slotFor } = await loadPlacement(); mkdirSync(OUT_DIR, { recursive: true }); - const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/sample-wordmark-logo.png"))); + const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png"))); const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height); sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0); const syn = syntheticLogos(createCanvas); diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index 3d2321f..1d655d2 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -940,7 +940,7 @@ mod tests { let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap(); let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap(); let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap(); - assert_eq!(bank["bankName"], "State Bank of India"); + assert_eq!(bank["bankName"], "Test Bank"); let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err(); assert!(err.contains("bank account"), "{err}"); } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 17289b6..0640f00 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -436,8 +436,8 @@ fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> { } /// Insert the first-run defaults the very first time the app is opened. -/// Vendor details are pre-filled from the owner's existing invoice. The vendor -/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax). +/// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard +/// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise. fn seed(conn: &Connection) -> rusqlite::Result<()> { let settings_count: i64 = conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?; @@ -447,38 +447,8 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> { (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, 'none', 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], + VALUES (1, '', '', '', '', '', '', '', 18, 'none', 30, 'INR', 0, 'g10')"#, + [], )?; } @@ -497,6 +467,25 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } +/// Sample vendor and bank for command-logic unit tests (production seeds none). Fake values only. +#[cfg(test)] +pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + r#"UPDATE app_settings SET vendor_name = 'Test Vendor', vendor_address = '1 Test Road, Bangalore - 560001', + vendor_email = 'vendor@example.com', vendor_phone = '9000000000', vendor_pan = 'ABCDE1234F', + vendor_state_code = '29' WHERE id = 1"#, + [], + )?; + conn.execute( + r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default) + VALUES ('Primary', 'Test Bank', 'Test Vendor', '000000000000', '000001', 'TEST0000001', 1)"#, + [], + )?; + let bank_id = conn.last_insert_rowid(); + conn.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id])?; + Ok(()) +} + /// A migrated, seeded in-memory database for unit tests of the command logic. #[cfg(test)] pub fn open_in_memory() -> Result { @@ -504,6 +493,7 @@ pub fn open_in_memory() -> Result { conn.pragma_update(None, "foreign_keys", "ON")?; migrations().to_latest(&mut conn)?; seed(&conn)?; + seed_test_fixture(&conn)?; Ok(conn) } @@ -726,7 +716,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); .unwrap(); assert_eq!(tax_type, "none"); assert_eq!(gst_reg, "unregistered"); - assert_eq!(name, "Test Vendor"); + assert_eq!(name, ""); assert!(backups_in(&backups).is_empty()); } diff --git a/src-tauri/src/logo.rs b/src-tauri/src/logo.rs index c433152..ae4fd90 100644 --- a/src-tauri/src/logo.rs +++ b/src-tauri/src/logo.rs @@ -213,7 +213,7 @@ fn clean_alpha(img: &mut RgbaImage) { } /// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder -/// noise of +-1 otherwise makes flat colours incompressible: the Test Vendor logo drops from 149 KB to 75 KB. +/// noise of +-1 otherwise makes flat colours incompressible: a sample wordmark logo drops from 149 KB to 75 KB. fn quantize_colour(img: &mut RgbaImage) { for p in img.pixels_mut() { for c in 0..3 { @@ -535,7 +535,7 @@ mod tests { #[test] fn sample_logo_is_trimmed_and_small() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/pdf/testing/fixtures/sample-wordmark-logo.png"); let bytes = std::fs::read(path).unwrap(); let out = process_logo(&bytes).unwrap(); let m = &out.metrics; diff --git a/src/components/DataBackupPanel.tsx b/src/components/DataBackupPanel.tsx index 51804c3..5f5490f 100644 --- a/src/components/DataBackupPanel.tsx +++ b/src/components/DataBackupPanel.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react"; -import { open, save } from "@tauri-apps/plugin-dialog"; +import { confirm, open, save } from "@tauri-apps/plugin-dialog"; import { api } from "../lib/api"; +import type { Settings } from "../lib/types"; import { backupFileName, ensureZipExtension, @@ -20,8 +21,15 @@ type RestoreStep = "confirm" | "staging" | "ready"; const joinPath = (dir: string, name: string) => !dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`; -/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */ -export default function DataBackupPanel() { +interface DataBackupPanelProps { + /** The saved settings; "Run setup again" writes them back with the wizard flag cleared. */ + settings: Settings; + /** Called after the flag is cleared, so the app reloads settings and shows the wizard. */ + onRerunSetup: () => void; +} + +/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */ +export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) { const toast = useToast(); const [status, setStatus] = useState(null); const [backingUp, setBackingUp] = useState(false); @@ -32,6 +40,20 @@ export default function DataBackupPanel() { const [restarting, setRestarting] = useState(false); useReturnFocus(restorePath !== null); + const rerunSetup = async () => { + const ok = await confirm( + "Open the setup wizard again? Your invoices, clients and backups are kept, and the wizard starts with your current details.", + { title: "Run setup again", kind: "warning" }, + ); + if (!ok) return; + try { + await api.saveSettings({ ...settings, onboarded: false }); + onRerunSetup(); + } catch (e) { + toast.error("Could not start setup", String(e)); + } + }; + const refresh = useCallback(async () => { try { setStatus(await api.getBackupStatus()); @@ -229,6 +251,19 @@ export default function DataBackupPanel() { + +

Setup wizard

+

+ Run the first-time setup again to change your business details, logo, bank account and numbering in one go. Existing + invoices, clients and backups are not touched, and invoice numbering carries on unless you change the prefix. +

+
+ +
+
+ { @@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise { + const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => { s = await api.getSettings(); - const banks = await api.listBanks(); + let banks = await api.listBanks(); + if (banks.length === 0) { + const saved = await api.saveBank({ + id: null, + label: "Primary", + bankName: "Self-test Bank", + accountName: "Self-test Vendor", + accountNo: "000000000000", + branch: "000001", + ifsc: "TEST0000001", + isDefault: true, + }); + s = await api.saveSettings({ + ...s, + vendorName: s.vendorName || "Self-test Vendor", + vendorAddress: s.vendorAddress || "1 Test Road, Bengaluru - 560001", + vendorStateCode: s.vendorStateCode || "29", + defaultBankId: saved.id, + onboarded: true, + }); + banks = await api.listBanks(); + } const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0]; - if (!found) throw new Error("the fresh database has no bank account"); + if (!found) throw new Error("no bank account after provisioning"); bank = found; return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`; }); diff --git a/src/pdf/templates/harness.ts b/src/pdf/templates/harness.ts index 861b6a3..edda96f 100644 --- a/src/pdf/templates/harness.ts +++ b/src/pdf/templates/harness.ts @@ -166,7 +166,7 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null { if (!hit) { if (kind === "real") { // 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect. - const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png")); + const png = readFileSync(path.resolve(process.cwd(), "src/pdf/testing/fixtures/sample-wordmark-logo.png")); const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)]; hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true }; } else if (kind === "knockout") { diff --git a/src/views/AppSettings.tsx b/src/views/AppSettings.tsx index f691375..372170d 100644 --- a/src/views/AppSettings.tsx +++ b/src/views/AppSettings.tsx @@ -440,7 +440,7 @@ export default function AppSettings({ - + t.id === "integrations")} /> diff --git a/src/views/Onboarding.tsx b/src/views/Onboarding.tsx index 83ca89c..4e56c1c 100644 --- a/src/views/Onboarding.tsx +++ b/src/views/Onboarding.tsx @@ -46,6 +46,8 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) { const [bank, setBank] = useState(bankers()); const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`); const [padding, setPadding] = useState(3); + // The series that is active when the wizard opens. A re-run keeps it (and its counter) unless changed. + const [loadedSeries, setLoadedSeries] = useState<{ prefix: string; padding: number } | null>(null); useEffect(() => { let active = true; @@ -58,6 +60,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) { if (series) { setPrefix(series.prefix); setPadding(series.padding); + setLoadedSeries({ prefix: series.prefix, padding: series.padding }); } } catch { /* first run – defaults are fine */ @@ -89,7 +92,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) { phoneError(form.vendorPhone) || gstinProblem, ), - [form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem], + [form.vendorName, form.vendorStateCode, form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem], ); const finish = async () => { @@ -97,10 +100,13 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) { setError(null); try { const savedBank = await api.saveBank({ ...bank, isDefault: true }); - await api.startNewSeries(prefix.trim() || `INV/${new Date().getFullYear()}-`, padding); + const wantedPrefix = prefix.trim() || `INV/${new Date().getFullYear()}-`; + if (!loadedSeries || loadedSeries.prefix !== wantedPrefix || loadedSeries.padding !== padding) { + await api.startNewSeries(wantedPrefix, padding); + } const next: Settings = { ...form, - vendorName: form.vendorName.trim() || "Vendor", + vendorName: form.vendorName.trim(), defaultBankId: savedBank.id, onboarded: true, }; @@ -120,8 +126,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {

- These details are printed on every invoice. They are pre-filled from your existing invoice — - adjust anything that has changed. + These details are printed on every invoice. You can change them later in Settings.

@@ -207,6 +212,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) { value={form.vendorStateCode} onChange={(e) => set("vendorStateCode", e.target.value)} > +