Start the setup wizard blank and add a Run setup again button

The first-run seed no longer inserts the original vendor's name, address, contact
details, PAN or bank account; the wizard now starts empty and requires a name and
state. Settings > Data gets a Run setup again button that clears the onboarded flag.
Re-running the wizard keeps the active invoice series unless the prefix or padding
changes. The sample logo moves out of public/ so it no longer ships in the app.
This commit is contained in:
2026-10-05 09:39:02 +05:30
parent 47c188a6cd
commit edcb3d2b13
11 changed files with 109 additions and 56 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Voiced # 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 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 local database, remembers your vendor, client and bank details, and works without an account or a network
+1 -1
View File
@@ -74,7 +74,7 @@ Everything can be built with View backgroundColor and borders. Svg is only neede
## Mapping to Voiced fields ## 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). - Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'. - '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}'. - Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
+1 -1
View File
@@ -116,7 +116,7 @@ async function main() {
const { computeLogoPlacement, slotFor } = await loadPlacement(); const { computeLogoPlacement, slotFor } = await loadPlacement();
mkdirSync(OUT_DIR, { recursive: true }); 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); const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0); sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
const syn = syntheticLogos(createCanvas); const syn = syntheticLogos(createCanvas);
+1 -1
View File
@@ -940,7 +940,7 @@ mod tests {
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap(); 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 inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).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(); let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
assert!(err.contains("bank account"), "{err}"); assert!(err.contains("bank account"), "{err}");
} }
+25 -35
View File
@@ -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. /// 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 /// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax). /// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
fn seed(conn: &Connection) -> rusqlite::Result<()> { fn seed(conn: &Connection) -> rusqlite::Result<()> {
let settings_count: i64 = let settings_count: i64 =
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?; 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, (id, vendor_name, vendor_address, vendor_email, vendor_phone,
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate, vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme) default_tax_type, payment_terms_days, currency, onboarded, theme)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#, VALUES (1, '', '', '', '', '', '', '', 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],
)?; )?;
} }
@@ -497,6 +467,25 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
Ok(()) 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. /// A migrated, seeded in-memory database for unit tests of the command logic.
#[cfg(test)] #[cfg(test)]
pub fn open_in_memory() -> Result<Connection, DbError> { pub fn open_in_memory() -> Result<Connection, DbError> {
@@ -504,6 +493,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "foreign_keys", "ON")?;
migrations().to_latest(&mut conn)?; migrations().to_latest(&mut conn)?;
seed(&conn)?; seed(&conn)?;
seed_test_fixture(&conn)?;
Ok(conn) Ok(conn)
} }
@@ -726,7 +716,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.unwrap(); .unwrap();
assert_eq!(tax_type, "none"); assert_eq!(tax_type, "none");
assert_eq!(gst_reg, "unregistered"); assert_eq!(gst_reg, "unregistered");
assert_eq!(name, "Test Vendor"); assert_eq!(name, "");
assert!(backups_in(&backups).is_empty()); assert!(backups_in(&backups).is_empty());
} }
+2 -2
View File
@@ -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 /// 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) { fn quantize_colour(img: &mut RgbaImage) {
for p in img.pixels_mut() { for p in img.pixels_mut() {
for c in 0..3 { for c in 0..3 {
@@ -535,7 +535,7 @@ mod tests {
#[test] #[test]
fn sample_logo_is_trimmed_and_small() { 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 bytes = std::fs::read(path).unwrap();
let out = process_logo(&bytes).unwrap(); let out = process_logo(&bytes).unwrap();
let m = &out.metrics; let m = &out.metrics;
+38 -3
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/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 { api } from "../lib/api";
import type { Settings } from "../lib/types";
import { import {
backupFileName, backupFileName,
ensureZipExtension, ensureZipExtension,
@@ -20,8 +21,15 @@ type RestoreStep = "confirm" | "staging" | "ready";
const joinPath = (dir: string, name: string) => const joinPath = (dir: string, name: string) =>
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`; !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. */ interface DataBackupPanelProps {
export default function DataBackupPanel() { /** 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 toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null); const [status, setStatus] = useState<BackupStatus | null>(null);
const [backingUp, setBackingUp] = useState(false); const [backingUp, setBackingUp] = useState(false);
@@ -32,6 +40,20 @@ export default function DataBackupPanel() {
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null); 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 () => { const refresh = useCallback(async () => {
try { try {
setStatus(await api.getBackupStatus()); setStatus(await api.getBackupStatus());
@@ -229,6 +251,19 @@ export default function DataBackupPanel() {
</div> </div>
</Tile> </Tile>
<Tile>
<h2 className="voiced-section-title">Setup wizard</h2>
<p className="voiced-muted">
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.
</p>
<div className="voiced-actions">
<Button size="md" kind="tertiary" onClick={() => void rerunSetup()}>
Run setup again
</Button>
</div>
</Tile>
<Modal <Modal
open={restorePath !== null} open={restorePath !== null}
danger={step === "confirm"} danger={step === "confirm"}
+26 -4
View File
@@ -7,7 +7,8 @@ import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
/** /**
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test * The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
* launches with (it seeds the unregistered vendor, a default bank and an active series). * launches with. The fresh database is neutral (no vendor, no bank), so the first step provisions a
* fake vendor and bank the way the setup wizard would.
*/ */
const today = () => { const today = () => {
@@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
let bank!: BankAccount; let bank!: BankAccount;
let invoice: Invoice | null = null; let invoice: Invoice | null = null;
const loaded = await log.run("e2e: get settings and default bank", async () => { const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => {
s = await api.getSettings(); 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]; 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; bank = found;
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`; return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
}); });
+1 -1
View File
@@ -166,7 +166,7 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
if (!hit) { if (!hit) {
if (kind === "real") { if (kind === "real") {
// 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect. // 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)]; 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 }; hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true };
} else if (kind === "knockout") { } else if (kind === "knockout") {
+1 -1
View File
@@ -440,7 +440,7 @@ export default function AppSettings({
</section> </section>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<DataBackupPanel /> <DataBackupPanel settings={settings} onRerunSetup={onSaved} />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} /> <ErpnextSettingsPanel active={tab === SETTINGS_TABS.findIndex((t) => t.id === "integrations")} />
+12 -6
View File
@@ -46,6 +46,8 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
const [bank, setBank] = useState<BankAccount>(bankers()); const [bank, setBank] = useState<BankAccount>(bankers());
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`); const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3); 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(() => { useEffect(() => {
let active = true; let active = true;
@@ -58,6 +60,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
if (series) { if (series) {
setPrefix(series.prefix); setPrefix(series.prefix);
setPadding(series.padding); setPadding(series.padding);
setLoadedSeries({ prefix: series.prefix, padding: series.padding });
} }
} catch { } catch {
/* first run – defaults are fine */ /* first run – defaults are fine */
@@ -89,7 +92,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
phoneError(form.vendorPhone) || phoneError(form.vendorPhone) ||
gstinProblem, gstinProblem,
), ),
[form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem], [form.vendorName, form.vendorStateCode, form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem],
); );
const finish = async () => { const finish = async () => {
@@ -97,10 +100,13 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
setError(null); setError(null);
try { try {
const savedBank = await api.saveBank({ ...bank, isDefault: true }); 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 = { const next: Settings = {
...form, ...form,
vendorName: form.vendorName.trim() || "Vendor", vendorName: form.vendorName.trim(),
defaultBankId: savedBank.id, defaultBankId: savedBank.id,
onboarded: true, onboarded: true,
}; };
@@ -120,8 +126,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
<Grid narrow> <Grid narrow>
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}> <p className="voiced-muted" style={{ marginBottom: "1rem" }}>
These details are printed on every invoice. They are pre-filled from your existing invoice — These details are printed on every invoice. You can change them later in Settings.
adjust anything that has changed.
</p> </p>
</Column> </Column>
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
@@ -207,6 +212,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
value={form.vendorStateCode} value={form.vendorStateCode}
onChange={(e) => set("vendorStateCode", e.target.value)} onChange={(e) => set("vendorStateCode", e.target.value)}
> >
<SelectItem value="" text="Choose your state" disabled hidden />
{INDIAN_STATES.map((s) => ( {INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} /> <SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))} ))}
@@ -392,7 +398,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
{saving ? "Saving…" : "Finish setup"} {saving ? "Saving…" : "Finish setup"}
</Button> </Button>
) : ( ) : (
<Button onClick={() => setStep((s) => s + 1)}>Next</Button> <Button disabled={step === 0 && businessInvalid} onClick={() => setStep((s) => s + 1)}>Next</Button>
)} )}
</div> </div>
</div> </div>