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
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
+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
- 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}'.
+1 -1
View File
@@ -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);
+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 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}");
}
+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.
/// 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<Connection, DbError> {
@@ -504,6 +493,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
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());
}
+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
/// 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;
+38 -3
View File
@@ -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<BackupStatus | null>(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() {
</div>
</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
open={restorePath !== null}
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
* 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 = () => {
@@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
let bank!: BankAccount;
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();
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}"`;
});
+1 -1
View File
@@ -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") {
+1 -1
View File
@@ -440,7 +440,7 @@ export default function AppSettings({
</section>
</TabPanel>
<TabPanel>
<DataBackupPanel />
<DataBackupPanel settings={settings} onRerunSetup={onSaved} />
</TabPanel>
<TabPanel>
<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 [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) {
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
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.
</p>
</Column>
<Column sm={4} md={8} lg={12}>
@@ -207,6 +212,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
value={form.vendorStateCode}
onChange={(e) => set("vendorStateCode", e.target.value)}
>
<SelectItem value="" text="Choose your state" disabled hidden />
{INDIAN_STATES.map((s) => (
<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"}
</Button>
) : (
<Button onClick={() => setStep((s) => s + 1)}>Next</Button>
<Button disabled={step === 0 && businessInvalid} onClick={() => setStep((s) => s + 1)}>Next</Button>
)}
</div>
</div>